@caelo-cms/shared 0.10.72 → 0.11.1

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 (204) hide show
  1. package/dist/ai-tools.d.ts +103 -142
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +79 -167
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/build-page.d.ts +115 -35
  6. package/dist/build-page.d.ts.map +1 -1
  7. package/dist/build-page.js +31 -12
  8. package/dist/build-page.js.map +1 -1
  9. package/dist/content.d.ts +214 -102
  10. package/dist/content.d.ts.map +1 -1
  11. package/dist/content.js +219 -84
  12. package/dist/content.js.map +1 -1
  13. package/dist/css-scope-scan.js +1 -1
  14. package/dist/css-var-aliases.d.ts +20 -0
  15. package/dist/css-var-aliases.d.ts.map +1 -0
  16. package/dist/css-var-aliases.js +95 -0
  17. package/dist/css-var-aliases.js.map +1 -0
  18. package/dist/css-var-scan.d.ts +0 -16
  19. package/dist/css-var-scan.d.ts.map +1 -1
  20. package/dist/css-var-scan.js +24 -0
  21. package/dist/css-var-scan.js.map +1 -1
  22. package/dist/edit-markers.d.ts +41 -5
  23. package/dist/edit-markers.d.ts.map +1 -1
  24. package/dist/edit-markers.js +47 -50
  25. package/dist/edit-markers.js.map +1 -1
  26. package/dist/escaped-line-breaks.d.ts +44 -0
  27. package/dist/escaped-line-breaks.d.ts.map +1 -0
  28. package/dist/escaped-line-breaks.js +203 -0
  29. package/dist/escaped-line-breaks.js.map +1 -0
  30. package/dist/index.d.ts +4 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interactions.js +6 -6
  35. package/dist/interactions.js.map +1 -1
  36. package/dist/link-canonicalise.d.ts.map +1 -1
  37. package/dist/link-canonicalise.js +7 -1
  38. package/dist/link-canonicalise.js.map +1 -1
  39. package/dist/link-refs.d.ts +1 -1
  40. package/dist/link-refs.js +3 -3
  41. package/dist/lists/address.d.ts +34 -0
  42. package/dist/lists/address.d.ts.map +1 -0
  43. package/dist/lists/address.js +43 -0
  44. package/dist/lists/address.js.map +1 -0
  45. package/dist/lists/default-types.d.ts +22 -0
  46. package/dist/lists/default-types.d.ts.map +1 -0
  47. package/dist/lists/default-types.js +127 -0
  48. package/dist/lists/default-types.js.map +1 -0
  49. package/dist/lists/fields.d.ts +152 -0
  50. package/dist/lists/fields.d.ts.map +1 -0
  51. package/dist/lists/fields.js +259 -0
  52. package/dist/lists/fields.js.map +1 -0
  53. package/dist/lists/index.d.ts +14 -0
  54. package/dist/lists/index.d.ts.map +1 -0
  55. package/dist/lists/index.js +15 -0
  56. package/dist/lists/index.js.map +1 -0
  57. package/dist/lists/item-attr.d.ts +26 -0
  58. package/dist/lists/item-attr.d.ts.map +1 -0
  59. package/dist/lists/item-attr.js +31 -0
  60. package/dist/lists/item-attr.js.map +1 -0
  61. package/dist/lists/items.d.ts +105 -0
  62. package/dist/lists/items.d.ts.map +1 -0
  63. package/dist/lists/items.js +304 -0
  64. package/dist/lists/items.js.map +1 -0
  65. package/dist/lists/list-type.d.ts +401 -0
  66. package/dist/lists/list-type.d.ts.map +1 -0
  67. package/dist/lists/list-type.js +275 -0
  68. package/dist/lists/list-type.js.map +1 -0
  69. package/dist/lists/model.d.ts +14 -0
  70. package/dist/lists/model.d.ts.map +1 -0
  71. package/dist/lists/model.js +15 -0
  72. package/dist/lists/model.js.map +1 -0
  73. package/dist/lists/page-type.d.ts +115 -0
  74. package/dist/lists/page-type.d.ts.map +1 -0
  75. package/dist/lists/page-type.js +66 -0
  76. package/dist/lists/page-type.js.map +1 -0
  77. package/dist/lists/refs.d.ts +13 -0
  78. package/dist/lists/refs.d.ts.map +1 -0
  79. package/dist/lists/refs.js +18 -0
  80. package/dist/lists/refs.js.map +1 -0
  81. package/dist/lists/render-data.d.ts +123 -0
  82. package/dist/lists/render-data.d.ts.map +1 -0
  83. package/dist/lists/render-data.js +3 -0
  84. package/dist/lists/render-data.js.map +1 -0
  85. package/dist/lists/render-deps.d.ts +69 -0
  86. package/dist/lists/render-deps.d.ts.map +1 -0
  87. package/dist/lists/render-deps.js +93 -0
  88. package/dist/lists/render-deps.js.map +1 -0
  89. package/dist/lists/resolve-values.d.ts +63 -0
  90. package/dist/lists/resolve-values.d.ts.map +1 -0
  91. package/dist/lists/resolve-values.js +181 -0
  92. package/dist/lists/resolve-values.js.map +1 -0
  93. package/dist/lists/resolve.d.ts +119 -0
  94. package/dist/lists/resolve.d.ts.map +1 -0
  95. package/dist/lists/resolve.js +970 -0
  96. package/dist/lists/resolve.js.map +1 -0
  97. package/dist/lists/scope.d.ts +81 -0
  98. package/dist/lists/scope.d.ts.map +1 -0
  99. package/dist/lists/scope.js +3 -0
  100. package/dist/lists/scope.js.map +1 -0
  101. package/dist/markup-structure.d.ts +8 -0
  102. package/dist/markup-structure.d.ts.map +1 -1
  103. package/dist/markup-structure.js +2 -0
  104. package/dist/markup-structure.js.map +1 -1
  105. package/dist/media.d.ts +1 -1
  106. package/dist/nested-module-render.d.ts +38 -20
  107. package/dist/nested-module-render.d.ts.map +1 -1
  108. package/dist/nested-module-render.js +156 -31
  109. package/dist/nested-module-render.js.map +1 -1
  110. package/dist/page-log.d.ts +1 -1
  111. package/dist/preview-compose.d.ts +24 -21
  112. package/dist/preview-compose.d.ts.map +1 -1
  113. package/dist/preview-compose.js +34 -118
  114. package/dist/preview-compose.js.map +1 -1
  115. package/dist/redirect-plan.d.ts +13 -0
  116. package/dist/redirect-plan.d.ts.map +1 -1
  117. package/dist/redirect-plan.js +22 -0
  118. package/dist/redirect-plan.js.map +1 -1
  119. package/dist/seo.d.ts +21 -0
  120. package/dist/seo.d.ts.map +1 -1
  121. package/dist/seo.js +32 -0
  122. package/dist/seo.js.map +1 -1
  123. package/dist/source-text-transport.d.ts +43 -0
  124. package/dist/source-text-transport.d.ts.map +1 -0
  125. package/dist/source-text-transport.js +79 -0
  126. package/dist/source-text-transport.js.map +1 -0
  127. package/dist/template-engine.d.ts +44 -31
  128. package/dist/template-engine.d.ts.map +1 -1
  129. package/dist/template-engine.js +75 -174
  130. package/dist/template-engine.js.map +1 -1
  131. package/dist/template-sections.d.ts +69 -0
  132. package/dist/template-sections.d.ts.map +1 -0
  133. package/dist/template-sections.js +436 -0
  134. package/dist/template-sections.js.map +1 -0
  135. package/dist/version.d.ts +2 -2
  136. package/dist/version.d.ts.map +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/version.js.map +1 -1
  139. package/package.json +8 -1
  140. package/src/__tests__/redos-hardening.test.ts +51 -16
  141. package/src/ai-tools-add-module-modes.test.ts +51 -1
  142. package/src/ai-tools.test.ts +7 -10
  143. package/src/ai-tools.ts +90 -180
  144. package/src/bug-intake.test.ts +1 -1
  145. package/src/build-page.test.ts +34 -3
  146. package/src/build-page.ts +34 -11
  147. package/src/content.test.ts +137 -4
  148. package/src/content.ts +230 -85
  149. package/src/css-scope-scan.ts +1 -1
  150. package/src/css-var-aliases.test.ts +61 -0
  151. package/src/css-var-aliases.ts +109 -0
  152. package/src/css-var-scan.ts +9 -0
  153. package/src/edit-markers.test.ts +37 -21
  154. package/src/edit-markers.ts +52 -20
  155. package/src/escaped-line-breaks.ts +221 -0
  156. package/src/index.ts +4 -1
  157. package/src/interactions.ts +6 -6
  158. package/src/link-canonicalise.test.ts +36 -6
  159. package/src/link-canonicalise.ts +6 -1
  160. package/src/link-refs.test.ts +67 -42
  161. package/src/link-refs.ts +3 -3
  162. package/src/lists/address.ts +75 -0
  163. package/src/lists/default-types.ts +141 -0
  164. package/src/lists/fields.ts +286 -0
  165. package/src/lists/index.ts +16 -0
  166. package/src/lists/item-attr.test.ts +36 -0
  167. package/src/lists/item-attr.ts +49 -0
  168. package/src/lists/items.ts +402 -0
  169. package/src/lists/list-fixtures.test.ts +308 -0
  170. package/src/lists/list-type.ts +306 -0
  171. package/src/lists/lists.test.ts +328 -0
  172. package/src/lists/model.ts +16 -0
  173. package/src/lists/page-type.ts +71 -0
  174. package/src/lists/refs.ts +21 -0
  175. package/src/lists/render-data.ts +134 -0
  176. package/src/lists/render-deps.test.ts +132 -0
  177. package/src/lists/render-deps.ts +147 -0
  178. package/src/lists/resolve-values.ts +224 -0
  179. package/src/lists/resolve.test.ts +472 -0
  180. package/src/lists/resolve.ts +1246 -0
  181. package/src/lists/scope.ts +69 -0
  182. package/src/lists-compose-parity.test.ts +262 -0
  183. package/src/markup-structure.ts +10 -0
  184. package/src/nested-module-render.test.ts +147 -74
  185. package/src/nested-module-render.ts +120 -48
  186. package/src/preview-compose.test.ts +55 -19
  187. package/src/preview-compose.ts +59 -154
  188. package/src/redirect-plan.test.ts +25 -0
  189. package/src/redirect-plan.ts +24 -0
  190. package/src/seo.test.ts +58 -0
  191. package/src/seo.ts +49 -0
  192. package/src/source-text-transport.test.ts +158 -0
  193. package/src/source-text-transport.ts +81 -0
  194. package/src/template-engine-lists.test.ts +608 -0
  195. package/src/template-engine.test.ts +62 -80
  196. package/src/template-engine.ts +102 -245
  197. package/src/template-sections.ts +514 -0
  198. package/src/version.ts +1 -1
  199. package/dist/structured-sets.d.ts +0 -70
  200. package/dist/structured-sets.d.ts.map +0 -1
  201. package/dist/structured-sets.js +0 -92
  202. package/dist/structured-sets.js.map +0 -1
  203. package/src/structured-sets.ts +0 -114
  204. package/src/template-engine-data-lists.test.ts +0 -90
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ formatCssVarRenameReport,
6
+ renameCssVarAliases,
7
+ themeVarForAlias,
8
+ } from "./css-var-aliases.js";
9
+ import { scanCssVars } from "./css-var-scan.js";
10
+
11
+ const THEME = [
12
+ "--color-background",
13
+ "--color-foreground",
14
+ "--color-muted",
15
+ "--color-muted-foreground",
16
+ "--color-border",
17
+ "--color-accent",
18
+ ];
19
+
20
+ describe("renameCssVarAliases", () => {
21
+ // The header CSS of e2e-livedit run 40351690739.
22
+ const css =
23
+ ".site-header{border-bottom:1px solid var(--color-border, #e5e5e5);background:var(--color-surface, #fff);}\n" +
24
+ ".site-header__brand{color:var(--color-text, #111);}\n" +
25
+ ".site-header__panel{background:var(--color-surface, #fff);}\n" +
26
+ ".site-header__col h3{color:var(--color-text-muted,#888);}";
27
+
28
+ it("renames common role names to the theme's names, every occurrence", () => {
29
+ const r = renameCssVarAliases(css, THEME, new Set());
30
+ expect(r.css).not.toContain("--color-surface");
31
+ expect(r.css).not.toContain("var(--color-text,");
32
+ expect(r.css).toContain("background:var(--color-background, #fff)");
33
+ expect(r.css).toContain("color:var(--color-foreground, #111)");
34
+ expect(r.css).toContain("color:var(--color-muted-foreground,#888)");
35
+ expect(r.renames).toEqual([
36
+ { from: "--color-surface", to: "--color-background", count: 2 },
37
+ { from: "--color-text", to: "--color-foreground", count: 1 },
38
+ { from: "--color-text-muted", to: "--color-muted-foreground", count: 1 },
39
+ ]);
40
+ expect(formatCssVarRenameReport(r.renames)).toContain("--color-surface→--color-background×2");
41
+ });
42
+
43
+ it("leaves known names, the author's own properties and unmapped names alone", () => {
44
+ const own = ":root{--surface:#fafafa}.a{background:var(--surface);color:var(--brand-ink)}";
45
+ const r = renameCssVarAliases(own, THEME, new Set(["--surface"]));
46
+ expect(r.css).toBe(own);
47
+ expect(r.renames).toEqual([]);
48
+ });
49
+
50
+ it("renames only when the theme emits the target", () => {
51
+ expect(themeVarForAlias("--color-surface-raised", new Set(THEME))).toBeNull();
52
+ expect(themeVarForAlias("--text", new Set(THEME))).toBe("--color-foreground");
53
+ });
54
+ });
55
+
56
+ describe("scanCssVars suggestions", () => {
57
+ it("suggests the theme's role name, not the nearest spelling", () => {
58
+ const [u] = scanCssVars({ css: ".a{color:var(--color-text, #111)}", knownVars: THEME });
59
+ expect(u?.suggestion).toBe("--color-foreground");
60
+ });
61
+ });
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Common names for theme roles, mapped to the names Caelo's theme emits.
5
+ *
6
+ * Module CSS is authored against conventions the model knows from other
7
+ * design systems: `--color-text`, `--color-surface`, `--color-text-muted`.
8
+ * Caelo's theme calls those roles `--color-foreground`, `--color-background`
9
+ * and `--color-muted-foreground`. The write-time scan used to answer with an
10
+ * edit-distance guess (`--color-text` → "did you mean `--color-accent`?"),
11
+ * so the AI loaded the theme and renamed every reference with edit_content
12
+ * — two to three extra tool calls per module, and a failed one when the
13
+ * same declaration appeared twice (e2e-livedit run 40351690739). A name
14
+ * that unambiguously means a role the theme has is renamed before the
15
+ * write instead, and the tool result says so, the same way theme-literal
16
+ * binding rewrites a literal that equals a token (issue #164). Only names
17
+ * in this table are touched, and only when the theme emits the target.
18
+ */
19
+
20
+ /** Role name (without `--` and an optional `color-` prefix) → theme role. */
21
+ const ROLE_ALIASES: Readonly<Record<string, string>> = {
22
+ text: "foreground",
23
+ "text-primary": "foreground",
24
+ "text-color": "foreground",
25
+ "body-text": "foreground",
26
+ fg: "foreground",
27
+ heading: "foreground",
28
+ "text-muted": "muted-foreground",
29
+ "text-secondary": "muted-foreground",
30
+ "text-subtle": "muted-foreground",
31
+ "muted-text": "muted-foreground",
32
+ "fg-muted": "muted-foreground",
33
+ surface: "background",
34
+ bg: "background",
35
+ "page-bg": "background",
36
+ "body-bg": "background",
37
+ "surface-muted": "muted",
38
+ "surface-alt": "muted",
39
+ "bg-muted": "muted",
40
+ "bg-subtle": "muted",
41
+ "surface-raised": "card",
42
+ "card-bg": "card",
43
+ "border-color": "border",
44
+ divider: "border",
45
+ line: "border",
46
+ };
47
+
48
+ /** The theme var an unknown name stands for, or null. */
49
+ export function themeVarForAlias(name: string, knownVars: ReadonlySet<string>): string | null {
50
+ const bare = name.replace(/^--/, "").replace(/^color-/, "");
51
+ const role = ROLE_ALIASES[bare];
52
+ if (role === undefined) return null;
53
+ const target = `--color-${role}`;
54
+ return knownVars.has(target) ? target : null;
55
+ }
56
+
57
+ /** `var(` + the referenced name, when the name ends there. */
58
+ const VAR_NAME_RE = /(var\(\s*)(--[a-zA-Z0-9_-]+)(?![a-zA-Z0-9_-])/g;
59
+
60
+ /** One renamed reference. */
61
+ export interface CssVarRename {
62
+ readonly from: string;
63
+ readonly to: string;
64
+ readonly count: number;
65
+ }
66
+
67
+ /**
68
+ * Rename `var(--alias…)` references to the theme's name for that role.
69
+ * A name the CSS itself defines (`--surface: …`) is the author's own
70
+ * property and stays.
71
+ */
72
+ export function renameCssVarAliases(
73
+ css: string,
74
+ knownVars: readonly string[],
75
+ definedInCss: ReadonlySet<string>,
76
+ ): { css: string; renames: CssVarRename[] } {
77
+ const known = new Set(knownVars);
78
+ const targets = new Map<string, string>();
79
+ const counts = new Map<string, number>();
80
+ // One pass over the references, no pattern built from a name.
81
+ const out = css.replace(VAR_NAME_RE, (match, open: string, name: string) => {
82
+ if (known.has(name) || definedInCss.has(name)) return match;
83
+ let to = targets.get(name);
84
+ if (to === undefined) {
85
+ const resolved = themeVarForAlias(name, known);
86
+ if (resolved === null) return match;
87
+ to = resolved;
88
+ targets.set(name, to);
89
+ }
90
+ counts.set(name, (counts.get(name) ?? 0) + 1);
91
+ return `${open}${to}`;
92
+ });
93
+ const renames: CssVarRename[] = [...targets].map(([from, to]) => ({
94
+ from,
95
+ to,
96
+ count: counts.get(from) ?? 0,
97
+ }));
98
+ return { css: out, renames };
99
+ }
100
+
101
+ /** Tool-result sentence for the renames, or null. */
102
+ export function formatCssVarRenameReport(renames: readonly CssVarRename[]): string | null {
103
+ if (renames.length === 0) return null;
104
+ return ` 🔗 renamed to the theme's names: ${renames
105
+ .map((r) => `${r.from}→${r.to}${r.count > 1 ? `×${r.count}` : ""}`)
106
+ .join(
107
+ ", ",
108
+ )} — the STORED css uses the theme's names, so anchor later edit_content calls on those.`;
109
+ }
@@ -17,6 +17,8 @@
17
17
  * that is legitimate authoring, not drift.
18
18
  */
19
19
 
20
+ import { themeVarForAlias } from "./css-var-aliases.js";
21
+
20
22
  /** One `var(--x)` reference found in CSS. */
21
23
  export interface CssVarReference {
22
24
  readonly name: string;
@@ -97,6 +99,13 @@ export function scanCssVars(input: CssVarScanInput): UnknownCssVar[] {
97
99
  for (const ref of extractCssVarReferences(input.css)) {
98
100
  if (known.has(ref.name) || reported.has(ref.name)) continue;
99
101
  reported.add(ref.name);
102
+ // A common role name (`--color-text`) suggests the theme's name for
103
+ // that role, not the nearest spelling (which was `--color-accent`).
104
+ const alias = themeVarForAlias(ref.name, known);
105
+ if (alias !== null) {
106
+ unknown.push({ name: ref.name, hasFallback: ref.hasFallback, suggestion: alias });
107
+ continue;
108
+ }
100
109
  let best: string | null = null;
101
110
  let bestDist = Number.POSITIVE_INFINITY;
102
111
  for (const candidate of known) {
@@ -2,19 +2,38 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import { finalizeEditMarkers, wrapEditableValues } from "./edit-markers.js";
5
- import { renderTemplate } from "./template-engine.js";
5
+ import { DEFAULT_LIST_TYPES } from "./lists/default-types.js";
6
+ import { fixtureData } from "./lists/list-fixtures.test.js";
7
+ import { createListRenderContext } from "./lists/resolve.js";
8
+ import { renderTemplate, type TemplateField } from "./template-engine.js";
6
9
 
7
- const FIELDS = [
10
+ const FIELDS: TemplateField[] = [
8
11
  { name: "title", kind: "text" },
9
- { name: "tags", kind: "text-list" },
10
- { name: "links", kind: "link-list" },
12
+ { name: "tags", kind: "list", listType: "texts" },
13
+ { name: "links", kind: "list", listType: "links" },
11
14
  { name: "photo", kind: "image" },
12
- ] as const;
15
+ ];
16
+ const listTypes = (slug: string) => DEFAULT_LIST_TYPES.find((t) => t.slug === slug);
17
+ const lists = createListRenderContext(fixtureData()).forPage(null);
18
+
19
+ const T1 = "00000000-0000-4000-9000-000000000001";
20
+ const T2 = "00000000-0000-4000-9000-000000000002";
21
+ const L1 = "00000000-0000-4000-9000-000000000003";
22
+ const tags = (...t: string[]) =>
23
+ t.map((text, i) => ({ id: [T1, T2][i], node: "text", values: { text } }));
24
+ const links = (label: string, href: string) => [
25
+ { id: L1, node: "link", values: { label, link: { href } } },
26
+ ];
27
+ /** A marker path as the comment encodes it (`-` → %2D). */
28
+ const enc = (p: string) => encodeURIComponent(p).replace(/-/g, "%2D");
13
29
 
14
30
  function render(html: string, values: Record<string, unknown>, sharedPages = 0): string {
15
- const wrapped = wrapEditableValues(values, FIELDS);
16
- const rendered = renderTemplate({ html, fields: [...FIELDS], contentValues: wrapped }).html;
17
- return finalizeEditMarkers(rendered, "content#2", sharedPages);
31
+ const wrapped = wrapEditableValues(values, FIELDS, listTypes);
32
+ const rendered = renderTemplate({ html, fields: FIELDS, contentValues: wrapped, lists }).html;
33
+ return finalizeEditMarkers(rendered, "content#2", sharedPages).replace(
34
+ / data-caelo-list-item="[^"]*"/g,
35
+ "",
36
+ );
18
37
  }
19
38
 
20
39
  const marked = (fields: string, shared = 0) =>
@@ -34,17 +53,14 @@ describe("edit markers (#917)", () => {
34
53
  expect(render("<h1>{{title}}</h1>", { title: "Hi" }, 4)).toContain(open("title", 4));
35
54
  });
36
55
 
37
- it("marks text-list items and link-list labels with their index", () => {
56
+ it("marks local list entries' text and link targets by entry id", () => {
38
57
  const html = render(
39
- '<ul>{{#tags}}<li>{{.}}</li>{{/tags}}</ul><nav>{{#links}}<a href="{{href}}">{{label}}</a>{{/links}}</nav>',
40
- {
41
- tags: ["a", "b"],
42
- links: [{ label: "Docs", href: "/docs" }],
43
- },
58
+ '<ul>{{#tags}}<li>{{text}}</li>{{/tags}}</ul><nav>{{#links}}<a href="{{link}}">{{label}}</a>{{/links}}</nav>',
59
+ { tags: tags("a", "b"), links: links("Docs", "/docs") },
44
60
  );
45
- expect(html).toContain(`<li>${open("tags.1")}b${close}</li>`);
61
+ expect(html).toContain(`<li>${open(enc(`tags.${T2}.text`))}b${close}</li>`);
46
62
  expect(html).toContain(
47
- `<a href="/docs"${marked("href=links.0.href")}>${open("links.0.label")}Docs${close}</a>`,
63
+ `<a href="/docs"${marked(`href=${enc(`links.${L1}.link.href`)}`)}>${open(enc(`links.${L1}.label`))}Docs${close}</a>`,
48
64
  );
49
65
  });
50
66
 
@@ -61,12 +77,12 @@ describe("edit markers (#917)", () => {
61
77
 
62
78
  it("marks link targets, images and alt text on their tag (#918)", () => {
63
79
  const html = render(
64
- '{{#links}}<a href="{{href}}">{{label}}</a>{{/links}}<img src="{{photo}}" alt="{{title}}"/>',
65
- { links: [{ label: "Docs", href: "/docs" }], photo: "/m/cup.webp", title: "A cup" },
80
+ '{{#links}}<a href="{{link}}">{{label}}</a>{{/links}}<img src="{{photo}}" alt="{{title}}"/>',
81
+ { links: links("Docs", "/docs"), photo: "/m/cup.webp", title: "A cup" },
66
82
  2,
67
83
  );
68
84
  expect(html).toContain(
69
- `<a href="/docs"${marked("href=links.0.href", 2)}>${open("links.0.label", 2)}Docs${close}</a>`,
85
+ `<a href="/docs"${marked(`href=${enc(`links.${L1}.link.href`)}`, 2)}>${open(enc(`links.${L1}.label`), 2)}Docs${close}</a>`,
70
86
  );
71
87
  expect(html).toContain(
72
88
  `<img src="/m/cup.webp" alt="A cup"${marked("src=photo alt=title", 2)}/>`,
@@ -80,9 +96,9 @@ describe("edit markers (#917)", () => {
80
96
  });
81
97
 
82
98
  it("does not mark values that carry markup, empty values, or non-text kinds", () => {
83
- const html = render("<h1>{{title}}</h1><p>{{#tags}}{{.}}{{/tags}}</p>", {
99
+ const html = render("<h1>{{title}}</h1><p>{{#tags}}{{text}}{{/tags}}</p>", {
84
100
  title: "<b>Bold</b>",
85
- tags: [" "],
101
+ tags: tags(" "),
86
102
  });
87
103
  expect(html).toBe("<h1><b>Bold</b></h1><p> </p>");
88
104
  expect(wrapEditableValues({ n: 3 }, [{ name: "n", kind: "number" }])).toEqual({ n: 3 });
@@ -29,6 +29,8 @@
29
29
  * be edited as plain text without destroying that markup.
30
30
  */
31
31
 
32
+ import type { ListTypeDef } from "./lists/list-type.js";
33
+
32
34
  const START = "\uE000";
33
35
  const SEP = "\uE001";
34
36
  const END = "\uE002";
@@ -38,21 +40,29 @@ const SENTINELS = /[\uE000-\uE002]/g;
38
40
  export const EDIT_MARKER_PREFIX = "caelo:f";
39
41
 
40
42
  /**
41
- * Field kinds the preview marks: text (`text`, `text-list` items,
42
- * `link-list` labels) and link / image targets (`url`, `image`,
43
- * `link-list` hrefs, #918) — the latter usually land in `href` / `src`.
43
+ * Field kinds the preview marks: text (`text`, the `text` fields of a
44
+ * local `list`'s entries) and link / image targets (`url`, `image`, the
45
+ * `href` of a local list entry's `link` fields, #918) — the latter
46
+ * usually land in `href` / `src`.
44
47
  */
45
- const EDITABLE_KINDS = new Set(["text", "text-list", "link-list", "url", "image"]);
48
+ const EDITABLE_KINDS = new Set(["text", "url", "image", "list"]);
46
49
 
47
50
  /** Attributes whose marked value the editor can change (link target, image, alt text). */
48
51
  const MARKABLE_ATTRIBUTES = new Set(["href", "src", "alt"]);
49
52
 
50
- /** Field shape {@link wrapEditableValues} needs: name + kind. */
53
+ /** Field shape {@link wrapEditableValues} needs: name + kind (+ a list field's type and binding). */
51
54
  export interface EditableFieldDef {
52
55
  readonly name: string;
53
56
  readonly kind?: string;
57
+ readonly listType?: string;
58
+ readonly bind?: string;
54
59
  }
55
60
 
61
+ /** List type lookup by slug, for marking a local list's entries. */
62
+ export type EditableListTypes = (slug: string) => ListTypeDef | undefined;
63
+
64
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
65
+
56
66
  function editable(value: unknown): value is string {
57
67
  return typeof value === "string" && value.trim().length > 0 && !value.includes("<");
58
68
  }
@@ -65,9 +75,12 @@ function wrap(path: string, value: string): string {
65
75
  * Wrap the editable text of a content instance's values in sentinels.
66
76
  * Returns a new object; non-editable values pass through untouched.
67
77
  *
68
- * Paths: `<field>` for a `text` / `url` / `image` field, `<field>.<i>`
69
- * for a `text-list` item, `<field>.<i>.label` / `<field>.<i>.href` for a
70
- * `link-list` item's label / target.
78
+ * Paths: `<field>` for a `text` / `url` / `image` field;
79
+ * `<field>.<itemId>.<key>` for a `text` value of a local list entry and
80
+ * `<field>.<itemId>.<key>.href` for the target of its `link` value (item
81
+ * ids are stable uuids, so a path survives reordering). Shared lists are
82
+ * edited in the list editor, not in place: their values are not part of
83
+ * the placement.
71
84
  *
72
85
  * @example
73
86
  * wrapEditableValues({ hero_title: "Hi" }, [{ name: "hero_title", kind: "text" }]);
@@ -76,6 +89,7 @@ function wrap(path: string, value: string): string {
76
89
  export function wrapEditableValues(
77
90
  values: Readonly<Record<string, unknown>>,
78
91
  fields: readonly EditableFieldDef[],
92
+ listTypes?: EditableListTypes,
79
93
  ): Record<string, unknown> {
80
94
  const out: Record<string, unknown> = { ...values };
81
95
  for (const f of fields) {
@@ -84,23 +98,41 @@ export function wrapEditableValues(
84
98
  const v = values[f.name];
85
99
  if (kind === "text" || kind === "url" || kind === "image") {
86
100
  if (editable(v)) out[f.name] = wrap(f.name, v);
87
- } else if (kind === "text-list" && Array.isArray(v)) {
88
- out[f.name] = v.map((item, i) => (editable(item) ? wrap(`${f.name}.${i}`, item) : item));
89
- } else if (kind === "link-list" && Array.isArray(v)) {
90
- out[f.name] = v.map((item, i) => {
91
- if (typeof item !== "object" || item === null) return item;
92
- const { label, href } = item as { label?: unknown; href?: unknown };
93
- return {
94
- ...item,
95
- ...(editable(label) ? { label: wrap(`${f.name}.${i}.label`, label) } : {}),
96
- ...(editable(href) ? { href: wrap(`${f.name}.${i}.href`, href) } : {}),
97
- };
98
- });
101
+ } else if (kind === "list" && (f.bind ?? "local") === "local" && Array.isArray(v)) {
102
+ const type = f.listType === undefined ? undefined : listTypes?.(f.listType);
103
+ if (type) out[f.name] = wrapListEntries(f.name, v, type);
99
104
  }
100
105
  }
101
106
  return out;
102
107
  }
103
108
 
109
+ /** Mark the text and link targets of local list entries, at any depth. */
110
+ function wrapListEntries(field: string, items: readonly unknown[], type: ListTypeDef): unknown[] {
111
+ return items.map((raw) => {
112
+ if (typeof raw !== "object" || raw === null) return raw;
113
+ const item = raw as { id?: unknown; node?: unknown; values?: unknown; children?: unknown };
114
+ const node = typeof item.node === "string" ? type.nodes[item.node] : undefined;
115
+ if (!node || typeof item.id !== "string" || !UUID_RE.test(item.id)) return raw;
116
+ const values = { ...((item.values ?? {}) as Record<string, unknown>) };
117
+ for (const def of node.fields) {
118
+ const v = values[def.name];
119
+ const at = `${field}.${item.id}.${def.name}`;
120
+ if (def.type === "text" && editable(v)) values[def.name] = wrap(at, v);
121
+ if (def.type === "link" && typeof v === "object" && v !== null) {
122
+ const href = (v as { href?: unknown }).href;
123
+ if (editable(href)) values[def.name] = { ...v, href: wrap(`${at}.href`, href) };
124
+ }
125
+ }
126
+ return {
127
+ ...item,
128
+ values,
129
+ ...(Array.isArray(item.children)
130
+ ? { children: wrapListEntries(field, item.children, type) }
131
+ : {}),
132
+ };
133
+ });
134
+ }
135
+
104
136
  /** Comment-safe encoding: no `--`, no `>`, no whitespace. */
105
137
  function attr(value: string): string {
106
138
  return encodeURIComponent(value).replace(/-/g, "%2D");
@@ -0,0 +1,221 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Source text whose line breaks arrived string-escaped as a whole.
5
+ *
6
+ * e2e-livedit run 40532297620 (release gate v0.11.1): `add_module`'s html,
7
+ * css and js came in with every line break as the two characters `\` `n`
8
+ * (`(function () {\n document…`). Stored as sent, the footer showed `\n`
9
+ * text, its CSS broke, and the page's shared module `<script>` threw
10
+ * `SyntaxError: Invalid or unexpected token` — a backslash outside a string
11
+ * is not JavaScript. Like the other whole-value transport encodings
12
+ * (`source-text-transport.ts`) the AI tool boundary decodes it, once.
13
+ *
14
+ * The same two characters are legitimate inside a string literal (`"a\nb"`
15
+ * in JS or a CSS `content`), a regex, an HTML attribute value, or the body
16
+ * of a raw-text element. A value is only decoded when EVERY escaped line
17
+ * break sits where a real line break could have stood — between tokens of
18
+ * code, or in markup text between tags. One escape inside a literal makes
19
+ * the value ambiguous and it is left exactly as sent: decoding it would
20
+ * turn valid source into broken source (a raw newline inside a JS string
21
+ * is a syntax error). The op validators still refuse what is left broken
22
+ * (module js is parsed in admin-core `module-js-syntax.ts`).
23
+ */
24
+
25
+ /** Which grammar a source value is written in; decides what counts as a literal. */
26
+ export type SourceLanguage = "html" | "css" | "js";
27
+
28
+ /** `\n` or `\r\n` escape starting at `i` (a single backslash, not an escaped one). */
29
+ function escapedLineBreakLength(value: string, i: number): number {
30
+ if (value[i] !== "\\") return 0;
31
+ if (value[i + 1] === "n") return 2;
32
+ if (value[i + 1] === "r" && value[i + 2] === "\\" && value[i + 3] === "n") return 4;
33
+ return 0;
34
+ }
35
+
36
+ /** Characters after which a `/` in JS starts a regex literal rather than a division. */
37
+ const REGEX_PRECEDERS = new Set([..."(,=:[!&|?{};+-*%<>~^"]);
38
+
39
+ /**
40
+ * Index just past a quoted literal starting at `start` (its quote char), or
41
+ * -1 when an escaped line break sits inside it.
42
+ */
43
+ function skipQuoted(value: string, start: number): number {
44
+ const quote = value[start];
45
+ let j = start + 1;
46
+ while (j < value.length) {
47
+ const ch = value[j];
48
+ if (ch === "\\") {
49
+ if (value[j + 1] === "n" || value[j + 1] === "r") return -1;
50
+ j += 2;
51
+ continue;
52
+ }
53
+ if (ch === quote) return j + 1;
54
+ j += 1;
55
+ }
56
+ return j;
57
+ }
58
+
59
+ /** True when an escaped line break sits inside a JS string, template, or regex literal. */
60
+ function jsLiteralHoldsEscape(value: string): boolean {
61
+ let prev = "";
62
+ let i = 0;
63
+ while (i < value.length) {
64
+ const ch = value[i] as string;
65
+ const lineBreak = escapedLineBreakLength(value, i);
66
+ if (lineBreak > 0) {
67
+ prev = "";
68
+ i += lineBreak;
69
+ continue;
70
+ }
71
+ if (ch === "\\") {
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch === '"' || ch === "'" || ch === "`") {
76
+ const end = skipQuoted(value, i);
77
+ if (end < 0) return true;
78
+ prev = ch;
79
+ i = end;
80
+ continue;
81
+ }
82
+ if (ch === "/" && value[i + 1] === "/") {
83
+ // A line comment runs to the next (escaped) line break.
84
+ i += 2;
85
+ while (i < value.length && escapedLineBreakLength(value, i) === 0) i += 1;
86
+ continue;
87
+ }
88
+ if (ch === "/" && value[i + 1] === "*") {
89
+ const close = value.indexOf("*/", i + 2);
90
+ i = close < 0 ? value.length : close + 2;
91
+ continue;
92
+ }
93
+ if (ch === "/" && (prev === "" || REGEX_PRECEDERS.has(prev))) {
94
+ let j = i + 1;
95
+ let inClass = false;
96
+ while (j < value.length) {
97
+ const r = value[j];
98
+ if (r === "\\") {
99
+ if (value[j + 1] === "n" || value[j + 1] === "r") return true;
100
+ j += 2;
101
+ continue;
102
+ }
103
+ if (r === "[") inClass = true;
104
+ else if (r === "]") inClass = false;
105
+ else if (r === "/" && !inClass) break;
106
+ j += 1;
107
+ }
108
+ prev = "/";
109
+ i = j + 1;
110
+ continue;
111
+ }
112
+ if (ch !== " " && ch !== "\t") prev = ch;
113
+ i += 1;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** True when an escaped line break sits inside a CSS string. */
119
+ function cssLiteralHoldsEscape(value: string): boolean {
120
+ let i = 0;
121
+ while (i < value.length) {
122
+ const ch = value[i];
123
+ if (ch === "/" && value[i + 1] === "*") {
124
+ const close = value.indexOf("*/", i + 2);
125
+ i = close < 0 ? value.length : close + 2;
126
+ continue;
127
+ }
128
+ if (ch === '"' || ch === "'") {
129
+ const end = skipQuoted(value, i);
130
+ if (end < 0) return true;
131
+ i = end;
132
+ continue;
133
+ }
134
+ i += ch === "\\" ? 2 : 1;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /** Elements whose body is not markup text: an escape there belongs to that body. */
140
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "pre", "textarea"]);
141
+
142
+ /** True when an escaped line break sits inside an attribute value, a comment, or a raw-text element body. */
143
+ function htmlLiteralHoldsEscape(value: string): boolean {
144
+ const lower = value.toLowerCase();
145
+ let i = 0;
146
+ while (i < value.length) {
147
+ if (value[i] !== "<") {
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if (value.startsWith("<!--", i)) {
152
+ const close = value.indexOf("-->", i + 4);
153
+ const end = close < 0 ? value.length : close + 3;
154
+ if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
155
+ i = end;
156
+ continue;
157
+ }
158
+ const name = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(value.slice(i, i + 64))?.[1]?.toLowerCase();
159
+ let j = i + 1;
160
+ while (j < value.length && value[j] !== ">") {
161
+ if (value[j] === '"' || value[j] === "'") {
162
+ const end = skipQuoted(value, j);
163
+ if (end < 0) return true;
164
+ j = end;
165
+ continue;
166
+ }
167
+ j += 1;
168
+ }
169
+ i = j + 1;
170
+ if (name && RAW_TEXT_ELEMENTS.has(name)) {
171
+ const close = lower.indexOf(`</${name}`, i);
172
+ const end = close < 0 ? value.length : close;
173
+ if (/(?<!\\)\\[nr]/.test(value.slice(i, end))) return true;
174
+ i = end;
175
+ }
176
+ }
177
+ return false;
178
+ }
179
+
180
+ const LITERAL_HOLDS_ESCAPE: Readonly<Record<SourceLanguage, (value: string) => boolean>> = {
181
+ html: htmlLiteralHoldsEscape,
182
+ css: cssLiteralHoldsEscape,
183
+ js: jsLiteralHoldsEscape,
184
+ };
185
+
186
+ /** An escaped line break (`\n`, `\r\n`) not preceded by another backslash. */
187
+ const ESCAPED_LINE_BREAK = /(?<!\\)(?:\\r)?\\n/g;
188
+
189
+ /**
190
+ * True when a source value's line breaks were string-escaped as a whole:
191
+ * no real line break at all, at least two escaped ones, one of them
192
+ * followed by indentation (the signature of pretty-printed source), and
193
+ * none inside a literal of `language` (see the file comment).
194
+ *
195
+ * @param value - the source text as sent.
196
+ * @param language - the grammar the value is written in.
197
+ */
198
+ export function hasEscapedLineBreaks(value: string, language: SourceLanguage): boolean {
199
+ if (/[\r\n]/.test(value)) return false;
200
+ const hits = value.match(ESCAPED_LINE_BREAK)?.length ?? 0;
201
+ if (hits < 2) return false;
202
+ if (!/(?<!\\)\\n(?:[ \t]|\\t)/.test(value)) return false;
203
+ return !LITERAL_HOLDS_ESCAPE[language](value);
204
+ }
205
+
206
+ /**
207
+ * The value with every escaped line break turned back into a real one (and
208
+ * the escaped tabs indenting the next line into real tabs) when
209
+ * `hasEscapedLineBreaks` holds; the input unchanged otherwise. Nothing else
210
+ * is unescaped: quotes and backslashes arrived as they were meant.
211
+ *
212
+ * @param value - the tool argument (non-strings pass through).
213
+ * @param language - the grammar the value is written in.
214
+ */
215
+ export function decodeEscapedLineBreaks(value: unknown, language: SourceLanguage): unknown {
216
+ if (typeof value !== "string" || !hasEscapedLineBreaks(value, language)) return value;
217
+ return value.replace(
218
+ /(?<!\\)(?:\\r)?\\n((?:\\t)*)/g,
219
+ (_m, tabs: string) => `\n${"\t".repeat(tabs.length / 2)}`,
220
+ );
221
+ }
package/src/index.ts CHANGED
@@ -14,11 +14,13 @@ export * from "./content.js";
14
14
  export * from "./context.js";
15
15
  export * from "./css-gradient-scan.js";
16
16
  export * from "./css-scope-scan.js";
17
+ export * from "./css-var-aliases.js";
17
18
  export * from "./css-var-scan.js";
18
19
  export * from "./database-url.js";
19
20
  export * from "./design-draft-shell.js";
20
21
  export * from "./document-language.js";
21
22
  export * from "./edit-markers.js";
23
+ export * from "./escaped-line-breaks.js";
22
24
  export * from "./font-assets.js";
23
25
  export * from "./font-axis-policy.js";
24
26
  export * from "./font-usage.js";
@@ -32,6 +34,7 @@ export * from "./image-capabilities.js";
32
34
  export * from "./interactions.js";
33
35
  export * from "./link-canonicalise.js";
34
36
  export * from "./link-refs.js";
37
+ export * from "./lists/index.js";
35
38
  export {
36
39
  type LogContext,
37
40
  type Logger,
@@ -72,11 +75,11 @@ export * from "./screenshot-capture.js";
72
75
  export * from "./seo.js";
73
76
  export * from "./skills.js";
74
77
  export * from "./snapshots.js";
78
+ export * from "./source-text-transport.js";
75
79
  export * from "./staging-gate.js";
76
80
  export * from "./static-cache-policy.js";
77
81
  export * from "./static-security-headers.js";
78
82
  export * from "./strip-cdata.js";
79
- export * from "./structured-sets.js";
80
83
  export * from "./subagents.js";
81
84
  export * from "./template-engine.js";
82
85
  export * from "./theme-importers/auto-detect.js";