-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheleventy.config.js
More file actions
807 lines (712 loc) · 36 KB
/
Copy patheleventy.config.js
File metadata and controls
807 lines (712 loc) · 36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
const yaml = require("js-yaml");
const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
// ---- Edition switch -------------------------------------------------------
// One repo, two editions. Pick with EDITION=com|org (default: org).
// EDITION=org -> imqueue.org, "Terminal" skin, output _site-org
// EDITION=com -> imqueue.com, "Flux" skin, output _site-com
const { buildAssetManifest } = require("./scripts/lib/asset-manifest.js");
const EDITION = (process.env.EDITION || "org").toLowerCase();
const isCom = EDITION === "com";
const SKIN = isCom ? "flux" : "terminal";
const SITE_URL = isCom ? "https://imqueue.com" : "https://imqueue.org";
const OTHER_URL = isCom ? "https://imqueue.org" : "https://imqueue.com";
const OUTPUT = isCom ? "_site-com" : "_site-org";
// Computed once at config time (sync fs reads) so the hashed names are available
// both to addPassthroughCopy and to the `asset` filter.
const ASSETS = buildAssetManifest(__dirname, EDITION);
module.exports = function (eleventyConfig) {
const markdownIt = require("markdown-it");
const mdAnchor = require("markdown-it-anchor");
const mdToc = require("markdown-it-table-of-contents");
const mdAttrs = require("markdown-it-attrs");
// ---- heading ids ---------------------------------------------------------
// The rule, and the long explanation of why it is not markdown-it-anchor's
// default, moved to scripts/lib/md-slug.js — the search index deep-links to
// `<page-url>#<slug>` and has to mint the same slugs from the same headings,
// which makes this the third consumer of one function rather than the second.
const { slugify } = require("./scripts/lib/md-slug.js");
const md = markdownIt({ html: true, linkify: false, typographer: false })
.use(mdAttrs)
.use(mdAnchor, { permalink: false, tabIndex: false, slugify })
.use(mdToc, {
includeLevel: [2, 3],
containerHeaderHtml: undefined,
markerPattern: /^\[\[toc\]\]/im,
// Must be the SAME function markdown-it-anchor got. Both plugins default
// to their own copy of the encodeURIComponent slugifier, which is the only
// reason the "On this page" hrefs matched the heading ids before; passing
// it to one and not the other would silently break every TOC link.
slugify,
});
eleventyConfig.setLibrary("md", md);
eleventyConfig.addPlugin(syntaxHighlight);
// Standard LiquidJS: quoted/variable partials + comma-separated include args.
eleventyConfig.setLiquidOptions({
dynamicPartials: true,
jekyllInclude: false,
strictFilters: false,
});
eleventyConfig.addDataExtension("yml", (contents) => yaml.load(contents));
// ---- the canonical definition is one string, and stays one string ---------
// src/_data/site.yml carries `definition` (24 words, on every surface verbatim)
// and `definitionShort` for the two headings the full sentence does not fit.
// The whole mechanism depends on them being the SAME claim: an answer engine
// pins an entity by finding one definitional string agreeing across independent
// surfaces, and this project already had seven variants and no consensus once.
//
// A prefix cannot be enforced by a comment. Editing `definition` without
// editing `definitionShort` is the obvious way to reintroduce variant eight,
// and it would fail nothing and look fine — so it fails the build instead.
{
const site = yaml.load(require("node:fs").readFileSync("./src/_data/site.yml", "utf8"));
const full = String(site.definition || "").replace(/\s+/g, " ").trim();
const short = String(site.definitionShort || "").replace(/\s+/g, " ").trim();
if (!full || !short || !full.startsWith(short)) {
throw new Error(
"src/_data/site.yml: definitionShort must be a literal prefix of definition.\n" +
` definition: ${full}\n` +
` definitionShort: ${short}\n` +
"Edit both together, or the site ships two different definitions of itself.",
);
}
}
// Edition-wide values available in every template.
eleventyConfig.addGlobalData("edition", EDITION);
eleventyConfig.addGlobalData("skin", SKIN);
eleventyConfig.addGlobalData("siteUrl", SITE_URL);
eleventyConfig.addGlobalData("otherUrl", OTHER_URL);
eleventyConfig.addGlobalData("siteName", "@imqueue");
// ---- SEO defaults (per edition) -----------------------------------------
// Page front matter can override `ogType`, `ogImage` and `description`; these
// are the site-wide fallbacks head.html reaches for.
//
// There is no `siteKeywords` any more: it only ever fed <meta name="keywords">,
// which Google has ignored since 2009. Per-page `keywords` front matter is
// still read — post.html puts it in BlogPosting.keywords, which is a real
// schema.org property.
eleventyConfig.addGlobalData("siteImage", `${SITE_URL}/images/og-${EDITION}.png`);
eleventyConfig.addGlobalData("siteLocale", "en_US");
eleventyConfig.addGlobalData("themeColor", isCom ? "#0c0a17" : "#0a0e0d");
eleventyConfig.addGlobalData("twitterHandle", "@imqueue");
// ---- analytics (per edition, from the environment) -----------------------
// These ids used to be hardcoded here, both editions sharing one GA4 property,
// and the pair was wrong: the id in this file belongs to the property named
// imqueue.com, so imqueue.org's traffic was recorded there while the property
// named imqueue.org received nothing from the page. A repo cannot tell you which
// property owns an id, which is exactly why they do not belong in a repo.
//
// Each Pages project now supplies its own, so the deployment is the single source
// of truth and the two sites cannot silently share a property again:
//
// GA4_MEASUREMENT_ID G-… for THIS edition's property
// CLARITY_PROJECT_ID Clarity project id for this edition
//
// Suffixed forms win when present (GA4_MEASUREMENT_ID_ORG / _COM), which is what
// makes a local `npm run build:all` able to give each edition its own id in one
// process. Unset means the tag is not emitted at all — so a local build, a fork
// and a preview deploy send nothing to production analytics. That is a feature:
// the previous arrangement had every `npm run serve:org` reporting as real traffic.
// null, never "" — Liquid treats an empty string as TRUTHY, so `{% if analytics.ga4 %}`
// in head.html would happily emit a tag with no id. Only nil and false are falsy there.
const envId = (name) =>
process.env[`${name}_${EDITION.toUpperCase()}`] || process.env[name] || null;
// Correct per-edition ids, READ OUT OF GA4 ADMIN on 2026-08-02 (Admin → Data streams
// → the stream → Measurement ID) rather than copied from the previous value here,
// which was wrong: G-EQTNPY721G belongs to the property named imqueue.com, so
// imqueue.org's traffic was recorded there and the property named imqueue.org got
// none of it.
//
// These are the FALLBACK. An env var always wins, and the deployment should own
// these values — but a Cloudflare Pages build was not receiving GA4_MEASUREMENT_ID
// even when set as plaintext (both sites shipped with no tag at all), and an
// env-only design means the site silently stops being measured whenever that
// happens. These ids are public — they ship in every page's source — so the only
// thing keeping them out of the repo was tidiness, and tidiness lost to a day of
// lost analytics.
const ANALYTICS_DEFAULTS = {
org: { ga4: "G-CZ1JYCB5TK", clarity: "josp89y34k" },
com: { ga4: "G-EQTNPY721G", clarity: "josp89y34k" },
};
// ...except under `npm run serve:*` / watch, where emitting the tag would report
// local browsing as production traffic. That used to happen on every dev run, and
// is part of why the org property reads 88.8% Direct. An explicit env var still
// wins here, so measuring a local build stays possible on purpose.
const isDevServer = ["serve", "watch"].includes(process.env.ELEVENTY_RUN_MODE);
const fallback = isDevServer ? { ga4: null, clarity: null } : ANALYTICS_DEFAULTS[EDITION];
const ANALYTICS = {
ga4: envId("GA4_MEASUREMENT_ID") || fallback.ga4,
clarity: envId("CLARITY_PROJECT_ID") || fallback.clarity,
};
for (const [key, value] of Object.entries(ANALYTICS)) {
console.log(`[analytics] ${EDITION} ${key}: ${value || "not emitted"}`);
}
eleventyConfig.addGlobalData("analytics", ANALYTICS);
// Full ISO 8601 for structured data and OG article timestamps. Schema.org and
// Open Graph both want an unambiguous instant; the date-only "%Y-%m-%d" that
// used to be emitted leaves the time zone to the consumer's guess.
eleventyConfig.addFilter("isoDate", (value) => {
const d = value instanceof Date ? value : new Date(value);
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
});
// Build only the active edition's pages.
eleventyConfig.ignores.add(isCom ? "src/org/**" : "src/com/**");
// Markdown content pages (docs/tutorial/cli/get-started) — used to emit
// per-page ".md" mirrors, which is what AI agents read: the @imqueue MCP
// server's get_doc fetches "<page-url>index.md".
//
// The generated API reference is included, but only its /latest/ pages.
// Archived majors are left out deliberately: they are already noindex, and an
// agent should never be handed a stale API surface.
// The /api/ LANDING page is deliberately absent here even though it has a
// markdown source. src/org/api/index.md is a Liquid template, not prose: its
// body is a JSON-LD block, two card grids, a state-persistence <script> and
// five `{% include %}`s that hold the actual guide. `rawInput` is none of
// that, so md-mirror.liquid published the template source — including a
// literal `"softwareVersion": "{{ latest_rpc }}"` — as the API reference an
// agent reads. src/org/mirrors/api.liquid mirrors it by hand instead, on the
// same model as the other authored mirrors, and includes the same guide
// partials the HTML page and llms-full.txt do.
const API_MIRRORED = /^\/api\/[^/]+\/latest\//;
eleventyConfig.addCollection("contentMd", (api) =>
api.getAll().filter((item) => {
const url = item.url || "";
if (!item.inputPath.endsWith(".md") || item.data.draft) {
return false;
}
return url.includes("/api/") ? API_MIRRORED.test(url) : true;
})
);
// ---- llms-full.txt document order ---------------------------------------
// The concatenated corpus, in a deliberate reading order rather than in whatever
// order `collections.all` happens to be in.
//
// The old template iterated collections.all unsorted, and the result was measured:
// the file opened on "Benchmarking @imqueue: throughput and delivery modes", with
// `cli` starting at line 4,680, `get-started` at 5,664, `mcp` at 5,856 and
// `tutorial` at 7,868 — documentation proper began ~45% in and the tutorial sat at
// 75%, in a 499 KB / ~125k-token file. A retrieval system that truncates keeps the
// BEGINNING, so the ordering decided what survived truncation, and it was decided
// by accident.
//
// Order: orientation, then the course, then reference, then articles, then legal.
// /, /intro/ and /docs/ are not here — they are authored as HTML templates, so the
// template front-loads them from the same shared includes their mirrors use.
//
// The tail matters as much as the head: anything with a markdown source that no
// rule above claims is APPENDED rather than dropped. The equivalent condition in
// llms.liquid was a hard-coded URL test, and it silently omitted the home page,
// /using-ai-assistants/, /contact/ and /blog/ for months. A page missing from this
// file is a page missing from the corpus an agent ingests.
const LLMS_FULL_LEAD = [
"/get-started/",
"/glossary/",
"/using-ai-assistants/",
"/compare/",
];
const LLMS_FULL_SECTIONS = ["/tutorial/", "/cli/", "/mcp/", "/agents/"];
const LLMS_FULL_TAIL = [
"/license/",
"/support/",
"/contributing/",
"/contact/",
"/privacy/",
"/terms/",
];
eleventyConfig.addCollection("llmsFull", (api) => {
const eligible = api.getAll().filter((item) => {
const url = item.url || "";
return item.inputPath.endsWith(".md")
&& !item.data.draft
&& !url.includes("/api/");
});
const byUrl = new Map(eligible.map((item) => [item.url, item]));
const out = [];
const taken = new Set();
const take = (item) => {
if (!item || taken.has(item.url)) return;
taken.add(item.url);
out.push(item);
};
for (const url of LLMS_FULL_LEAD) take(byUrl.get(url));
for (const prefix of LLMS_FULL_SECTIONS) {
// Same chapter-order rule the sidebars and llms.txt use: `chapter:` front
// matter first, then url, so a section index leads its own section.
const section = eligible
.filter((item) => (item.url || "").startsWith(prefix))
.sort((a, b) => {
const ca = a.data.chapter;
const cb = b.data.chapter;
if (typeof ca === "number" && typeof cb === "number") return ca - cb;
if (typeof ca === "number") return -1;
if (typeof cb === "number") return 1;
return (a.url || "").localeCompare(b.url || "");
});
for (const item of section) take(item);
}
// Articles, newest first — the same order /blog/ presents them in.
const posts = eligible
.filter((item) => (item.url || "").startsWith("/blog/") && item.date)
.sort((a, b) => b.date - a.date);
for (const item of posts) take(item);
// Whatever is left, before the legal tail, so an unclassified page lands in the
// body rather than after the boilerplate.
const tail = new Set(LLMS_FULL_TAIL);
for (const item of eligible) {
if (!tail.has(item.url)) take(item);
}
for (const url of LLMS_FULL_TAIL) take(byUrl.get(url));
return out;
});
// Blog posts (.org only) — src/org/blog/posts/*.md, newest→oldest by date.
// Drafts (front matter `draft: true`) build to their URL but are kept out of
// the index listing.
eleventyConfig.addCollection("posts", (api) =>
api
.getFilteredByGlob("src/org/blog/posts/*.md")
.filter((item) => !item.data.draft)
.sort((a, b) => b.date - a.date)
);
// Blog topic hubs (.org only). Posts already declare `topics:`, but nothing
// turned that into pages, so the blog had no taxonomy: no intermediate pages
// to pass link equity through, and 18 of 26 posts sat 3+ clicks from the home
// page because /blog/ only surfaced the newest 8.
//
// A topic needs MIN_TOPIC_POSTS posts to get a hub. Below that a hub is one
// link on a near-empty page, which is index bloat rather than a taxonomy.
const MIN_TOPIC_POSTS = 3;
eleventyConfig.addCollection("blogTopics", (api) => {
const meta = require("./src/_data/blogTopics.json");
const byTopic = new Map();
for (const post of api
.getFilteredByGlob("src/org/blog/posts/*.md")
.filter((p) => !p.data.draft)
.sort((a, b) => b.date - a.date)) {
for (const slug of post.data.topics || []) {
if (!byTopic.has(slug)) byTopic.set(slug, []);
byTopic.get(slug).push(post);
}
}
return [...byTopic.entries()]
.filter(([slug, posts]) =>
posts.length >= MIN_TOPIC_POSTS && meta[slug] && meta[slug].title)
.map(([slug, posts]) => ({
slug,
posts,
label: meta[slug].label,
title: meta[slug].title,
description: meta[slug].description,
}))
.sort((a, b) => b.posts.length - a.posts.length || a.slug.localeCompare(b.slug));
});
// ---- agent-facing markdown ----------------------------------------------
// api-documenter emits HTML tables inside its markdown, plus `<!-- -->`
// spacers and HTML-escaped angle brackets. That renders correctly as HTML but
// is pure overhead for anything reading the ".md" mirror, so flatten it to
// real markdown there. Hand-written pages are passed through untouched.
const API_DOC_MARKER =
"<!-- Do not edit this file. It is automatically generated by API Documenter. -->";
// Only `<!-- -->` is markup inside a cell. A generic <...> strip would eat
// literal placeholders that belong to the prose — `<prefix>:<name>`, `<T>`
// and `<channel>` all appear in cells.
const cell = (html) =>
html
.replace(/<!-- -->/g, "")
.replace(/\s+/g, " ")
.replace(/(?<!\\)\|/g, "\\|") // api-documenter pre-escapes union pipes
.trim();
const tableToMarkdown = (table) => {
const head = [...table.matchAll(/<th>([\s\S]*?)<\/th>/g)].map((m) =>
cell(m[1])
);
if (!head.length) {
return table; // unrecognised shape — leave it alone
}
const rows = [...table.matchAll(/<tr>\s*<td>([\s\S]*?)<\/tr>/g)].map((m) =>
m[1].split(/<\/td>\s*<td>/).map((c) => cell(c.replace(/<\/td>\s*$/, "")))
);
return `\n${[
`| ${head.join(" | ")} |`,
`| ${head.map(() => "---").join(" | ")} |`,
...rows.map((r) => `| ${r.join(" | ")} |`),
].join("\n")}\n`;
};
eleventyConfig.addFilter("agentMarkdown", (content) => {
let out = String(content == null ? "" : content);
const generated = out.includes(API_DOC_MARKER);
// Hand-written pages embed <script type="application/ld+json"> for FAQPage
// markup and, on one page, a behaviour script. Both are markup for a browser
// and neither is content: in the ".md" mirror they arrive as a wall of raw
// JSON (or JavaScript) before the prose, and any Liquid inside them is
// unrendered, so the mirror asserted things like `"softwareVersion":
// "{{ latest_rpc }}"`. The HTML page keeps them; the mirror never wants them.
out = out
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
// An inline SVG is unreadable as markdown and can run to hundreds of lines.
// /tutorial/index.md carried a whole architecture diagram that way. Both
// <desc> and <title> are already authored on every SVG here (image alt text is
// 80/80 on this site), and the <desc> IS the description a non-visual reader
// is meant to get — so keep that and drop the geometry.
out = out.replace(/<svg\b[\s\S]*?<\/svg>/gi, (svg) => {
const desc = /<desc[^>]*>([\s\S]*?)<\/desc>/i.exec(svg);
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
const text = [title && title[1], desc && desc[1]]
.filter(Boolean)
.map((t) => t.replace(/\s+/g, " ").trim())
.join(" — ");
return text ? `\n*Diagram: ${text}*\n` : "";
});
// A <figure> wrapping a <video> is the same problem as the SVG above: nine
// lines of element attributes and inline styles where the reader needs one
// sentence and a URL. /mcp/'s demo recording is the only one, and its
// <figcaption> already says what it shows. The URL is absolutised by the
// siteAbsolute step further down, which only rewrites markdown links — so it
// is written as one here rather than as a bare path.
out = out.replace(/<figure\b[^>]*>[\s\S]*?<\/figure>/gi, (fig) => {
const src = /<video\b[^>]*\bsrc="([^"]+)"/i.exec(fig);
const cap = /<figcaption[^>]*>([\s\S]*?)<\/figcaption>/i.exec(fig);
const text = cap
? cap[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()
: "";
if (!src) return text ? `\n*${text}*\n` : "";
return `\n*Video: ${text || "recording"}* — [watch](${src[1]})\n`;
});
out = out.replace(/<table>[\s\S]*?<\/table>/g, tableToMarkdown);
if (generated) {
out = out
.split(API_DOC_MARKER)
.join("") // an instruction to repo contributors, not to readers
.replace(/<!-- -->/g, "")
.replace(/>/g, ">")
.replace(/</g, "<")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, "&"); // last, so &gt; does not become >
}
// markdown-it-attrs syntax. `## Heading {#explicit-id}` is markup the HTML
// build consumes and the mirror ships verbatim — five CLI guide mirrors had
// `{#update-version-vs-up}` and friends sitting in their headings, which is
// noise at best and a heading a model may quote with the braces attached.
// Removed AFTER any table conversion, since a cell can legitimately contain
// braces.
out = out.replace(/[ \t]*\{#[A-Za-z0-9_-]+\}[ \t]*(?=\r?$)/gm, "");
// HTML comments are instructions to whoever edits the source. The
// api-documenter marker is handled above and is gone by now; what is left is
// authoring notes, which a reader should never see.
out = out.replace(/<!--[\s\S]*?-->/g, "");
// Relative links. A mirror is fetched on its own — by get_doc, by a crawler
// following /llms.txt, or by an agent that was handed the URL — and 1,224
// distinct `](/path/)` targets in the mirrors resolve against nothing in that
// situation. Whatever consumed the file has to already know which host it came
// from, and the whole point of the mirror is that it travels.
//
// Only root-relative hrefs, and only in markdown link/image syntax:
// `](#anchor)` stays a fragment on the page itself, `](https://…)` is already
// absolute, and `](./x)` does not occur here.
out = out.replace(/\]\((\/[^)\s]*)\)/g, `](${SITE_URL}$1)`);
// The mirror templates emit `# {{ title }}` and then the body, and a body that
// starts with its own H1 gives the file two — /api/core/latest/index.md had
// "# @imqueue/core 3.3.1 · API reference" followed by "# core package". Two H1s
// is ambiguous about what the document is, and chunkers split on the first one.
// Only a LEADING H1 is dropped, so an H1 used later as a real section divider
// survives.
//
// CRLF matters here and cost a debugging round: api-documenter writes \r\n, and
// JavaScript's `.` does not match \r — so `/^\s*#\s+.+\n+/` never fired on the
// generated pages, which are exactly the ones with the duplicate H1.
out = out.replace(/^\s*#[ \t]+[^\r\n]+(\r?\n)+/, "");
return out.replace(/\n{3,}/g, "\n\n").trim();
});
// ---- FAQPage, generated from the page's own FAQ section ------------------
// The Q&A pairs were already written, already visible, and already shaped as
// questions — and only two pages in the repo turned them into FAQPage markup,
// by hand. Nine blog posts and /license/ had 55 answered questions between them
// that no engine could read as questions.
//
// Generated from the source rather than hand-written for two reasons. Google
// requires FAQPage answers to be PRESENT ON THE PAGE, and a hand-copied block
// drifts from the prose the moment either is edited — the one that existed had
// already normalised "What's" to "What is" and "don't" to "do not", so the
// markup and the visible text were not the same strings. And 55 pairs is not
// something to transcribe into JSON by hand once, let alone keep in step.
//
// Scoped deliberately narrowly: only `### `-level questions, only inside a
// `## FAQ` / `## Frequently asked…` section, only when the heading ends in a
// question mark, and only when the page has at least two of them. The repo has
// two other FAQ shapes — <details>/<summary> pairs in one post, and question
// headings scattered under ordinary sections in mcp/security.md — which this
// deliberately does NOT match, so their existing hand-written blocks stay
// correct and nothing is emitted twice.
const inlineToText = (s) =>
String(s)
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links -> their text
.replace(/`([^`]+)`/g, "$1") // code spans
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/(^|\s)\*([^*]+)\*/g, "$1$2")
// NAMED inline tags only. A generic /<[^>]+>/ strip eats the angle-bracket
// placeholders that belong to the prose — `imq client generate <name> [path]`
// became "imq client generate [path]" in the FAQPage markup while the page
// said otherwise, which is exactly the drift this generation exists to
// prevent. The `cell()` helper above carries the same warning for the same
// reason; this is the second time that lesson has been paid for.
.replace(/<\/?(?:a|code|em|strong|b|i|span|br|kbd|sup|sub|small|abbr)\b[^>]*>/gi, "")
.replace(/\s+/g, " ")
.trim();
eleventyConfig.addFilter("faqPairs", (raw) => {
const pairs = [];
let inFaq = false;
let question = null;
let answer = [];
const flush = () => {
if (question && answer.length) {
pairs.push({ q: question, a: inlineToText(answer.join(" ")) });
}
question = null;
answer = [];
};
for (const line of String(raw == null ? "" : raw).split("\n")) {
// Any H2 ends the previous answer and decides whether we are in the section.
if (/^##\s/.test(line) && !/^###/.test(line)) {
flush();
inFaq = /^##\s+(FAQ|Frequently asked)/i.test(line);
continue;
}
if (!inFaq) continue;
const heading = line.match(/^###\s+(.+?)\s*$/);
if (heading) {
flush();
question = inlineToText(heading[1]);
continue;
}
if (!question) continue;
// A blank line closes the answer: only the first paragraph after the
// question is the answer, so a follow-up code block or aside is left out
// rather than concatenated into one run-on string.
if (line.trim() === "") {
if (answer.length) flush();
continue;
}
answer.push(line.trim());
}
flush();
return pairs.filter((p) => p.q.endsWith("?"));
});
// The same slugifier markdown-it-anchor writes `id` attributes with, exposed
// to templates. glossary-jsonld.html mints a `@id` per term from its heading,
// and an independent slug implementation there would silently produce fragments
// that do not exist on the page — a graph of edges pointing at nothing.
// Eleventy's built-in `slug`/`slugify` filters are NOT interchangeable here:
// they keep characters this one drops.
eleventyConfig.addFilter("mdSlug", slugify);
// ---- DefinedTermSet, generated from the glossary's own H3 entries --------
// Same argument as faqPairs above, and the same mechanism: the terms are
// already written, already visible, already shaped as `### Term` + definition,
// so transcribing them into JSON by hand would only create a second copy to
// keep honest.
//
// Scoped to `## `-sectioned `### ` headings anywhere in the page — unlike
// faqPairs there is no section-name gate, because a glossary page is a
// glossary throughout. The first paragraph after the heading is the
// definition; a following example or aside is deliberately left out, which is
// what makes each entry a short quotable unit rather than a run-on string.
//
// `inSection` tracks the H2 the term sits under, so the emitted node can carry
// it — the page groups terms by area (Framework, Delivery, CLI) and an engine
// reconciling "fleet" benefits from knowing it is CLI vocabulary.
eleventyConfig.addFilter("definedTerms", (raw) => {
const terms = [];
let term = null;
let body = [];
let section = null;
const flush = () => {
if (term && body.length) {
terms.push({ term, definition: inlineToText(body.join(" ")), section });
}
term = null;
body = [];
};
for (const line of String(raw == null ? "" : raw).split("\n")) {
if (/^##\s/.test(line) && !/^###/.test(line)) {
flush();
section = inlineToText(line.replace(/^##\s+/, ""));
continue;
}
const heading = line.match(/^###\s+(.+?)\s*$/);
if (heading) {
flush();
term = inlineToText(heading[1]);
continue;
}
if (!term) continue;
if (line.trim() === "") {
if (body.length) flush();
continue;
}
body.push(line.trim());
}
flush();
return terms;
});
// ---- llms.txt section ordering -------------------------------------------
// The Tutorial and CLI Guide sections of /llms.txt listed their pages in
// `collections.all` order, which is neither authored nor alphabetical — the
// tutorial read chapter 5, 3, 7, 8, 2, 1, 6, 4. Both trees already carry a
// `chapter:` in front matter, which is what the sidebars sort by, so the index
// an agent reads was the one surface presenting a numbered course out of order.
//
// A model given a shuffled list of steps either follows it or reorders it by
// guessing; neither is what a chapter number is for.
//
// Sorted by `chapter` where present, then by url so a page that has none is
// still deterministic rather than build-order dependent.
eleventyConfig.addFilter("bySection", (items, prefix) =>
(items || [])
.filter((item) => (item.url || "").includes(prefix))
.sort((a, b) => {
const ca = a.data.chapter;
const cb = b.data.chapter;
if (typeof ca === "number" && typeof cb === "number") return ca - cb;
if (typeof ca === "number") return -1;
if (typeof cb === "number") return 1;
return (a.url || "").localeCompare(b.url || "");
})
);
// The most recent editorial change anywhere on the site, as YYYY-MM-DD.
//
// /llms.txt and /llms-full.txt are the two files this site asks agents to
// ingest, and neither said when it was generated. A cached copy of an index is
// indistinguishable from a current one, so there was no way for a consumer to
// decide whether to refetch.
//
// Deliberately NOT the build timestamp: Cloudflare Pages rebuilds on every
// deploy, so a build date moves when nothing changed, which is exactly the
// discardable freshness signal pageDates.json exists to avoid. This is the
// maximum of the real committed dates — the same values the sitemap's per-bucket
// lastmod maxima come from.
eleventyConfig.addFilter("latestModified", (posts) => {
const stamps = Object.values(require("./src/_data/pageDates.json"))
.flatMap((entry) => [entry.modified, entry.published])
.concat((posts || []).map((p) => p.data.dateModified || p.date))
.map((value) => (value ? new Date(value).getTime() : NaN))
.filter((t) => !Number.isNaN(t));
return stamps.length
? new Date(Math.max(...stamps)).toISOString().slice(0, 10)
: "";
});
// Posts written by a given author slug (newest first).
eleventyConfig.addFilter("byAuthor", (posts, slug) =>
(posts || []).filter((p) => p.data.author === slug)
);
// Look up a single author record by slug from the authors data list.
eleventyConfig.addFilter("authorBySlug", (authors, slug) =>
(authors || []).find((a) => a.slug === slug)
);
// Related posts: others sharing the most `topics` with the current one,
// newest first as the tie-breaker; falls back to filling with recent posts.
eleventyConfig.addFilter("related", (posts, currentUrl, topics, limit) => {
const want = new Set(topics || []);
const others = (posts || []).filter((p) => p.url !== currentUrl);
const scored = others
.map((p) => ({
p,
score: (p.data.topics || []).filter((t) => want.has(t)).length,
}))
.sort((a, b) => b.score - a.score || b.p.date - a.p.date);
const n = limit || 5;
const picked = scored.filter((x) => x.score > 0).slice(0, n).map((x) => x.p);
if (picked.length < n) {
for (const x of scored) {
if (picked.length >= n) break;
if (!picked.includes(x.p)) picked.push(x.p);
}
}
return picked;
});
// Reverse mesh: given a list of topics (declared by a docs/tutorial/cli area),
// return the blog posts sharing the most topics, newest first. Drafts excluded.
// Unlike `related` it does NOT backfill — a docs page only links posts that are
// genuinely on-topic (empty result -> the "From the blog" block is omitted).
eleventyConfig.addFilter("postsByTopics", (posts, topics, limit) => {
const want = new Set(topics || []);
if (!want.size) return [];
return (posts || [])
.filter((p) => !p.data.draft)
.map((p) => ({ p, score: (p.data.topics || []).filter((t) => want.has(t)).length }))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || b.p.date - a.p.date)
.slice(0, limit || 4)
.map((x) => x.p);
});
// Static assets: shared first, then the active edition's theme css (same /css dir).
eleventyConfig.addPassthroughCopy({ "src/_shared/fonts": "fonts" });
// CSS + JS are emitted under content-hashed filenames and referenced through the
// `asset` filter, which is what lets /css/* and /js/* be cached immutably (see
// scripts/lib/asset-manifest.js for the deploy bug this fixes, and
// src/headers.liquid for the matching Cache-Control). Only hashed names are
// written — no unhashed copy — so a wildcard immutable header cannot ever apply
// to a mutable URL.
for (const [from, to] of ASSETS.copies) {
eleventyConfig.addPassthroughCopy({ [from]: to });
}
eleventyConfig.addGlobalData("assetManifest", ASSETS.manifest);
// Resolve a logical asset URL ("/css/base.css") to its hashed one. Throws on an
// unknown path rather than passing it through: a silent miss would emit a 404ing
// stylesheet that renders as an unstyled page, and the link checker only sees
// what the templates actually wrote.
eleventyConfig.addFilter("asset", function (url) {
const hashed = ASSETS.manifest[url];
if (!hashed) {
throw new Error(
`asset filter: no hashed build of "${url}". ` +
`Known: ${Object.keys(ASSETS.manifest).join(", ")}`,
);
}
return hashed;
});
// ---- search index --------------------------------------------------------
// Written after the build rather than by a template, because it is derived from
// the built markdown MIRRORS — see scripts/lib/search-corpus.js for why that is
// the right source and what indexing the HTML would cost. A template also could
// not read /api/search-index.json, which is itself a template's output.
//
// `eleventy.after` fires on every build including each incremental rebuild under
// `--serve`, so the dev preview cannot serve a stale index.
//
// BOTH editions, which reverses an earlier decision. The first version was org-only,
// on the grounds that imqueue.com is seven pages with a nav that fits them all — true,
// and it missed the point: com's search is not for finding com's pages, it is for
// reaching the documentation from the site where somebody is evaluating a licence. Its
// own index costs 0.9 KB + 11 KB gzipped, and publishing it is also what lets the
// @imqueue MCP server keep full coverage if it ever moves off llms.txt, since it
// indexes both domains today.
//
// Deliberately NOT wrapped in try/catch. Everything else that touches the
// request path here fails open, but this is build time: an index that is silently
// absent would ship a search box that finds nothing, which is worse than a red
// build. The size budget in the generator throws for the same reason.
eleventyConfig.on("eleventy.after", ({ dir }) => {
require("./scripts/gen-search-index.js").generate(dir.output);
});
eleventyConfig.addPassthroughCopy({ "images": "images" });
eleventyConfig.addPassthroughCopy({ [`src/${EDITION}/favicon.svg`]: "favicon.svg" });
eleventyConfig.addPassthroughCopy({ [`src/${EDITION}/favicon.ico`]: "favicon.ico" });
// robots.txt + sitemap.xml are generated per edition (see src/robots.liquid,
// src/sitemap.liquid) so each domain advertises its own sitemap URL.
// Per-edition _redirects (Cloudflare Pages). imqueue.com 301s legacy content
// paths to imqueue.org; imqueue.org 301s retired versioned API URLs to /latest/.
eleventyConfig.addPassthroughCopy({ [`src/${EDITION}/_redirects`]: "_redirects" });
// API reference (current + kept archives) is now generated as native Eleventy
// pages under src/org/api/**; the old standalone TypeDoc HTML passthrough is
// gone. Regenerate with `npm run build-docs` (latest) / `gen-api-archive` (old).
return {
dir: {
input: "src",
output: OUTPUT,
includes: "_shared/_includes",
layouts: "_shared/_includes",
data: "_data",
},
markdownTemplateEngine: "liquid",
htmlTemplateEngine: "liquid",
};
};