create-ailk 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +9 -3
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/export-content.d.ts +6 -3
  5. package/dist/export-content.js +79 -10
  6. package/dist/lib/import-mdx.d.ts +42 -0
  7. package/dist/lib/import-mdx.js +531 -45
  8. package/dist/seed-flows.d.ts +1 -1
  9. package/dist/seed-flows.js +9 -4
  10. package/dist/surfaces.js +1 -0
  11. package/package.json +3 -3
  12. package/templates/apps/api/CLAUDE.md +7 -5
  13. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  14. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  15. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  16. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  17. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  18. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  19. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  20. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  21. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  22. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  23. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  24. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  25. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  26. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  27. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  28. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  29. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  30. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  31. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  32. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  33. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  34. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  35. package/templates/apps/web/auth.ts +22 -6
  36. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  37. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  38. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  39. package/templates/apps/web/lib/account-config.ts +52 -14
  40. package/templates/apps/web/lib/auth-email.ts +87 -5
  41. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  42. package/templates/apps/web/messages/en.json +26 -1
  43. package/templates/database/seed.ts +7 -7
  44. package/versions.json +48 -48
  45. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  46. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -19,6 +19,14 @@
19
19
  * reports `unchanged`; a changed layout on a live draft is refused
20
20
  * `draft_is_live` and the page fails with that reason.
21
21
  *
