@caelo-cms/shared 0.10.58 → 0.10.60

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.
@@ -0,0 +1,256 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The wire contract between a Caelo install and the Caelo-operated bug
5
+ * intake (epic #901). Both sides import this module, so the request shapes,
6
+ * the signature, the fingerprint and the redaction can never drift apart.
7
+ *
8
+ * Flow:
9
+ * 1. On opt-in the install generates an Ed25519 keypair and registers the
10
+ * public key under its `install_id` (`POST /v1/installs`). The intake
11
+ * pins the first key it sees for an id (trust on first use).
12
+ * 2. Every later request is signed: the install signs
13
+ * {@link bugIntakeSigningString} and sends the three
14
+ * {@link BUG_INTAKE_HEADERS}. The intake verifies against the pinned key
15
+ * and refuses timestamps outside {@link BUG_INTAKE_MAX_CLOCK_SKEW_MS}.
16
+ * 3. A report (`POST /v1/reports`) is answered with the status of the
17
+ * cluster it joined; `POST /v1/clusters/status` refreshes those
18
+ * statuses later, so the install learns "fixed in vX" without polling
19
+ * GitHub itself.
20
+ *
21
+ * Redaction runs on the install BEFORE a report is queued (the Owner's
22
+ * preview shows the redacted bytes) and again on the intake before anything
23
+ * reaches the public issue tracker.
24
+ */
25
+
26
+ import { z } from "zod";
27
+
28
+ /** Bumped on any breaking change to the request or response shapes below. */
29
+ export const BUG_INTAKE_PROTOCOL_VERSION = 1;
30
+
31
+ /** Header names carrying the install's identity and request signature. */
32
+ export const BUG_INTAKE_HEADERS = {
33
+ installId: "x-caelo-install-id",
34
+ timestamp: "x-caelo-timestamp",
35
+ signature: "x-caelo-signature",
36
+ } as const;
37
+
38
+ /** Requests signed further than this from the intake's clock are refused (replay window). */
39
+ export const BUG_INTAKE_MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
40
+
41
+ /** Hard caps on what a report may carry once redacted. */
42
+ export const BUG_INTAKE_LIMITS = {
43
+ title: 200,
44
+ whatHappened: 4000,
45
+ expected: 4000,
46
+ evidence: 4000,
47
+ } as const;
48
+
49
+ const fingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/, "fingerprint must be 64 hex chars");
50
+
51
+ /** `POST /v1/installs` — registers the install's public key (unsigned; TOFU). */
52
+ export const bugIntakeRegisterRequest = z
53
+ .object({
54
+ protocolVersion: z.literal(BUG_INTAKE_PROTOCOL_VERSION),
55
+ installId: z.string().uuid(),
56
+ /** Raw 32-byte Ed25519 public key, base64. */
57
+ publicKey: z.string().regex(/^[A-Za-z0-9+/]{43}=$/, "publicKey must be a base64 Ed25519 key"),
58
+ caeloVersion: z.string().min(1).max(64),
59
+ })
60
+ .strict();
61
+ export type BugIntakeRegisterRequest = z.infer<typeof bugIntakeRegisterRequest>;
62
+
63
+ /** `POST /v1/reports` — one redacted bug report (signed). */
64
+ export const bugIntakeReport = z
65
+ .object({
66
+ protocolVersion: z.literal(BUG_INTAKE_PROTOCOL_VERSION),
67
+ /** The install's own `ai_bug_reports.id`; the intake dedups retries on it. */
68
+ reportId: z.string().uuid(),
69
+ caeloVersion: z.string().min(1).max(64),
70
+ fingerprint: fingerprintSchema,
71
+ source: z.enum(["ai", "auto"]),
72
+ severity: z.enum(["blocking", "degraded", "cosmetic"]),
73
+ blockedTask: z.boolean(),
74
+ suspectedTool: z.string().max(200).nullable(),
75
+ title: z.string().min(1).max(BUG_INTAKE_LIMITS.title),
76
+ whatHappened: z.string().min(1).max(BUG_INTAKE_LIMITS.whatHappened),
77
+ expected: z.string().min(1).max(BUG_INTAKE_LIMITS.expected),
78
+ evidence: z.string().max(BUG_INTAKE_LIMITS.evidence).nullable(),
79
+ })
80
+ .strict();
81
+ export type BugIntakeReport = z.infer<typeof bugIntakeReport>;
82
+
83
+ /**
84
+ * Upstream state of the defect a report belongs to.
85
+ * open — known, not fixed yet
86
+ * fixed — the issue was closed as fixed; `fixedInVersion` names the
87
+ * first release carrying the fix when it is known
88
+ * regression — fixed once, reported again from a version that has the fix
89
+ * wontfix — closed as not planned
90
+ */
91
+ export const bugClusterStatus = z.enum(["open", "fixed", "regression", "wontfix"]);
92
+ export type BugClusterStatus = z.infer<typeof bugClusterStatus>;
93
+
94
+ /**
95
+ * What the intake knows about one cluster (= one upstream issue). Also the
96
+ * `POST /v1/reports` response body: the cluster the report joined.
97
+ */
98
+ export const bugIntakeClusterInfo = z
99
+ .object({
100
+ clusterId: z.string().min(1).max(100),
101
+ status: bugClusterStatus,
102
+ /** Public issue URL; null while the cluster is below the issue threshold. */
103
+ issueUrl: z.string().url().nullable(),
104
+ fixedInVersion: z.string().max(64).nullable(),
105
+ })
106
+ .strict();
107
+ export type BugIntakeClusterInfo = z.infer<typeof bugIntakeClusterInfo>;
108
+
109
+ /** `POST /v1/clusters/status` — refresh the status of clusters this install reported into (signed). */
110
+ export const bugIntakeStatusRequest = z
111
+ .object({
112
+ protocolVersion: z.literal(BUG_INTAKE_PROTOCOL_VERSION),
113
+ clusterIds: z.array(z.string().min(1).max(100)).min(1).max(100),
114
+ })
115
+ .strict();
116
+ export type BugIntakeStatusRequest = z.infer<typeof bugIntakeStatusRequest>;
117
+
118
+ export const bugIntakeStatusResponse = z
119
+ .object({ clusters: z.array(bugIntakeClusterInfo) })
120
+ .strict();
121
+ export type BugIntakeStatusResponse = z.infer<typeof bugIntakeStatusResponse>;
122
+
123
+ /** Error body every non-2xx intake response carries. */
124
+ export const bugIntakeErrorResponse = z
125
+ .object({
126
+ error: z.string(),
127
+ /** true = retrying the same request later can succeed (rate limit, outage). */
128
+ retryable: z.boolean(),
129
+ })
130
+ .strict();
131
+ export type BugIntakeErrorResponse = z.infer<typeof bugIntakeErrorResponse>;
132
+
133
+ const encoder = new TextEncoder();
134
+
135
+ function bytesToHex(bytes: Uint8Array): string {
136
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
137
+ }
138
+
139
+ async function sha256Hex(text: string): Promise<string> {
140
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(text) as BufferSource);
141
+ return bytesToHex(new Uint8Array(digest));
142
+ }
143
+
144
+ /**
145
+ * The exact bytes an install signs for a request. Binding method, path and
146
+ * body hash means a captured signature cannot be replayed against another
147
+ * endpoint or with another body; the timestamp bounds replay in time.
148
+ */
149
+ export async function bugIntakeSigningString(args: {
150
+ method: string;
151
+ path: string;
152
+ timestamp: string;
153
+ body: string;
154
+ }): Promise<string> {
155
+ return `${args.method.toUpperCase()}\n${args.path}\n${args.timestamp}\n${await sha256Hex(args.body)}`;
156
+ }
157
+
158
+ /**
159
+ * Collapse the parts of an error text that differ between occurrences of the
160
+ * same defect (ids, numbers, quoted values, URLs, paths) so the same bug on
161
+ * two installs yields the same text.
162
+ */
163
+ export function normalizeBugText(text: string): string {
164
+ return (
165
+ text
166
+ .toLowerCase()
167
+ .replace(/https?:\/\/\S+/g, "<url>")
168
+ .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, "<email>")
169
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "<id>")
170
+ .replace(/\b[0-9a-f]{12,}\b/g, "<id>")
171
+ // Quoted values (a slug, a title) vary per occurrence; a quoted field
172
+ // path or tool name (`page.slug`, `build_page`) tells two defects apart,
173
+ // so identifiers carrying `.`, `_` or `[` stay.
174
+ .replace(/(["'`])((?:(?!\1).){0,200})\1/g, (m, _q: string, inner: string) =>
175
+ /^[A-Za-z][\w.[\]-]{0,59}$/.test(inner) && /[._[]/.test(inner) ? m : "<v>",
176
+ )
177
+ .replace(/(?:\/[\w.-]+){2,}/g, "<path>")
178
+ .replace(/\d+(\.\d+)?/g, "<n>")
179
+ .replace(/\s+/g, " ")
180
+ .trim()
181
+ .slice(0, 300)
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Deterministic identity of a defect, computed on the install. The Caelo
187
+ * version is deliberately NOT part of it, so one defect stays one cluster
188
+ * across releases (that is how a regression is recognised).
189
+ *
190
+ * Auto-captured reports carry the failing tool's error text as
191
+ * `whatHappened`, which is stable once normalised. AI-filed reports are free
192
+ * prose, so their fingerprint (tool + normalised title) only catches exact
193
+ * repeats; the intake clusters the rest semantically.
194
+ */
195
+ export async function computeBugFingerprint(args: {
196
+ source: "ai" | "auto";
197
+ suspectedTool: string | null;
198
+ title: string;
199
+ whatHappened: string;
200
+ }): Promise<string> {
201
+ const tool = (args.suspectedTool ?? "").trim().toLowerCase();
202
+ const signal = args.source === "auto" ? args.whatHappened : args.title;
203
+ return sha256Hex(`${args.source}\n${tool}\n${normalizeBugText(signal)}`);
204
+ }
205
+
206
+ /**
207
+ * Remove what must never leave an install inside a bug report: contact
208
+ * data, credentials, network addresses, and links (which name the site and
209
+ * its pages). Conservative by design: a report that lost a URL is still
210
+ * triageable, a leaked key is not recoverable.
211
+ */
212
+ export function redactBugText(text: string): string {
213
+ return (
214
+ text
215
+ // Credentials first, before the generic long-token rule eats their prefix.
216
+ .replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+/g, "[redacted-token]")
217
+ .replace(
218
+ /\b(?:sk|pk|rk)-[\w-]{16,}|\bgh[pousr]_[A-Za-z0-9]{20,}|\bAKIA[0-9A-Z]{16}\b|\bAIza[\w-]{30,}|\bxox[abpr]-[\w-]{10,}/g,
219
+ "[redacted-secret]",
220
+ )
221
+ .replace(/\b(Bearer|Basic)\s+[\w.~+/=-]{8,}/gi, "$1 [redacted-secret]")
222
+ .replace(
223
+ /\b(password|passwd|secret|token|api[_-]?key)(["']?\s*[:=]\s*["']?)[^\s"',}]+/gi,
224
+ "$1$2[redacted-secret]",
225
+ )
226
+ .replace(/https?:\/\/[^\s"'<>)]+/g, "[url]")
227
+ .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, "[email]")
228
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
229
+ // International (+49 …) or trunk-prefixed (030 …) numbers; plain digit
230
+ // runs and ISO dates are left alone.
231
+ .replace(/\+\d[\d\s()/-]{7,}\d|\b0\d{2,5}[\s/-]?\d{3,}[\d\s-]*\d/g, "[phone]")
232
+ // Long mixed-case alphanumerics are keys or encoded payloads; source
233
+ // paths and snake_case names (no uppercase+digit mix) survive.
234
+ .replace(
235
+ /\b(?=[A-Za-z0-9+/_-]*\d)(?=[A-Za-z0-9+/_-]*[A-Z])[A-Za-z0-9+/_-]{40,}={0,2}/g,
236
+ "[redacted-blob]",
237
+ )
238
+ );
239
+ }
240
+
241
+ /** Redact and cap one report's free-text fields (both sides run this). */
242
+ export function redactBugReportFields<
243
+ T extends { title: string; whatHappened: string; expected: string; evidence: string | null },
244
+ >(report: T): T {
245
+ const cap = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
246
+ return {
247
+ ...report,
248
+ title: cap(redactBugText(report.title), BUG_INTAKE_LIMITS.title),
249
+ whatHappened: cap(redactBugText(report.whatHappened), BUG_INTAKE_LIMITS.whatHappened),
250
+ expected: cap(redactBugText(report.expected), BUG_INTAKE_LIMITS.expected),
251
+ evidence:
252
+ report.evidence === null
253
+ ? null
254
+ : cap(redactBugText(report.evidence), BUG_INTAKE_LIMITS.evidence),
255
+ };
256
+ }
package/src/content.ts CHANGED
@@ -609,14 +609,14 @@ export const pageUpdateSchema = z
609
609
  templateId: z.string().uuid().optional(),
610
610
  status: pageStatusSchema.optional(),
611
611
  /**
612
- * Only meaningful together with `slug`. A slug change rewrites the page's
613
- * public URL, so the op creates a 301 from the old path by default and
614
- * rewrites every nav-menu / link-list / module-body link that pointed at
615
- * it — all inside the same transaction as the slug write.
612
+ * Only meaningful together with `slug`. A slug change moves the page's
613
+ * public URL, so the op creates a 301 from the old path by default,
614
+ * inside the same transaction as the slug write. Links on the site are
615
+ * page references (epic #724) and follow the page without a rewrite;
616
+ * the 301 is for links from outside the site.
616
617
  *
617
- * `'skip'` suppresses ONLY the redirect (the link rewrites still run) and
618
- * is for the rare case where the operator explicitly says the old URL
619
- * should 404. Defaults to `'auto'`: silently dropping the 301 would strand
618
+ * `'skip'` suppresses the redirect and is for the rare case where the
619
+ * operator explicitly says the old URL should 404. Defaults to `'auto'`: silently dropping the 301 would strand
620
620
  * every inbound link, which is never a safe default (CLAUDE.md §2).
621
621
  */
622
622
  redirectFromOld: z.enum(["auto", "skip"]).optional(),
@@ -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
+ }
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";
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.58";
25
+ export const CAELO_VERSION = "0.10.60";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this