@caelo-cms/shared 0.10.58 → 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.
- package/dist/ai-tools.d.ts +12 -1
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +8 -1
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +176 -0
- package/dist/bug-intake.d.ts.map +1 -0
- package/dist/bug-intake.js +200 -0
- package/dist/bug-intake.js.map +1 -0
- package/dist/content.js +7 -7
- package/dist/edit-markers.d.ts +49 -0
- package/dist/edit-markers.d.ts.map +1 -0
- package/dist/edit-markers.js +225 -0
- package/dist/edit-markers.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +8 -1
- package/src/bug-intake.test.ts +154 -0
- package/src/bug-intake.ts +256 -0
- package/src/content.ts +7 -7
- package/src/edit-markers.test.ts +111 -0
- package/src/edit-markers.ts +244 -0
- package/src/index.ts +2 -0
- package/src/version.ts +1 -1
|
@@ -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
|
|
613
|
-
* public URL, so the op creates a 301 from the old path by default
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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
|
|
618
|
-
*
|
|
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";
|