@caelo-cms/shared 0.10.57 → 0.10.59

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 (64) hide show
  1. package/dist/ai-tools.d.ts +30 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +26 -9
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +176 -0
  6. package/dist/bug-intake.d.ts.map +1 -0
  7. package/dist/bug-intake.js +200 -0
  8. package/dist/bug-intake.js.map +1 -0
  9. package/dist/content.js +7 -7
  10. package/dist/edit-markers.d.ts +49 -0
  11. package/dist/edit-markers.d.ts.map +1 -0
  12. package/dist/edit-markers.js +225 -0
  13. package/dist/edit-markers.js.map +1 -0
  14. package/dist/google-oauth.d.ts +22 -0
  15. package/dist/google-oauth.d.ts.map +1 -0
  16. package/dist/google-oauth.js +25 -0
  17. package/dist/google-oauth.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/link-canonicalise.d.ts +109 -0
  23. package/dist/link-canonicalise.d.ts.map +1 -0
  24. package/dist/link-canonicalise.js +471 -0
  25. package/dist/link-canonicalise.js.map +1 -0
  26. package/dist/link-refs.d.ts +63 -8
  27. package/dist/link-refs.d.ts.map +1 -1
  28. package/dist/link-refs.js +80 -14
  29. package/dist/link-refs.js.map +1 -1
  30. package/dist/media.d.ts.map +1 -1
  31. package/dist/media.js +15 -0
  32. package/dist/media.js.map +1 -1
  33. package/dist/nested-module-render.d.ts +17 -0
  34. package/dist/nested-module-render.d.ts.map +1 -1
  35. package/dist/nested-module-render.js +45 -6
  36. package/dist/nested-module-render.js.map +1 -1
  37. package/dist/page-log.d.ts +1 -1
  38. package/dist/preview-compose.d.ts.map +1 -1
  39. package/dist/preview-compose.js +12 -2
  40. package/dist/preview-compose.js.map +1 -1
  41. package/dist/theme-normalize.d.ts.map +1 -1
  42. package/dist/theme-normalize.js +17 -2
  43. package/dist/theme-normalize.js.map +1 -1
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/src/ai-tools.ts +29 -11
  48. package/src/bug-intake.test.ts +154 -0
  49. package/src/bug-intake.ts +256 -0
  50. package/src/content.ts +7 -7
  51. package/src/edit-markers.test.ts +111 -0
  52. package/src/edit-markers.ts +244 -0
  53. package/src/google-oauth.ts +27 -0
  54. package/src/index.ts +4 -0
  55. package/src/link-canonicalise.test.ts +387 -0
  56. package/src/link-canonicalise.ts +598 -0
  57. package/src/link-refs.test.ts +206 -0
  58. package/src/link-refs.ts +119 -21
  59. package/src/media.ts +16 -0
  60. package/src/nested-module-render.ts +59 -4
  61. package/src/preview-compose.ts +11 -2
  62. package/src/theme-normalize-typography-subfields.test.ts +54 -0
  63. package/src/theme-normalize.ts +19 -2
  64. package/src/version.ts +1 -1
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { finalizeEditMarkers, wrapEditableValues } from "./edit-markers.js";
5
+ import { renderTemplate } from "./template-engine.js";
6
+
7
+ const FIELDS = [
8
+ { name: "title", kind: "text" },
9
+ { name: "tags", kind: "text-list" },
10
+ { name: "links", kind: "link-list" },
11
+ { name: "photo", kind: "image" },
12
+ ] as const;
13
+
14
+ 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);
18
+ }
19
+
20
+ const marked = (fields: string, shared = 0) =>
21
+ ` data-caelo-fields="${fields}" data-caelo-placement="content%232" data-caelo-shared="${shared}"`;
22
+
23
+ const open = (f: string, s = 0) => `<!--caelo:f p=content%232 s=${s} f=${f}-->`;
24
+ const close = "<!--/caelo:f-->";
25
+
26
+ describe("edit markers (#917)", () => {
27
+ it("marks a text field in text content", () => {
28
+ expect(render("<h1>{{title}}</h1>", { title: "Hello & welcome" })).toBe(
29
+ `<h1>${open("title")}Hello & welcome${close}</h1>`,
30
+ );
31
+ });
32
+
33
+ it("carries how many pages share a synced placement", () => {
34
+ expect(render("<h1>{{title}}</h1>", { title: "Hi" }, 4)).toContain(open("title", 4));
35
+ });
36
+
37
+ it("marks text-list items and link-list labels with their index", () => {
38
+ 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
+ },
44
+ );
45
+ expect(html).toContain(`<li>${open("tags.1")}b${close}</li>`);
46
+ expect(html).toContain(
47
+ `<a href="/docs"${marked("href=links.0.href")}>${open("links.0.label")}Docs${close}</a>`,
48
+ );
49
+ });
50
+
51
+ it("leaves other attributes, scripts, styles and titles byte-identical", () => {
52
+ const html = render(
53
+ '<div data-x=\'{{title}}\' title="{{title}}"></div><script>var t = "{{title}}";</script><style>/* {{title}} */</style><title>{{title}}</title><!-- {{title}} -->',
54
+ { title: "Hi > there" },
55
+ );
56
+ expect(html).toBe(
57
+ '<div data-x=\'Hi > there\' title="Hi > there"></div><script>var t = "Hi > there";</script><style>/* Hi > there */</style><title>Hi > there</title><!-- Hi > there -->',
58
+ );
59
+ expect(html).not.toMatch(/[\uE000-\uE002]/);
60
+ });
61
+
62
+ it("marks link targets, images and alt text on their tag (#918)", () => {
63
+ 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" },
66
+ 2,
67
+ );
68
+ expect(html).toContain(
69
+ `<a href="/docs"${marked("href=links.0.href", 2)}>${open("links.0.label", 2)}Docs${close}</a>`,
70
+ );
71
+ expect(html).toContain(
72
+ `<img src="/m/cup.webp" alt="A cup"${marked("src=photo alt=title", 2)}/>`,
73
+ );
74
+ });
75
+
76
+ it("does not mark an attribute assembled from several parts", () => {
77
+ expect(render('<a href="/tags/{{title}}">x</a>', { title: "cups" })).toBe(
78
+ '<a href="/tags/cups">x</a>',
79
+ );
80
+ });
81
+
82
+ 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>", {
84
+ title: "<b>Bold</b>",
85
+ tags: [" "],
86
+ });
87
+ expect(html).toBe("<h1><b>Bold</b></h1><p> </p>");
88
+ expect(wrapEditableValues({ n: 3 }, [{ name: "n", kind: "number" }])).toEqual({ n: 3 });
89
+ });
90
+
91
+ it("keeps conditional sections working on wrapped values", () => {
92
+ expect(render("{{#title}}<p>{{title}}</p>{{/title}}", { title: "Hi" })).toBe(
93
+ `<p>${open("title")}Hi${close}</p>`,
94
+ );
95
+ expect(render("{{#title}}<p>{{title}}</p>{{/title}}", {})).toBe("");
96
+ });
97
+
98
+ it("encodes placement and field so a comment can never close early", () => {
99
+ const out = finalizeEditMarkers(
100
+ wrapEditableValues({ "a--b": "x" }, [{ name: "a--b", kind: "text" }])["a--b"] as string,
101
+ "main--slot#0",
102
+ 0,
103
+ );
104
+ expect(out).toBe("<!--caelo:f p=main%2D%2Dslot%230 s=0 f=a%2D%2Db-->x<!--/caelo:f-->");
105
+ });
106
+
107
+ it("is a no-op on HTML without sentinels", () => {
108
+ const html = "<h1>Plain</h1><!-- caelo:missing reason=x -->";
109
+ expect(finalizeEditMarkers(html, "content#0", 0)).toBe(html);
110
+ });
111
+ });
@@ -0,0 +1,244 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #917 — field markers for the Live Edit preview. They let the preview
5
+ * iframe tell which placement + field a piece of visible text came from,
6
+ * so the operator can edit that text in place.
7
+ *
8
+ * Two steps around the normal module render:
9
+ *
10
+ * 1. {@link wrapEditableValues} wraps every plain-text value of a
11
+ * placement's content instance in private-use sentinels that carry
12
+ * the field path. The template engine substitutes the wrapped value
13
+ * like any other string (its escape is the identity), so the
14
+ * sentinels land wherever the module author put `{{field}}`.
15
+ * 2. {@link finalizeEditMarkers} scans the rendered HTML. A sentinel in
16
+ * text content becomes a pair of HTML comments around the value:
17
+ * `<!--caelo:f p=<placement> s=<0|1> f=<path>-->value<!--/caelo:f-->`.
18
+ * Comments do not affect layout or CSS selectors. A sentinel in a
19
+ * comment or a raw-text element (`<script>`, `<style>`, `<title>`,
20
+ * `<textarea>`) is removed and the value stays as it was. In a tag
21
+ * it is removed too; an `href` / `src` / `alt` that is exactly one
22
+ * marked value also gets `data-caelo-fields` on its tag (#918).
23
+ *
24
+ * Only the editor preview runs this. Published output never carries
25
+ * markers: the static generator and every other caller of
26
+ * `pages.render_preview` leave `editMarkers` off.
27
+ *
28
+ * Only values without `<` are wrapped: a value that carries markup cannot
29
+ * be edited as plain text without destroying that markup.
30
+ */
31
+
32
+ const START = "\uE000";
33
+ const SEP = "\uE001";
34
+ const END = "\uE002";
35
+ const SENTINELS = /[\uE000-\uE002]/g;
36
+
37
+ /** Comment prefix of an opening marker; the closing marker is `/caelo:f`. */
38
+ export const EDIT_MARKER_PREFIX = "caelo:f";
39
+
40
+ /**
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`.
44
+ */
45
+ const EDITABLE_KINDS = new Set(["text", "text-list", "link-list", "url", "image"]);
46
+
47
+ /** Attributes whose marked value the editor can change (link target, image, alt text). */
48
+ const MARKABLE_ATTRIBUTES = new Set(["href", "src", "alt"]);
49
+
50
+ /** Field shape {@link wrapEditableValues} needs: name + kind. */
51
+ export interface EditableFieldDef {
52
+ readonly name: string;
53
+ readonly kind?: string;
54
+ }
55
+
56
+ function editable(value: unknown): value is string {
57
+ return typeof value === "string" && value.trim().length > 0 && !value.includes("<");
58
+ }
59
+
60
+ function wrap(path: string, value: string): string {
61
+ return `${START}${path}${SEP}${value.replace(SENTINELS, "")}${END}`;
62
+ }
63
+
64
+ /**
65
+ * Wrap the editable text of a content instance's values in sentinels.
66
+ * Returns a new object; non-editable values pass through untouched.
67
+ *
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.
71
+ *
72
+ * @example
73
+ * wrapEditableValues({ hero_title: "Hi" }, [{ name: "hero_title", kind: "text" }]);
74
+ * // → { hero_title: "\uE000hero_title\uE001Hi\uE002" }
75
+ */
76
+ export function wrapEditableValues(
77
+ values: Readonly<Record<string, unknown>>,
78
+ fields: readonly EditableFieldDef[],
79
+ ): Record<string, unknown> {
80
+ const out: Record<string, unknown> = { ...values };
81
+ for (const f of fields) {
82
+ const kind = f.kind ?? "text";
83
+ if (!EDITABLE_KINDS.has(kind)) continue;
84
+ const v = values[f.name];
85
+ if (kind === "text" || kind === "url" || kind === "image") {
86
+ 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
+ });
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Comment-safe encoding: no `--`, no `>`, no whitespace. */
105
+ function attr(value: string): string {
106
+ return encodeURIComponent(value).replace(/-/g, "%2D");
107
+ }
108
+
109
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "title", "textarea"]);
110
+
111
+ /** End index (exclusive) of the tag starting at `i`, honouring quoted attribute values. */
112
+ function tagEnd(html: string, i: number): number {
113
+ let quote: string | null = null;
114
+ for (let j = i + 1; j < html.length; j += 1) {
115
+ const c = html[j];
116
+ if (quote) {
117
+ if (c === quote) quote = null;
118
+ } else if (c === '"' || c === "'") {
119
+ quote = c;
120
+ } else if (c === ">") {
121
+ return j + 1;
122
+ }
123
+ }
124
+ return html.length;
125
+ }
126
+
127
+ /**
128
+ * Turn sentinels in rendered module HTML into marker comments (text
129
+ * content) or drop them (everywhere else). See the file header.
130
+ *
131
+ * A tag whose `href` / `src` / `alt` value is exactly one marked value
132
+ * gets `data-caelo-fields="href=<path> …"` plus the placement
133
+ * (`data-caelo-placement`, `data-caelo-shared`), so the editor can offer
134
+ * "Edit link" / "Replace image" (#918). A value assembled from several
135
+ * parts (`href="/blog/{{slug}}"`) is not editable and is not marked.
136
+ *
137
+ * @param placement The placement key, `<blockName>#<position>`.
138
+ * @param sharedPages 0 for a placement with its own content; for a synced
139
+ * (shared) one, the number of pages that show the same content.
140
+ */
141
+ export function finalizeEditMarkers(html: string, placement: string, sharedPages: number): string {
142
+ if (!html.includes(START)) return html;
143
+ const open = (path: string) =>
144
+ `<!--${EDIT_MARKER_PREFIX} p=${attr(placement)} s=${sharedPages} f=${attr(path)}-->`;
145
+ const close = `<!--/${EDIT_MARKER_PREFIX}-->`;
146
+ let out = "";
147
+ let i = 0;
148
+ while (i < html.length) {
149
+ const c = html[i];
150
+ if (c === "<" && html.startsWith("<!--", i)) {
151
+ const end = html.indexOf("-->", i + 4);
152
+ const stop = end === -1 ? html.length : end + 3;
153
+ out += stripSentinels(html.slice(i, stop));
154
+ i = stop;
155
+ continue;
156
+ }
157
+ if (c === "<" && /[a-zA-Z/!?]/.test(html[i + 1] ?? "")) {
158
+ const stop = tagEnd(html, i);
159
+ const tag = html.slice(i, stop);
160
+ out += markTag(tag, placement, sharedPages);
161
+ i = stop;
162
+ const name = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(tag)?.[1]?.toLowerCase();
163
+ if (name && RAW_TEXT_ELEMENTS.has(name) && !tag.endsWith("/>")) {
164
+ const closeAt = html.toLowerCase().indexOf(`</${name}`, i);
165
+ const rawStop = closeAt === -1 ? html.length : closeAt;
166
+ out += stripSentinels(html.slice(i, rawStop));
167
+ i = rawStop;
168
+ }
169
+ continue;
170
+ }
171
+ if (c === START) {
172
+ const sep = html.indexOf(SEP, i);
173
+ const end = html.indexOf(END, i);
174
+ if (sep !== -1 && end !== -1 && sep < end) {
175
+ out += `${open(html.slice(i + 1, sep))}${html.slice(sep + 1, end)}${close}`;
176
+ i = end + 1;
177
+ continue;
178
+ }
179
+ }
180
+ if (c !== SEP && c !== END && c !== START) out += c;
181
+ i += 1;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ const ATTRIBUTE = /([^\s"'=<>/]+)(\s*=\s*)(?:"([^"]*)"|'([^']*)')/g;
187
+ const WHOLE_VALUE = /^\uE000([^\uE001]*)\uE001[^\uE002]*\uE002$/;
188
+
189
+ /** Strip sentinels from a tag and mark its editable attributes (see finalizeEditMarkers). */
190
+ function markTag(tag: string, placement: string, sharedPages: number): string {
191
+ if (!tag.includes(START)) return tag;
192
+ const marks: string[] = [];
193
+ const cleaned = stripSentinels(
194
+ tag.replace(ATTRIBUTE, (m, name: string, eq: string, dq?: string, sq?: string) => {
195
+ const value = dq ?? sq ?? "";
196
+ if (!value.includes(START)) return m;
197
+ const whole = WHOLE_VALUE.exec(value);
198
+ const lower = name.toLowerCase();
199
+ if (whole?.[1] && MARKABLE_ATTRIBUTES.has(lower)) marks.push(`${lower}=${attr(whole[1])}`);
200
+ const quote = dq !== undefined ? '"' : "'";
201
+ return `${name}${eq}${quote}${stripSentinels(value)}${quote}`;
202
+ }),
203
+ );
204
+ if (marks.length === 0) return cleaned;
205
+ const extra = ` data-caelo-fields="${marks.join(" ")}" data-caelo-placement="${attr(placement)}" data-caelo-shared="${sharedPages}"`;
206
+ const at = cleaned.endsWith("/>") ? cleaned.length - 2 : cleaned.length - 1;
207
+ return `${cleaned.slice(0, at)}${extra}${cleaned.slice(at)}`;
208
+ }
209
+
210
+ /** Drop sentinels and the path they carry, keeping the wrapped value. */
211
+ function stripSentinels(s: string): string {
212
+ return s.replace(/\uE000[^\uE001]*\uE001/g, "").replace(SENTINELS, "");
213
+ }
214
+
215
+ /** A parsed opening marker. */
216
+ export interface EditMarker {
217
+ readonly blockName: string;
218
+ readonly position: number;
219
+ readonly synced: boolean;
220
+ readonly field: string;
221
+ readonly index?: number;
222
+ }
223
+
224
+ /**
225
+ * Parse an opening marker's comment text (without `<!--`/`-->`), or null.
226
+ * Mirrors what the preview runtime does in the browser.
227
+ */
228
+ export function parseEditMarker(comment: string): EditMarker | null {
229
+ const m = /^caelo:f p=(\S+) s=([01]) f=(\S+)$/.exec(comment.trim());
230
+ if (!m?.[1] || !m[2] || !m[3]) return null;
231
+ const placement = decodeURIComponent(m[1]);
232
+ const hash = placement.lastIndexOf("#");
233
+ const position = Number(placement.slice(hash + 1));
234
+ if (hash <= 0 || !Number.isInteger(position)) return null;
235
+ const [field, index] = decodeURIComponent(m[3]).split(".");
236
+ if (!field) return null;
237
+ return {
238
+ blockName: placement.slice(0, hash),
239
+ position,
240
+ synced: m[2] === "1",
241
+ field,
242
+ ...(index !== undefined ? { index: Number(index) } : {}),
243
+ };
244
+ }
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #914 — the shape of a Google OAuth client and the redirect URI IAP
5
+ * needs on it. Shared so the admin's server-side validation and the setup
6
+ * page (which shows IAP's redirect URI as the Owner types the client ID)
7
+ * use one definition.
8
+ */
9
+
10
+ /**
11
+ * A Google OAuth 2.0 client ID (`<project number>-<random>.apps.googleusercontent.com`).
12
+ * Same pattern as the `operator_oauth_client` CHECK constraint (migration 0323).
13
+ */
14
+ export const GOOGLE_OAUTH_CLIENT_ID = /^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$/;
15
+
16
+ /**
17
+ * IAP's redirect URI for a custom OAuth client: Google sends a signed-in
18
+ * visitor back to IAP here, so the client must list it among its authorized
19
+ * redirect URIs (cloud.google.com/iap/docs/custom-oauth-configuration).
20
+ *
21
+ * @example
22
+ * iapRedirectUri("123-abc.apps.googleusercontent.com")
23
+ * // → "https://iap.googleapis.com/v1/oauth/clientIds/123-abc.apps.googleusercontent.com:handleRedirect"
24
+ */
25
+ export function iapRedirectUri(clientId: string): string {
26
+ return `https://iap.googleapis.com/v1/oauth/clientIds/${clientId}:handleRedirect`;
27
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./ai-tools.js";
6
6
  export * from "./auth-forms.js";
7
7
  export * from "./auto-update.js";
8
8
  export * from "./base-css.js";
9
+ export * from "./bug-intake.js";
9
10
  export * from "./build-page.js";
10
11
  export * from "./cap-failures.js";
11
12
  export * from "./cloud-shell-install.js";
@@ -16,6 +17,7 @@ export * from "./css-var-scan.js";
16
17
  export * from "./database-url.js";
17
18
  export * from "./design-draft-shell.js";
18
19
  export * from "./document-language.js";
20
+ export * from "./edit-markers.js";
19
21
  export * from "./font-assets.js";
20
22
  export * from "./font-axis-policy.js";
21
23
  export * from "./font-usage.js";
@@ -23,8 +25,10 @@ export * from "./fonts.js";
23
25
  export * from "./genesis.js";
24
26
  export * from "./genesis-inventory.js";
25
27
  export * from "./google-models.js";
28
+ export * from "./google-oauth.js";
26
29
  export * from "./image-capabilities.js";
27
30
  export * from "./interactions.js";
31
+ export * from "./link-canonicalise.js";
28
32
  export * from "./link-refs.js";
29
33
  export {
30
34
  type LogContext,