22
+ * A section string that holds inline HTML is converted first (#10804) — a
23
+ * link to `[label](href)`, a line break to a newline, a block to a blank
24
+ * line, every other tag unwrapped to its text — so the words and the layout
25
+ * carry no tag, which the hub's structure write refuses. The layout is then
26
+ * checked against the hub's section-string rules before any write, so a page
27
+ * the hub would refuse fails with the rule and the path, and a page whose
28
+ * draft holds no layout yet is never reported `unchanged`.
29
+ *
22
30
  * create-ailk ships one runtime dependency, so the adapter is never imported:
23
31
  * the caller injects it, and `loadSiteMdxAdapter` resolves the SITE's
24
32
  * installed copy from its `apps/web` at run time (the #6937 precedent), and
@@ -29,7 +37,9 @@
29
37
  * `<slug>/index.mdx` becomes a `BlogPosting` / `DocsDetail` page (the
30
38
  * directory's own `index.mdx` its `BlogList` / `DocsList` index), its
31
39
  * fumadocs frontmatter mapped to the stored vocabulary and written through the
32
- * same converting path. Every outcome names the root it came from.
40
+ * same converting path. Every outcome names the root it came from. A key the
41
+ * vocabulary does not name rides in `siteFields`, and a blog post's tags are
42
+ * sent (#10806); a key the store cannot keep is named with its reason.
33
43
  *
34
44
  * Given `publish` (#10659 D2), each page with no Live version is set Live
35
45
  * after it is written (`POST …/publish`, the draft tip); a page already Live
@@ -41,7 +51,8 @@
41
51
  * a status and a `[a-z0-9_]` error code reach output (SEC-7876-S1).
42
52
  *
43
53
  * Spec: docs/specs/10203-import-mdx-one-mdx-artifact-spec.md; the editorial
44
- * walk docs/specs/10701-import-mdx-walks-the-editorial-spec.md.
54
+ * walk docs/specs/10701-import-mdx-walks-the-editorial-spec.md; its site
55
+ * fields docs/specs/10806-the-editorial-import-drops-blog-spec.md.
45
56
  */
46
57
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
47
58
  import { createRequire } from "node:module";
@@ -71,10 +82,15 @@ const EDITORIAL_TYPES = {
71
82
  /** The slug the runtime reads as a surface's own index. */
72
83
  const INDEX_SLUG = "index";
73
84
  const INDEX_TYPES = new Set(Object.values(EDITORIAL_TYPES).map((k) => k.index));
85
+ /** Each index type's own slug, its editorial directory: `BlogList/blog` shares `/blog` with `BlogList/index` (#10805 D5). */
86
+ const INDEX_OWN_SLUG = new Map(Object.entries(EDITORIAL_TYPES).map(([dir, k]) => [k.index, dir]));
87
+ /** `PAGE_TAG_TYPES` (the validation package's `site-fields.ts`), restated: the blog collection keeps a sent `tags`. */
88
+ const PAGE_TAG_TYPES = new Set(["BlogPosting", "BlogList"]);
74
89
  /**
75
90
  * `fumadocsToArtifactFrontmatter` (`apps/web/lib/runtime-source.ts`) restated —
76
- * fumadocs key to stored key — less `tags`, which the store keeps itself, plus
77
- * `sections`, which `convert` moves. Any other key is not sent.
91
+ * fumadocs key to stored key — less `tags`, which only a blog page sends, plus
92
+ * `sections`, which `convert` moves. Any other key rides in `siteFields`
93
+ * (#10806 D4).
78
94
  */
79
95
  const EDITORIAL_FIELDS = {
80
96
  title: "title",
@@ -89,6 +105,14 @@ const EDITORIAL_FIELDS = {
89
105
  };
90
106
  /** Keys not sent and not reported: the path decides both, and a strip marker. */
91
107
  const EDITORIAL_IGNORED = new Set(["type", "slug", "oss"]);
108
+ /** Why a key is not sent (#10806 D4); `siteFieldRefusal`'s own reasons are the rest. */
109
+ const NOT_SENT = {
110
+ parent: "the path decides a blog post's parent",
111
+ agentTag: "a tag agents act on, or a site tag, is kept by AI",
112
+ notList: "not a list of tags",
113
+ full: "siteFields is full (32 keys or 64 KiB)",
114
+ olderSite: `the site's ${VALIDATION_PACKAGE} predates site fields; update its dependencies and re-run`,
115
+ };
92
116
  /** `wksp_<uuidv7>` — the `@working-theory/ids` shape, restated: no runtime dep. */
93
117
  const WORKSPACE_ID = /^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
94
118
  /** A key is visible ASCII only: what an HTTP header can carry, with no space. */
@@ -338,35 +362,118 @@ function dateString(value) {
338
362
  const iso = value.toISOString();
339
363
  return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
340
364
  }
365
+ /** Below this depth a value is left as read: the site's rule refuses it as too deep. */
366
+ const DATES_MAX_DEPTH = 8;
367
+ /** `value` with every `Date` to a bounded depth as `dateString` gives it (#10806 D4). */
368
+ function datesAsStrings(value, depth = 1) {
369
+ if (value instanceof Date)
370
+ return dateString(value);
371
+ if (depth > DATES_MAX_DEPTH)
372
+ return value;
373
+ if (Array.isArray(value))
374
+ return value.map((item) => datesAsStrings(item, depth + 1));
375
+ if (value === null || typeof value !== "object")
376
+ return value;
377
+ if (Object.getPrototypeOf(value) !== Object.prototype)
378
+ return value;
379
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, datesAsStrings(item, depth + 1)]));
380
+ }
381
+ /** A tag as printed: control characters as `?`, at most 64 characters. */
382
+ function printedTag(tag) {
383
+ // eslint-disable-next-line no-control-regex -- the controls are what is replaced
384
+ return String(tag).replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").slice(0, 64);
385
+ }
341
386
  /**
342
387
  * An editorial file as the adapter would return a page (#10701 D4): its
343
388
  * fumadocs frontmatter mapped to the stored vocabulary, the path deciding the
344
- * type and slug — and, for a blog page, its `parent` (#10775 D3) — and the
345
- * keys not sent named.
389
+ * type and slug — and, for a blog page, its `parent` (#10775 D3). Every other
390
+ * key the site's rule accepts rides in `siteFields`, and a blog page sends its
391
+ * `tags` (#10806 D4); the keys not sent are named with their reasons.
346
392
  */
347
- function editorialSource(page, parsed) {
393
+ function editorialSource(page, parsed, rules) {
348
394
  const frontmatter = {
349
395
  type: page.type,
350
396
  slug: page.slug,
351
397
  };
352
- const dropped = [];
353
- const blog = page.type === "BlogPosting" || page.type === "BlogList";
354
- for (const [key, value] of Object.entries(parsed.data)) {
355
- const stored = Object.hasOwn(EDITORIAL_FIELDS, key) && !(blog && key === "parent")
356
- ? EDITORIAL_FIELDS[key]
357
- : undefined;
358
- if (stored !== undefined)
359
- frontmatter[stored] = dateString(value);
360
- else if (!EDITORIAL_IGNORED.has(key))
361
- dropped.push(key);
398
+ const notSent = [];
399
+ const siteFields = {};
400
+ const blog = PAGE_TAG_TYPES.has(page.type);
401
+ for (const [key, raw] of Object.entries(parsed.data)) {
402
+ if (EDITORIAL_IGNORED.has(key))
403
+ continue;
404
+ if (blog && key === "parent") {
405
+ notSent.push({ key, reason: NOT_SENT.parent });
406
+ }
407
+ else if (Object.hasOwn(EDITORIAL_FIELDS, key)) {
408
+ frontmatter[EDITORIAL_FIELDS[key]] = dateString(raw);
409
+ }
410
+ else if (rules === undefined) {
411
+ notSent.push({ key, reason: NOT_SENT.olderSite });
412
+ }
413
+ else if (blog && key === "tags") {
414
+ if (!Array.isArray(raw)) {
415
+ notSent.push({ key, reason: NOT_SENT.notList });
416
+ continue;
417
+ }
418
+ // Sent as written, so the stored list is what the store computes; a
419
+ // tag it keeps for itself is named.
420
+ frontmatter["tags"] = raw;
421
+ const kept = new Set(rules.publicPageTags(raw));
422
+ for (const tag of new Set(raw.filter((tag) => !kept.has(tag)))) {
423
+ notSent.push({ key: `tags: ${printedTag(tag)}`, reason: NOT_SENT.agentTag });
424
+ }
425
+ }
426
+ else {
427
+ const value = datesAsStrings(raw);
428
+ const refused = rules.refusal(key, value);
429
+ const fits = Object.keys(siteFields).length < rules.maxKeys &&
430
+ new TextEncoder().encode(JSON.stringify({ ...siteFields, [key]: value })).length <=
431
+ rules.maxBytes;
432
+ if (refused !== null)
433
+ notSent.push({ key, reason: refused });
434
+ else if (!fits)
435
+ notSent.push({ key, reason: NOT_SENT.full });
436
+ else
437
+ siteFields[key] = value;
438
+ }
362
439
  }
363
440
  if (page.parent !== undefined)
364
441
  frontmatter["parent"] = page.parent;
442
+ if (Object.keys(siteFields).length > 0)
443
+ frontmatter["siteFields"] = siteFields;
365
444
  return {
366
445
  source: { frontmatter, body: parsed.content },
367
- dropped: dropped.sort(),
446
+ notSent: notSent.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
368
447
  };
369
448
  }
449
+ /** `a (reason); b, c (reason)` — keys in order, one segment per reason. */
450
+ function notSentList(notSent) {
451
+ const byReason = new Map();
452
+ for (const { key, reason } of notSent) {
453
+ byReason.set(reason, [...(byReason.get(reason) ?? []), key]);
454
+ }
455
+ return [...byReason]
456
+ .map(([reason, keys]) => `${keys.join(", ")} (${reason})`)
457
+ .join("; ");
458
+ }
459
+ /**
460
+ * The sent site fields and public tags a write's stored content lacks
461
+ * (#10806 D4): what a hub that predates site fields dropped.
462
+ */
463
+ function notKept(sent, kept, rules) {
464
+ const keptFields = kept["siteFields"] !== null && typeof kept["siteFields"] === "object"
465
+ ? kept["siteFields"]
466
+ : {};
467
+ const keptTags = Array.isArray(kept["tags"]) ? kept["tags"] : [];
468
+ const sentFields = Object.keys((sent["siteFields"] ?? {}));
469
+ const sentTags = PAGE_TAG_TYPES.has(String(sent["type"])) && Array.isArray(sent["tags"])
470
+ ? rules.publicPageTags(sent["tags"])
471
+ : [];
472
+ return [
473
+ ...sentFields.filter((key) => !Object.hasOwn(keptFields, key)).sort(),
474
+ ...sentTags.filter((tag) => !keptTags.includes(tag)).map((tag) => `tags: ${printedTag(tag)}`),
475
+ ];
476
+ }
370
477
  // ── "Unchanged" (D4) ────────────────────────────────────────────────────────
371
478
  function sortKeys(value) {
372
479
  if (Array.isArray(value))
@@ -382,14 +489,21 @@ function canonical(value) {
382
489
  return JSON.stringify(sortKeys(JSON.parse(JSON.stringify(value ?? null))));
383
490
  }
384
491
  /**
385
- * Content without `tags`: the store's tagger writes them on every version and
386
- * drops a sent value (#10342 D5), so they are never the importer's to compare.
492
+ * Content as compared: the store's tagger writes `tags` on every version
493
+ * (#10342 D5), so they are dropped — except a blog page that sends them, whose
494
+ * public tags are the importer's (#10806 D4): compared as `publicTags` gives
495
+ * them, so a changed category is an update.
387
496
  */
388
- function withoutTags(value) {
497
+ function compared(value, sent, publicTags) {
389
498
  if (value === null || typeof value !== "object" || Array.isArray(value))
390
499
  return value;
391
- const { tags: _tags, ...rest } = value;
392
- return rest;
500
+ const { tags, ...rest } = value;
501
+ if (publicTags === undefined ||
502
+ !PAGE_TAG_TYPES.has(String(sent["type"])) ||
503
+ !Array.isArray(sent["tags"])) {
504
+ return rest;
505
+ }
506
+ return { ...rest, tags: publicTags(Array.isArray(tags) ? tags : []) };
393
507
  }
394
508
  function safeCode(value) {
395
509
  return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
@@ -474,16 +588,18 @@ function wrongLocale(at, answer, locale) {
474
588
  }
475
589
  return failed(at, `the API does not serve locale ${locale}`, `not sent: the API does not serve locale ${locale}`);
476
590
  }
477
- async function writePage(t, page, locale, content, layout) {
591
+ async function writePage(t, page, locale, content, layout, publicTags) {
478
592
  const { root, path, type, slug } = page;
479
593
  const pagePath = `${t.base}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`;
480
594
  const query = `?locale=${encodeURIComponent(locale)}`;
481
595
  const address = `${pagePath}${query}`;
482
596
  // The route's `statusOf`: anything but `draft` has a Live version.
483
597
  let live = false;
598
+ let stored;
484
599
  const outcome = (result) => ({
485
600
  outcome: { root, path, result, type, slug },
486
601
  live,
602
+ stored,
487
603
  });
488
604
  // The words are written; now the layout, under the token the draft holds
489
605
  // (`null` for a page that has none yet).
@@ -497,7 +613,11 @@ async function writePage(t, page, locale, content, layout) {
497
613
  if ("network" in put || put.status !== 200)
498
614
  return refusedAnswer(page, put);
499
615
  return (wrongLocale(page, put, locale) ??
500
- outcome(result === "unchanged" && put.data["written"] === true
616
+ outcome(
617
+ // #10804 D6 — with no token the hub writes the layout (or refuses a
618
+ // live tip), so a dry run's `written: false` never makes it unchanged.
619
+ result === "unchanged" &&
620
+ (structureToken === null || put.data["written"] === true)
501
621
  ? "updated"
502
622
  : result));
503
623
  };
@@ -515,8 +635,9 @@ async function writePage(t, page, locale, content, layout) {
515
635
  live = read.data["status"] !== "draft";
516
636
  const token = read.data["structureToken"];
517
637
  const structureToken = typeof token === "string" ? token : null;
518
- if (canonical(withoutTags(read.data["content"])) ===
519
- canonical(withoutTags(content))) {
638
+ stored = read.data["content"];
639
+ if (canonical(compared(stored, content, publicTags)) ===
640
+ canonical(compared(content, content, publicTags))) {
520
641
  return done("unchanged", structureToken);
521
642
  }
522
643
  // The token is the version just compared against, so a draft saved
@@ -524,6 +645,7 @@ async function writePage(t, page, locale, content, layout) {
524
645
  const saved = await call(t, "PATCH", address, content, version);
525
646
  if ("network" in saved || saved.status !== 200)
526
647
  return refusedAnswer(page, saved);
648
+ stored = saved.data["content"];
527
649
  // A save carries the structure token forward (#10330 D5).
528
650
  return (wrongLocale(page, saved, locale) ?? done("updated", structureToken));
529
651
  }
@@ -536,9 +658,10 @@ async function writePage(t, page, locale, content, layout) {
536
658
  if ("network" in created)
537
659
  return refusedAnswer(page, created);
538
660
  if (created.status === 201) {
661
+ stored = created.data["content"];
539
662
  return wrongLocale(page, created, locale) ?? done("created");
540
663
  }
541
- // 409 slug_taken: another type holds this page's registry link (#10268
664
+ // 409 slug_taken: another page holds this page's registry link (#10268
542
665
  // D5) — reported as it is, since a re-read at this address finds nothing.
543
666
  // Any other 409: created by someone else since the read — read it once more.
544
667
  if (created.status !== 409 || created.code === "slug_taken") {
@@ -564,6 +687,323 @@ export function formatImportMdxReport(result) {
564
687
  `created ${created} · updated ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failures}`,
565
688
  ].join("\n")}\n`;
566
689
  }
690
+ // ── Inline HTML in section strings (#10804) ─────────────────────────────────
691
+ /** `HTML_TAG` (`content-draft.ts`, from `derive-publish.ts`, SEC-10589-01), restated: no runtime dep. */
692
+ const HTML_TAG = /<[A-Za-z!/?]/;
693
+ /** `checkSectionData`'s string and key rules (`content-draft.ts`), restated: no runtime dep. */
694
+ const SECTION_STRING_MAX = 2_000;
695
+ const SECTION_KEY_MAX = 100;
696
+ const SCRIPT_SCHEME = /^(?:javascript|vbscript|data):/;
697
+ /** `FAQ_ALLOWED_HREF` (`faq-questions.ts`), the hrefs `parseFaqAnswer` draws as links, restated. */
698
+ const LINK_HREF = /^(?:\/|#|https?:|mailto:|tel:)/i;
699
+ /** Elements removed with their content (D3). */
700
+ const DROPPED_ELEMENTS = new Set([
701
+ "script",
702
+ "style",
703
+ "template",
704
+ "iframe",
705
+ "noscript",
706
+ "svg",
707
+ "object",
708
+ "video",
709
+ "audio",
710
+ "canvas",
711
+ ]);
712
+ /** Elements whose open or close tag is a paragraph break (D3). */
713
+ const BLOCK_ELEMENTS = new Set([
714
+ "p",
715
+ "div",
716
+ "section",
717
+ "article",
718
+ "header",
719
+ "footer",
720
+ "blockquote",
721
+ "figure",
722
+ "figcaption",
723
+ "pre",
724
+ "table",
725
+ "tr",
726
+ "dl",
727
+ "dt",
728
+ "dd",
729
+ "h1",
730
+ "h2",
731
+ "h3",
732
+ "h4",
733
+ "h5",
734
+ "h6",
735
+ "ul",
736
+ "ol",
737
+ "hr",
738
+ ]);
739
+ /** What the layout keeps of a tag (D3): `a` only when it becomes a link. */
740
+ const CARRIED_ELEMENTS = new Set(["a", "br", "p", "ul", "li"]);
741
+ const NAMED_ENTITIES = {
742
+ amp: "&",
743
+ lt: "<",
744
+ gt: ">",
745
+ quot: '"',
746
+ apos: "'",
747
+ nbsp: "\u00a0",
748
+ };
749
+ const ENTITY = /&(?:#(\d+)|#[xX]([0-9A-Fa-f]+)|(amp|lt|gt|quot|apos|nbsp));/g;
750
+ const TAG_NAME = /\/?([A-Za-z][A-Za-z0-9-]*)/y;
751
+ const TAG_END = /[ \t\r\n\f]*(\/?)>/y;
752
+ /**
753
+ * One attribute, or one stray character a browser would skip. A `<` ends the
754
+ * scan whether it is unquoted or inside a quoted value, so a `<` that begins
755
+ * no tag costs only the gap to the next one and the tokenizer stays linear in
756
+ * the string (spec §9). D3 needs a quoted value to hold `>`, never `<`.
757
+ */
758
+ const TAG_ATTR = /[ \t\r\n\f]*(?:([^ \t\r\n\f"'<>/=]+)(?:[ \t\r\n\f]*=[ \t\r\n\f]*(?:"([^"<]*)"|'([^'<]*)'|([^ \t\r\n\f<>]+)))?|[^ \t\r\n\f<>])/y;
759
+ const NEWLINE = 1;
760
+ const PARAGRAPH = 2;
761
+ function decodeEntities(text) {
762
+ return text.replace(ENTITY, (source, dec, hex, name) => {
763
+ if (name !== undefined)
764
+ return NAMED_ENTITIES[name];
765
+ const point = dec !== undefined ? Number(dec) : parseInt(hex, 16);
766
+ return point > 0 &&
767
+ point <= 0x10ffff &&
768
+ (point < 0xd800 || point > 0xdfff)
769
+ ? String.fromCodePoint(point)
770
+ : source;
771
+ });
772
+ }
773
+ /** The tag at `at` (`<`, `/`?, a name, attributes, `/`?, `>`), or `undefined` when `<` begins none. */
774
+ function readTag(value, at) {
775
+ TAG_NAME.lastIndex = at + 1;
776
+ const name = TAG_NAME.exec(value)?.[1];
777
+ if (name === undefined)
778
+ return undefined;
779
+ let href;
780
+ for (let i = TAG_NAME.lastIndex;; i = TAG_ATTR.lastIndex) {
781
+ TAG_END.lastIndex = i;
782
+ const end = TAG_END.exec(value);
783
+ if (end !== null) {
784
+ return {
785
+ end: TAG_END.lastIndex,
786
+ close: value[at + 1] === "/",
787
+ selfClosing: end[1] === "/",
788
+ name: name.toLowerCase(),
789
+ ...(href !== undefined && { href }),
790
+ };
791
+ }
792
+ TAG_ATTR.lastIndex = i;
793
+ const attr = TAG_ATTR.exec(value);
794
+ if (attr === null)
795
+ return undefined;
796
+ if (href === undefined && attr[1]?.toLowerCase() === "href") {
797
+ href = attr[2] ?? attr[3] ?? attr[4] ?? "";
798
+ }
799
+ }
800
+ }
801
+ /**
802
+ * A section string's inline HTML as the constrained Markdown the upstream
803
+ * renderers read (#10804 D3): a link with an allowed href becomes
804
+ * `[label](href)`, `<br>` a newline, a block or list item a paragraph break,
805
+ * and every other tag goes, its text kept. A string with no tag comes back
806
+ * byte-identical; the result never holds one. `notCarried` names, sorted,
807
+ * every tag the layout does not keep.
808
+ */
809
+ export function inlineHtmlToMarkdown(value) {
810
+ if (!HTML_TAG.test(value))
811
+ return { text: value, notCarried: [] };
812
+ const pieces = [];
813
+ const notCarried = new Set();
814
+ const text = (raw) => {
815
+ const decoded = decodeEntities(raw.replace(/[ \t\r\n\f]+/g, " "));
816
+ decoded.split("\n").forEach((part, i) => {
817
+ if (i > 0)
818
+ pieces.push(NEWLINE);
819
+ if (part !== "")
820
+ pieces.push(part);
821
+ });
822
+ };
823
+ let link;
824
+ const closeLink = () => {
825
+ if (link === undefined)
826
+ return;
827
+ const label = pieces.slice(link.at);
828
+ const words = label.every((p) => typeof p === "string")
829
+ ? label.join("")
830
+ : "";
831
+ if (words !== "" &&
832
+ !words.includes("]") &&
833
+ LINK_HREF.test(link.href) &&
834
+ !/[\s)]/.test(link.href)) {
835
+ pieces.splice(link.at, label.length, `[${words}](${link.href})`);
836
+ }
837
+ else {
838
+ notCarried.add("a");
839
+ }
840
+ link = undefined;
841
+ };
842
+ let from = 0;
843
+ for (let lt = value.indexOf("<"); lt !== -1; lt = value.indexOf("<", Math.max(lt + 1, from))) {
844
+ if (value.startsWith("<!--", lt)) {
845
+ text(value.slice(from, lt));
846
+ const end = value.indexOf("-->", lt + 4);
847
+ from = end === -1 ? value.length : end + 3;
848
+ continue;
849
+ }
850
+ const tag = readTag(value, lt);
851
+ if (tag === undefined)
852
+ continue;
853
+ text(value.slice(from, lt));
854
+ from = tag.end;
855
+ const { name } = tag;
856
+ if (!CARRIED_ELEMENTS.has(name))
857
+ notCarried.add(name);
858
+ if (DROPPED_ELEMENTS.has(name)) {
859
+ if (!tag.close && !tag.selfClosing) {
860
+ const close = new RegExp(`</${name}[ \\t\\r\\n\\f]*>`, "gi");
861
+ close.lastIndex = from;
862
+ from = close.exec(value) === null ? value.length : close.lastIndex;
863
+ }
864
+ }
865
+ else if (name === "a") {
866
+ closeLink();
867
+ if (!tag.close) {
868
+ link = { at: pieces.length, href: decodeEntities(tag.href ?? "") };
869
+ }
870
+ }
871
+ else if (name === "br") {
872
+ pieces.push(NEWLINE);
873
+ }
874
+ else if (name === "li") {
875
+ // An item with no text leaves no bullet.
876
+ if (tag.close && pieces.at(-1) === "- ")
877
+ pieces.pop();
878
+ pieces.push(PARAGRAPH);
879
+ if (!tag.close)
880
+ pieces.push("- ");
881
+ }
882
+ else if (BLOCK_ELEMENTS.has(name)) {
883
+ pieces.push(PARAGRAPH);
884
+ }
885
+ }
886
+ text(value.slice(from));
887
+ closeLink();
888
+ // Spaces never touch a newline; a run holding a paragraph break, or two or
889
+ // more newlines, is one blank line; the ends are trimmed.
890
+ const parts = [];
891
+ let run;
892
+ for (const piece of pieces) {
893
+ if (typeof piece !== "string") {
894
+ if (run === undefined) {
895
+ while (parts.length > 0) {
896
+ const last = parts[parts.length - 1].replace(/ +$/, "");
897
+ if (last !== "") {
898
+ parts[parts.length - 1] = last;
899
+ break;
900
+ }
901
+ parts.pop();
902
+ }
903
+ run = { newlines: 0, paragraph: false };
904
+ }
905
+ if (piece === PARAGRAPH)
906
+ run.paragraph = true;
907
+ else
908
+ run.newlines += 1;
909
+ continue;
910
+ }
911
+ let words = piece;
912
+ if (run !== undefined) {
913
+ words = words.replace(/^ +/, "");
914
+ if (words === "")
915
+ continue;
916
+ if (parts.length > 0) {
917
+ parts.push(run.paragraph || run.newlines > 1 ? "\n\n" : "\n");
918
+ }
919
+ run = undefined;
920
+ }
921
+ else if (words.startsWith(" ") && parts.at(-1)?.endsWith(" ") === true) {
922
+ words = words.slice(1);
923
+ }
924
+ parts.push(words);
925
+ }
926
+ return {
927
+ text: parts
928
+ .join("")
929
+ .replace(/^[ \n]+|[ \n]+$/g, "")
930
+ .replace(/<(?=[A-Za-z!/?])/g, "<\u2060"),
931
+ notCarried: [...notCarried].sort(),
932
+ };
933
+ }
934
+ const isPlainObject = (value) => value !== null &&
935
+ typeof value === "object" &&
936
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
937
+ /**
938
+ * Each section with every string in its `data` that holds a tag converted by
939
+ * `inlineHtmlToMarkdown` (#10804 D2); keys and tag-free strings are kept as
940
+ * they are. `notCarried` is the sorted union of the names it did not carry.
941
+ */
942
+ export function sectionsWithoutHtml(sections) {
943
+ const names = new Set();
944
+ const walk = (value) => {
945
+ if (typeof value === "string") {
946
+ const out = inlineHtmlToMarkdown(value);
947
+ for (const name of out.notCarried)
948
+ names.add(name);
949
+ return out.text;
950
+ }
951
+ if (Array.isArray(value))
952
+ return value.map(walk);
953
+ if (!isPlainObject(value))
954
+ return value;
955
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, walk(child)]));
956
+ };
957
+ return {
958
+ sections: sections.map((section) => isPlainObject(section) && "data" in section
959
+ ? { ...section, data: walk(section.data) }
960
+ : section),
961
+ notCarried: [...names].sort(),
962
+ };
963
+ }
964
+ /** The first string or key rule of the hub's that `value` breaks, at `path`. */
965
+ function refusalAt(value, path) {
966
+ if (typeof value === "string") {
967
+ // A URL parser drops tabs, newlines and leading control characters.
968
+ // eslint-disable-next-line no-control-regex -- the control range is the point
969
+ const scheme = value.replace(/[\u0000- ]/g, "").toLowerCase();
970
+ const message = value.length > SECTION_STRING_MAX
971
+ ? `A section string may hold at most ${SECTION_STRING_MAX} characters`
972
+ : HTML_TAG.test(value)
973
+ ? "A section string may not hold an HTML tag"
974
+ : SCRIPT_SCHEME.test(scheme)
975
+ ? "A section string may not use a script-bearing URL scheme"
976
+ : undefined;
977
+ return message === undefined ? undefined : `${message} (${path})`;
978
+ }
979
+ if (value === null || typeof value !== "object")
980
+ return undefined;
981
+ const array = Array.isArray(value);
982
+ for (const [key, child] of Object.entries(value)) {
983
+ if (!array && (key.length > SECTION_KEY_MAX || HTML_TAG.test(key))) {
984
+ return `A section key must be a short plain name (${path}.${key})`;
985
+ }
986
+ const refusal = refusalAt(child, array ? `${path}[${key}]` : `${path}.${key}`);
987
+ if (refusal !== undefined)
988
+ return refusal;
989
+ }
990
+ return undefined;
991
+ }
992
+ /**
993
+ * The first section-string or key rule of the hub's structure write
994
+ * (`checkSectionData`) the layout breaks, as `<message> (sections[i].data<path>)`,
995
+ * or `undefined` (#10804 D5). The depth and size bounds are not restated.
996
+ */
997
+ export function layoutRefusal(layout) {
998
+ for (const [index, section] of layout.sections.entries()) {
999
+ if (!isPlainObject(section) || section["data"] === undefined)
1000
+ continue;
1001
+ const refusal = refusalAt(section["data"], `sections[${index}].data`);
1002
+ if (refusal !== undefined)
1003
+ return refusal;
1004
+ }
1005
+ return undefined;
1006
+ }
567
1007
  // ── The importer ────────────────────────────────────────────────────────────
568
1008
  /** One whitespace-collapsed line of at most `ADAPTER_REASON_MAX` characters. */
569
1009
  function adapterReason(err, stage = "adapter") {
@@ -573,14 +1013,19 @@ function adapterReason(err, stage = "adapter") {
573
1013
  /**
574
1014
  * What the page stores (#10534): with `sections` and a codec, its words after
575
1015
  * the body's own text and its sections as the layout; else the page as read.
1016
+ * Both come from one array, its inline HTML converted (#10804 D2).
576
1017
  */
577
1018
  function convert(source, codec) {
578
- const { sections, ...frontmatter } = source.frontmatter;
579
- if (!Array.isArray(sections) ||
580
- sections.length === 0 ||
1019
+ const { sections: authored, ...frontmatter } = source.frontmatter;
1020
+ if (!Array.isArray(authored) ||
1021
+ authored.length === 0 ||
581
1022
  codec === undefined) {
582
- return { content: { ...source.frontmatter, body: source.body } };
1023
+ return {
1024
+ content: { ...source.frontmatter, body: source.body },
1025
+ notCarried: [],
1026
+ };
583
1027
  }
1028
+ const { sections, notCarried } = sectionsWithoutHtml(authored);
584
1029
  let words;
585
1030
  try {
586
1031
  words = codec(sections);
@@ -595,6 +1040,7 @@ function convert(source, codec) {
595
1040
  body: lead === "" ? words : `${lead}\n\n${words}`,
596
1041
  },
597
1042
  layout: { kind: "sections", sections },
1043
+ notCarried,
598
1044
  };
599
1045
  }
600
1046
  export async function importMdx(opts) {
@@ -616,15 +1062,17 @@ export async function importMdx(opts) {
616
1062
  ? { pages: [], skipped: [] }
617
1063
  : planEditorial(editorialRoot, types, defaultLocale);
618
1064
  const parseFrontmatter = opts.adapters.parseFrontmatter;
1065
+ const rules = opts.adapters.siteFields;
619
1066
  // #10701 D3 — one route path holds one page, so an editorial index the run
620
- // will write supersedes a content page of its type at another slug (the
621
- // scaffold's teaser), which the create would refuse `slug_taken`.
1067
+ // will write supersedes the content page of its type at the surface's own
1068
+ // slug (the scaffold's teaser), which the create would refuse `slug_taken`.
1069
+ // Any other slug of the type has a link of its own (#10805 D5).
622
1070
  const indexTypes = new Set(parseFrontmatter === undefined
623
1071
  ? []
624
1072
  : editorial.pages
625
1073
  .filter((p) => p.invalid === undefined && INDEX_TYPES.has(p.type))
626
1074
  .map((p) => p.type));
627
- const superseded = (p) => indexTypes.has(p.type) && p.slug !== INDEX_SLUG;
1075
+ const superseded = (p) => indexTypes.has(p.type) && p.slug === INDEX_OWN_SLUG.get(p.type);
628
1076
  const pages = [
629
1077
  ...content.pages.filter((p) => !superseded(p)),
630
1078
  ...editorial.pages,
@@ -681,15 +1129,15 @@ export async function importMdx(opts) {
681
1129
  }
682
1130
  let read;
683
1131
  try {
684
- read = editorialSource(page, parseFrontmatter(readFileSync(join(editorialRoot, page.path), "utf8")));
1132
+ read = editorialSource(page, parseFrontmatter(readFileSync(join(editorialRoot, page.path), "utf8")), rules);
685
1133
  }
686
1134
  catch (err) {
687
1135
  fail(adapterReason(err, "editorial"));
688
1136
  continue;
689
1137
  }
690
1138
  source = read.source;
691
- if (read.dropped.length > 0) {
692
- onLine(`${page.path}: not imported — ${read.dropped.join(", ")} (the editorial walk sends only the fields the store keeps from a caller)`);
1139
+ if (read.notSent.length > 0) {
1140
+ onLine(`${page.path}: not imported — ${notSentList(read.notSent)}`);
693
1141
  }
694
1142
  }
695
1143
  else {
@@ -712,13 +1160,37 @@ export async function importMdx(opts) {
712
1160
  fail(converted.reason);
713
1161
  continue;
714
1162
  }
1163
+ if (converted.notCarried.length > 0) {
1164
+ onLine(`${outcomePath(page)}: inline HTML not carried — ${converted.notCarried.join(", ")} (the layout holds no HTML; their text is kept)`);
1165
+ }
715
1166
  if (converted.layout === undefined &&
716
1167
  Array.isArray(source.frontmatter["sections"]) &&
717
1168
  source.frontmatter["sections"].length > 0) {
718
1169
  onLine(`${page.path}: keeps its words in sections — the site's ${VALIDATION_PACKAGE} has no words codec; update its dependencies and re-run`);
719
1170
  }
720
- const result = await writePage(t, page, defaultLocale, converted.content, converted.layout);
1171
+ // #10804 D5 — a layout the hub would refuse fails here, in a dry run and
1172
+ // under --apply alike, before its words are saved.
1173
+ const refusal = converted.layout === undefined
1174
+ ? undefined
1175
+ : layoutRefusal(converted.layout);
1176
+ if (refusal !== undefined) {
1177
+ fail(`layout: ${refusal}`);
1178
+ continue;
1179
+ }
1180
+ const result = await writePage(t, page, defaultLocale, converted.content, converted.layout, rules?.publicPageTags);
721
1181
  halt = result.halt;
1182
+ // #10806 D4 — an older hub strips what it has no member for; say so.
1183
+ // An answer that carries no content says nothing about what was kept.
1184
+ const dropped = rules === undefined ||
1185
+ page.root !== "editorial" ||
1186
+ "reason" in result.outcome ||
1187
+ result.stored === null ||
1188
+ typeof result.stored !== "object"
1189
+ ? []
1190
+ : notKept(converted.content, result.stored, rules);
1191
+ if (dropped.length > 0) {
1192
+ onLine(`${page.path}: not kept by the hub: ${dropped.join(", ")} (the hub predates site fields; update it and re-run)`);
1193
+ }
722
1194
  if (opts.publish !== true || "reason" in result.outcome) {
723
1195
  record(result.outcome);
724
1196
  continue;
@@ -806,16 +1278,19 @@ export async function loadSiteMdxAdapter(siteRoot) {
806
1278
  !types.every((type) => typeof type === "string")) {
807
1279
  throw new ImportMdxRefusedError(`import-mdx: ${CONTENT_ADAPTERS_PACKAGE} in ${web} does not export createMdxAdapter and SCHEMA_ORG_TYPES. Install the site's dependencies (pnpm install) first. Nothing was sent.`);
808
1280
  }
809
- // The codec the adapter's own validation package carries (#10534); one
810
- // that predates it, or none, leaves pages unconverted and the run says so.
811
- let codec;
1281
+ // The codec the adapter's own validation package carries (#10534), and its
1282
+ // site-field rule (#10806 D4); one that predates either, or none, leaves
1283
+ // pages unconverted or site fields unsent, and the run says so.
1284
+ let validation = {};
812
1285
  try {
813
- const validation = createRequire(entry).resolve(VALIDATION_PACKAGE);
814
- codec = (await import(pathToFileURL(validation).href))["sectionsToMarkdown"];
1286
+ const path = createRequire(entry).resolve(VALIDATION_PACKAGE);
1287
+ validation = (await import(pathToFileURL(path).href));
815
1288
  }
816
1289
  catch {
817
- // Not resolvable from the adapter: no codec.
1290
+ // Not resolvable from the adapter: no codec and no site-field rule.
818
1291
  }
1292
+ const codec = validation["sectionsToMarkdown"];
1293
+ const { siteFieldRefusal: refusal, publicPageTags, SITE_FIELDS_MAX_KEYS: maxKeys, SITE_FIELDS_MAX_BYTES: maxBytes, } = validation;
819
1294
  // The adapter's own frontmatter parser (#10701 D1), for the editorial tree;
820
1295
  // none leaves every editorial page failed, and the run says so.
821
1296
  let matter;
@@ -831,6 +1306,17 @@ export async function loadSiteMdxAdapter(siteRoot) {
831
1306
  ...(typeof codec === "function" && {
832
1307
  sectionsToMarkdown: codec,
833
1308
  }),
1309
+ ...(typeof refusal === "function" &&
1310
+ typeof publicPageTags === "function" &&
1311
+ typeof maxKeys === "number" &&
1312
+ typeof maxBytes === "number" && {
1313
+ siteFields: {
1314
+ refusal: refusal,
1315
+ publicPageTags: publicPageTags,
1316
+ maxKeys,
1317
+ maxBytes,
1318
+ },
1319
+ }),
834
1320
  ...(typeof matter === "function" && {
835
1321
  parseFrontmatter: (raw) => {
836
1322
  const { data, content } = matter(raw);