@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.
- package/dist/ai-tools.d.ts +30 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +26 -9
- 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/google-oauth.d.ts +22 -0
- package/dist/google-oauth.d.ts.map +1 -0
- package/dist/google-oauth.js +25 -0
- package/dist/google-oauth.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/link-canonicalise.d.ts +109 -0
- package/dist/link-canonicalise.d.ts.map +1 -0
- package/dist/link-canonicalise.js +471 -0
- package/dist/link-canonicalise.js.map +1 -0
- package/dist/link-refs.d.ts +63 -8
- package/dist/link-refs.d.ts.map +1 -1
- package/dist/link-refs.js +80 -14
- package/dist/link-refs.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +15 -0
- package/dist/media.js.map +1 -1
- package/dist/nested-module-render.d.ts +17 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +45 -6
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +12 -2
- package/dist/preview-compose.js.map +1 -1
- package/dist/theme-normalize.d.ts.map +1 -1
- package/dist/theme-normalize.js +17 -2
- package/dist/theme-normalize.js.map +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 +29 -11
- 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/google-oauth.ts +27 -0
- package/src/index.ts +4 -0
- package/src/link-canonicalise.test.ts +387 -0
- package/src/link-canonicalise.ts +598 -0
- package/src/link-refs.test.ts +206 -0
- package/src/link-refs.ts +119 -21
- package/src/media.ts +16 -0
- package/src/nested-module-render.ts +59 -4
- package/src/preview-compose.ts +11 -2
- package/src/theme-normalize-typography-subfields.test.ts +54 -0
- package/src/theme-normalize.ts +19 -2
- package/src/version.ts +1 -1
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write-time canonicalisation of internal links (epic #724, rollout C):
|
|
5
|
+
* the pure core. Authors — operators and the AI — keep writing `/about`,
|
|
6
|
+
* `https://<own domain>/about#team` or `/_caelo/media/hero`; this turns
|
|
7
|
+
* every such link into its stored reference (`caelo:page/<id>#team`,
|
|
8
|
+
* `caelo:media/<id>`) so renaming or moving the target changes no stored
|
|
9
|
+
* link. External URLs, `mailto:`, `tel:`, fragments, template
|
|
10
|
+
* placeholders and non-page files stay literal.
|
|
11
|
+
*
|
|
12
|
+
* Where a link can live, and what is looked at:
|
|
13
|
+
*
|
|
14
|
+
* - a WHOLE value that is a URL: `url` / `link` / `image` fields, any
|
|
15
|
+
* field or object key named `href` / `src` / `url` / `link` or ending
|
|
16
|
+
* in `_href` / `_url` / `_link` / `_src` (`primary_cta_href`), the
|
|
17
|
+
* `href` of `link-list` items and structured-set items (any depth);
|
|
18
|
+
* - HTML anywhere (module HTML, rich text, any string value carrying
|
|
19
|
+
* markup): `href` (pages and media), `src` / `poster` / `srcset`
|
|
20
|
+
* (media only — a `src="/x"` that is not media is not a page link).
|
|
21
|
+
*
|
|
22
|
+
* A plain text value is never canonicalised as a whole: "/month" in a
|
|
23
|
+
* price label is text, not a link.
|
|
24
|
+
*
|
|
25
|
+
* Failure policy (CLAUDE.md §2, §11): an internal path that matches no
|
|
26
|
+
* page, a media URL that matches no asset and a reference whose target
|
|
27
|
+
* is gone are collected as {@link LinkCanonicaliseIssue}s; the write op
|
|
28
|
+
* turns them into one AI-actionable error. Two exceptions keep editing
|
|
29
|
+
* possible around content that predates this rule:
|
|
30
|
+
*
|
|
31
|
+
* - grandfathering: a literal that was ALREADY in the stored value
|
|
32
|
+
* (pre-#724 content, rollout E migrates it) is left as it is — an
|
|
33
|
+
* edit must not introduce a broken link, but it need not fix an
|
|
34
|
+
* unrelated old one. The reference-integrity check (rollout F)
|
|
35
|
+
* reports those.
|
|
36
|
+
* - report mode: imports keep unresolved literals and report them
|
|
37
|
+
* (maintainer decision 3 on #724) instead of failing the import.
|
|
38
|
+
* - pending: while a chat rebuilds a site import, a link to a page of
|
|
39
|
+
* that run not built yet is kept and listed in `run.pending`; the
|
|
40
|
+
* caller records it and the run's finalisation converts it (or
|
|
41
|
+
* reports it) once the run's pages exist.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
findMalformedLinkRefs,
|
|
46
|
+
formatLinkRef,
|
|
47
|
+
LINK_REF_PART_PATTERNS,
|
|
48
|
+
type LinkRef,
|
|
49
|
+
parseLinkRef,
|
|
50
|
+
} from "./link-refs.js";
|
|
51
|
+
import { MEDIA_URL_PREFIX } from "./media.js";
|
|
52
|
+
|
|
53
|
+
/** What the canonicaliser needs to know about the site. */
|
|
54
|
+
export interface LinkTargetLookup {
|
|
55
|
+
/**
|
|
56
|
+
* The page served at `path` (leading slash, no trailing slash except
|
|
57
|
+
* the root "/"), following one redirect hop when the path is an old
|
|
58
|
+
* URL whose redirect lands on a page. Null when no page answers it.
|
|
59
|
+
*/
|
|
60
|
+
pageIdForPath(path: string): string | null;
|
|
61
|
+
/** The live media asset with this slug, or null. */
|
|
62
|
+
mediaIdForSlug(slug: string): string | null;
|
|
63
|
+
pageExists(pageId: string): boolean;
|
|
64
|
+
mediaExists(mediaId: string): boolean;
|
|
65
|
+
/** True when `host` (lowercase, may carry a port) is the site's own. */
|
|
66
|
+
isOwnHost(host: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* True when `path` names a page that does not exist YET but is planned:
|
|
69
|
+
* a page of the site import the writing chat is rebuilding, not built
|
|
70
|
+
* so far (#724 rollout C, maintainer decision on open point 1). Such a
|
|
71
|
+
* link keeps its literal path and lands in
|
|
72
|
+
* {@link LinkCanonicaliseRun.pending} instead of being an issue; the
|
|
73
|
+
* import converts it once the page exists. Absent = nothing is pending
|
|
74
|
+
* (every write outside an import run).
|
|
75
|
+
*/
|
|
76
|
+
isPendingPagePath?(path: string): boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A link to a planned (not yet built) page, kept literal for now. */
|
|
80
|
+
export interface PendingLinkTarget {
|
|
81
|
+
/** The link exactly as written (trimmed). */
|
|
82
|
+
readonly value: string;
|
|
83
|
+
/** Its normalised path: the planned page's path. */
|
|
84
|
+
readonly path: string;
|
|
85
|
+
readonly where: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One link the canonicaliser could not turn into a reference. */
|
|
89
|
+
export interface LinkCanonicaliseIssue {
|
|
90
|
+
readonly kind:
|
|
91
|
+
| "unknown-page-path"
|
|
92
|
+
| "unknown-media"
|
|
93
|
+
| "dangling-page-ref"
|
|
94
|
+
| "dangling-media-ref"
|
|
95
|
+
| "unknown-file"
|
|
96
|
+
| "unsupported-link-form"
|
|
97
|
+
| "malformed-link-ref";
|
|
98
|
+
/** The link exactly as written. */
|
|
99
|
+
readonly value: string;
|
|
100
|
+
/** Where it was found: `values.cta_href`, `html`, `fields.nav.default[2].href`, `items[0].href`. */
|
|
101
|
+
readonly where: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A canonicalisation run over one write. */
|
|
105
|
+
export interface LinkCanonicaliseRun {
|
|
106
|
+
readonly lookup: LinkTargetLookup;
|
|
107
|
+
/**
|
|
108
|
+
* `strict` (actor writes): unknown targets are issues the op refuses.
|
|
109
|
+
* `report` (imports): unknown targets stay literal and are reported.
|
|
110
|
+
* Either way they land in {@link issues}.
|
|
111
|
+
*/
|
|
112
|
+
readonly mode: "strict" | "report";
|
|
113
|
+
/** Link literals already present in the stored state; see file header. */
|
|
114
|
+
readonly grandfathered: ReadonlySet<string>;
|
|
115
|
+
readonly issues: LinkCanonicaliseIssue[];
|
|
116
|
+
/** Links to planned pages (see {@link LinkTargetLookup.isPendingPagePath}). */
|
|
117
|
+
readonly pending: PendingLinkTarget[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Start a run. */
|
|
121
|
+
export function linkCanonicaliseRun(
|
|
122
|
+
lookup: LinkTargetLookup,
|
|
123
|
+
opts: { mode?: "strict" | "report"; grandfathered?: ReadonlySet<string> } = {},
|
|
124
|
+
): LinkCanonicaliseRun {
|
|
125
|
+
return {
|
|
126
|
+
lookup,
|
|
127
|
+
mode: opts.mode ?? "strict",
|
|
128
|
+
grandfathered: opts.grandfathered ?? new Set(),
|
|
129
|
+
issues: [],
|
|
130
|
+
pending: [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The reference grammar's own part patterns (link-refs.ts): a query,
|
|
135
|
+
// anchor or variant they accept always round-trips through
|
|
136
|
+
// `formatLinkRef`, and one they reject is an issue, never a throw (a
|
|
137
|
+
// thrown error would turn bad author input into a 500).
|
|
138
|
+
const QUERY_OK = LINK_REF_PART_PATTERNS.query;
|
|
139
|
+
const ANCHOR_OK = LINK_REF_PART_PATTERNS.anchor;
|
|
140
|
+
const VARIANT_RE = LINK_REF_PART_PATTERNS.variant;
|
|
141
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
142
|
+
const MEDIA_SEGMENT_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
143
|
+
// Text that starts like a reference. When the grammar rejects it, it is
|
|
144
|
+
// a broken reference, not some other URL (`malformed-link-ref`).
|
|
145
|
+
const REF_LIKE_RE = /^caelo:(?:page|media)\//;
|
|
146
|
+
// A last path segment with a file extension names a file, not a page —
|
|
147
|
+
// unless a page or an old-URL redirect answers it (`/about.html` an import
|
|
148
|
+
// redirected), which is checked first.
|
|
149
|
+
const FILE_EXT_RE = /\.[A-Za-z0-9]{1,8}$/;
|
|
150
|
+
// Files the static build itself serves at the site root; a link to one
|
|
151
|
+
// is a plain URL. Any other file path must come from the media library.
|
|
152
|
+
const SITE_SERVED_FILES: ReadonlySet<string> = new Set(["/robots.txt", "/sitemap.xml"]);
|
|
153
|
+
|
|
154
|
+
/** Key / field names whose whole value is a URL. */
|
|
155
|
+
export function isLinkKeyName(name: string): boolean {
|
|
156
|
+
const n = name.toLowerCase();
|
|
157
|
+
return (
|
|
158
|
+
n === "href" ||
|
|
159
|
+
n === "src" ||
|
|
160
|
+
n === "url" ||
|
|
161
|
+
n === "link" ||
|
|
162
|
+
n.endsWith("_href") ||
|
|
163
|
+
n.endsWith("_url") ||
|
|
164
|
+
n.endsWith("_link") ||
|
|
165
|
+
n.endsWith("_src")
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Split `/a/b?q#h` into its parts (no URL parser: it must stay path-shaped). */
|
|
170
|
+
function splitPathTail(v: string): { path: string; query: string | null; anchor: string | null } {
|
|
171
|
+
let rest = v;
|
|
172
|
+
let anchor: string | null = null;
|
|
173
|
+
const hash = rest.indexOf("#");
|
|
174
|
+
if (hash !== -1) {
|
|
175
|
+
anchor = rest.slice(hash + 1);
|
|
176
|
+
rest = rest.slice(0, hash);
|
|
177
|
+
}
|
|
178
|
+
let query: string | null = null;
|
|
179
|
+
const q = rest.indexOf("?");
|
|
180
|
+
if (q !== -1) {
|
|
181
|
+
query = rest.slice(q + 1);
|
|
182
|
+
rest = rest.slice(0, q);
|
|
183
|
+
}
|
|
184
|
+
return { path: rest, query: query === "" ? null : query, anchor: anchor === "" ? null : anchor };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `/a/b/` → `/a/b`; `/` stays `/`. Linear, no regex backtracking. */
|
|
188
|
+
export function normaliseLinkPath(path: string): string {
|
|
189
|
+
let end = path.length;
|
|
190
|
+
while (end > 1 && path.charCodeAt(end - 1) === 47) end -= 1;
|
|
191
|
+
return path.slice(0, end) || "/";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function issue(
|
|
195
|
+
run: LinkCanonicaliseRun,
|
|
196
|
+
kind: LinkCanonicaliseIssue["kind"],
|
|
197
|
+
value: string,
|
|
198
|
+
where: string,
|
|
199
|
+
): void {
|
|
200
|
+
run.issues.push({ kind, value, where });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Record every malformed reference in `text` (HTML or a plain value).
|
|
205
|
+
* The render flags exactly these (`link-ref-malformed`, via the shared
|
|
206
|
+
* `findMalformedLinkRefs`), so a write that would ship one is refused up
|
|
207
|
+
* front. A snippet already recorded at the same position (the
|
|
208
|
+
* whole-value check in {@link canonicaliseUrl} saw it first) is not
|
|
209
|
+
* reported twice.
|
|
210
|
+
*/
|
|
211
|
+
function reportMalformedRefs(text: string, run: LinkCanonicaliseRun, where: string): void {
|
|
212
|
+
for (const raw of findMalformedLinkRefs(text)) {
|
|
213
|
+
if (run.grandfathered.has(raw)) continue;
|
|
214
|
+
const seen = run.issues.some(
|
|
215
|
+
(i) => i.kind === "malformed-link-ref" && i.where === where && i.value.startsWith(raw),
|
|
216
|
+
);
|
|
217
|
+
if (!seen) issue(run, "malformed-link-ref", raw, where);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Canonicalise a `/_caelo/media/<slug>[/<variant>]` path, or null when it
|
|
223
|
+
* is not one.
|
|
224
|
+
*
|
|
225
|
+
* @param hasSuffix the link carries a `?query` or `#anchor`. A media
|
|
226
|
+
* reference cannot carry either (`caelo:media/<id>` has no suffix), so
|
|
227
|
+
* such a link is an `unsupported-link-form` issue instead of a
|
|
228
|
+
* reference that silently drops `#page=3`.
|
|
229
|
+
*/
|
|
230
|
+
function canonicaliseMediaPath(
|
|
231
|
+
path: string,
|
|
232
|
+
raw: string,
|
|
233
|
+
hasSuffix: boolean,
|
|
234
|
+
run: LinkCanonicaliseRun,
|
|
235
|
+
where: string,
|
|
236
|
+
): string | null {
|
|
237
|
+
if (!path.startsWith(`${MEDIA_URL_PREFIX}/`)) return null;
|
|
238
|
+
const segs = path.slice(MEDIA_URL_PREFIX.length + 1).split("/");
|
|
239
|
+
const first = segs[0] ?? "";
|
|
240
|
+
const variant = segs[1] ?? null;
|
|
241
|
+
if (segs.length > 2 || !MEDIA_SEGMENT_RE.test(first) || (variant && !VARIANT_RE.test(variant))) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
// Legacy `/_caelo/media/<uuid>/<variant>` names the asset by id.
|
|
245
|
+
const id = UUID_RE.test(first) && variant ? first : run.lookup.mediaIdForSlug(first);
|
|
246
|
+
if (id === null || !run.lookup.mediaExists(id)) {
|
|
247
|
+
if (!run.grandfathered.has(raw)) issue(run, "unknown-media", raw, where);
|
|
248
|
+
return raw;
|
|
249
|
+
}
|
|
250
|
+
if (hasSuffix) {
|
|
251
|
+
if (!run.grandfathered.has(raw)) issue(run, "unsupported-link-form", raw, where);
|
|
252
|
+
return raw;
|
|
253
|
+
}
|
|
254
|
+
// `orig` is the original, which a reference expresses as no variant.
|
|
255
|
+
return formatLinkRef({
|
|
256
|
+
kind: "media",
|
|
257
|
+
mediaId: id,
|
|
258
|
+
variant: variant === "orig" ? null : variant,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Canonicalise one URL value. Returns the stored form: a reference, or
|
|
264
|
+
* the value unchanged (external, non-page, or an issue was recorded).
|
|
265
|
+
*
|
|
266
|
+
* @param scope `pages+media` for link positions (`href`, link fields);
|
|
267
|
+
* `media` for image positions (`src`, `srcset`, `poster`).
|
|
268
|
+
*/
|
|
269
|
+
export function canonicaliseUrl(
|
|
270
|
+
value: string,
|
|
271
|
+
run: LinkCanonicaliseRun,
|
|
272
|
+
where: string,
|
|
273
|
+
scope: "pages+media" | "media" = "pages+media",
|
|
274
|
+
): string {
|
|
275
|
+
const v = value.trim();
|
|
276
|
+
if (v.length === 0 || v.includes("{{")) return value;
|
|
277
|
+
|
|
278
|
+
const ref = parseLinkRef(v);
|
|
279
|
+
if (ref) {
|
|
280
|
+
if (!targetExists(ref, run.lookup) && !run.grandfathered.has(v)) {
|
|
281
|
+
issue(run, ref.kind === "page" ? "dangling-page-ref" : "dangling-media-ref", v, where);
|
|
282
|
+
}
|
|
283
|
+
return v;
|
|
284
|
+
}
|
|
285
|
+
// Starts like a reference but breaks the grammar (a truncated id,
|
|
286
|
+
// stray characters after it): passing it through would store a link
|
|
287
|
+
// no render resolves (CLAUDE.md §2).
|
|
288
|
+
if (REF_LIKE_RE.test(v)) {
|
|
289
|
+
if (!run.grandfathered.has(v)) issue(run, "malformed-link-ref", v, where);
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let local: string;
|
|
294
|
+
if (/^https?:\/\//i.test(v)) {
|
|
295
|
+
let u: URL;
|
|
296
|
+
try {
|
|
297
|
+
u = new URL(v);
|
|
298
|
+
} catch {
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
if (!run.lookup.isOwnHost(u.host.toLowerCase())) return value;
|
|
302
|
+
local = `${u.pathname}${u.search}${u.hash}`;
|
|
303
|
+
} else if (v.startsWith("/") && !v.startsWith("//")) {
|
|
304
|
+
local = v;
|
|
305
|
+
} else {
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const { path, query, anchor } = splitPathTail(local);
|
|
310
|
+
const media = canonicaliseMediaPath(path, v, query !== null || anchor !== null, run, where);
|
|
311
|
+
if (media !== null) return media;
|
|
312
|
+
if (scope === "media") return value;
|
|
313
|
+
// Not a page: other reserved surfaces (`/_assets/…`, `/_caelo/fonts/…`)
|
|
314
|
+
// and the gateway (`/api/…`).
|
|
315
|
+
if (path.startsWith("/_") || path === "/api" || path.startsWith("/api/")) return value;
|
|
316
|
+
|
|
317
|
+
const normalised = normaliseLinkPath(path);
|
|
318
|
+
const pageId = run.lookup.pageIdForPath(normalised);
|
|
319
|
+
if (pageId === null && run.lookup.isPendingPagePath?.(normalised)) {
|
|
320
|
+
// A page the import run will still build: kept as written and
|
|
321
|
+
// recorded by the caller; the run's finalisation converts it.
|
|
322
|
+
run.pending.push({ value: v, path: normalised, where });
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
if (pageId === null) {
|
|
326
|
+
const lastSegment = path.slice(path.lastIndexOf("/") + 1);
|
|
327
|
+
const isFile = FILE_EXT_RE.test(lastSegment);
|
|
328
|
+
if (isFile && SITE_SERVED_FILES.has(path)) return value;
|
|
329
|
+
if (!run.grandfathered.has(v))
|
|
330
|
+
issue(run, isFile ? "unknown-file" : "unknown-page-path", v, where);
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
if ((query !== null && !QUERY_OK.test(query)) || (anchor !== null && !ANCHOR_OK.test(anchor))) {
|
|
334
|
+
if (!run.grandfathered.has(v)) issue(run, "unsupported-link-form", v, where);
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
return formatLinkRef({ kind: "page", pageId, query, anchor });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function targetExists(ref: LinkRef, lookup: LinkTargetLookup): boolean {
|
|
341
|
+
return ref.kind === "page" ? lookup.pageExists(ref.pageId) : lookup.mediaExists(ref.mediaId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Attribute scanner: `name="…"` / `name='…'` for the four link-bearing
|
|
345
|
+
// attributes. The value classes exclude their own quote, so each match
|
|
346
|
+
// is linear (no backtracking across attributes).
|
|
347
|
+
const ATTR_RE = /(\s(?:href|src|poster|srcset)\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi;
|
|
348
|
+
|
|
349
|
+
/** Canonicalise the link-bearing attributes of an HTML fragment. */
|
|
350
|
+
export function canonicaliseHtmlLinks(
|
|
351
|
+
html: string,
|
|
352
|
+
run: LinkCanonicaliseRun,
|
|
353
|
+
where: string,
|
|
354
|
+
): string {
|
|
355
|
+
if (!html.includes("=")) {
|
|
356
|
+
reportMalformedRefs(html, run, where);
|
|
357
|
+
return html;
|
|
358
|
+
}
|
|
359
|
+
// Attributes are only read inside a start tag, so escaped markup in a
|
|
360
|
+
// code sample (`<a href="/x">` is text, not a link) is left alone.
|
|
361
|
+
let out = "";
|
|
362
|
+
let from = 0;
|
|
363
|
+
for (const [start, end] of startTags(html)) {
|
|
364
|
+
out += html.slice(from, start) + canonicaliseTagAttributes(html.slice(start, end), run, where);
|
|
365
|
+
from = end;
|
|
366
|
+
}
|
|
367
|
+
// Broken references anywhere the render looks (any attribute, a srcset
|
|
368
|
+
// entry), not only in the four attributes canonicalised above.
|
|
369
|
+
reportMalformedRefs(html, run, where);
|
|
370
|
+
return from === 0 ? html : out + html.slice(from);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `[start, end)` of every start tag (`<` + letter … `>`), quoted values
|
|
375
|
+
* allowed to contain `>`. One linear pass; an unterminated quote runs to
|
|
376
|
+
* the end of the input instead of being rescanned.
|
|
377
|
+
*/
|
|
378
|
+
function* startTags(html: string): Generator<[number, number]> {
|
|
379
|
+
let i = html.indexOf("<");
|
|
380
|
+
while (i !== -1 && i < html.length - 1) {
|
|
381
|
+
const c = html.charCodeAt(i + 1);
|
|
382
|
+
const isLetter = (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
383
|
+
if (!isLetter) {
|
|
384
|
+
i = html.indexOf("<", i + 1);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
let j = i + 2;
|
|
388
|
+
let quote = 0;
|
|
389
|
+
while (j < html.length) {
|
|
390
|
+
const ch = html.charCodeAt(j);
|
|
391
|
+
if (quote !== 0) {
|
|
392
|
+
if (ch === quote) quote = 0;
|
|
393
|
+
} else if (ch === 34 || ch === 39) {
|
|
394
|
+
quote = ch;
|
|
395
|
+
} else if (ch === 62) {
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
j += 1;
|
|
399
|
+
}
|
|
400
|
+
const end = Math.min(j + 1, html.length);
|
|
401
|
+
yield [i, end];
|
|
402
|
+
i = html.indexOf("<", end);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function canonicaliseTagAttributes(tag: string, run: LinkCanonicaliseRun, where: string): string {
|
|
407
|
+
return tag.replace(ATTR_RE, (whole, prefix: string, dq?: string, sq?: string) => {
|
|
408
|
+
const quote = dq !== undefined ? '"' : "'";
|
|
409
|
+
const raw = (dq ?? sq) as string;
|
|
410
|
+
const attr = prefix
|
|
411
|
+
.trim()
|
|
412
|
+
.replace(/\s*=\s*$/, "")
|
|
413
|
+
.toLowerCase();
|
|
414
|
+
let next: string;
|
|
415
|
+
if (attr === "href") next = canonicaliseUrl(raw, run, where, "pages+media");
|
|
416
|
+
else if (attr === "srcset") next = canonicaliseSrcset(raw, run, where);
|
|
417
|
+
else next = canonicaliseUrl(raw, run, where, "media");
|
|
418
|
+
return next === raw ? whole : `${prefix}${quote}${next}${quote}`;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `url 400w, url 800w` — each candidate URL is a media position. */
|
|
423
|
+
function canonicaliseSrcset(srcset: string, run: LinkCanonicaliseRun, where: string): string {
|
|
424
|
+
return srcset
|
|
425
|
+
.split(",")
|
|
426
|
+
.map((entry) => {
|
|
427
|
+
const lead = entry.length - entry.trimStart().length;
|
|
428
|
+
const body = entry.slice(lead);
|
|
429
|
+
const sp = body.search(/\s/);
|
|
430
|
+
const url = sp === -1 ? body : body.slice(0, sp);
|
|
431
|
+
const rest = sp === -1 ? "" : body.slice(sp);
|
|
432
|
+
const next = canonicaliseUrl(url, run, where, "media");
|
|
433
|
+
return `${entry.slice(0, lead)}${next}${rest}`;
|
|
434
|
+
})
|
|
435
|
+
.join(",");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Canonicalise any JSON value: whole-value URLs where the key says so
|
|
440
|
+
* (`asUrl`), HTML attributes in every string, recursing through arrays
|
|
441
|
+
* and objects (object keys decide per {@link isLinkKeyName}).
|
|
442
|
+
*/
|
|
443
|
+
export function canonicaliseJsonLinks(
|
|
444
|
+
value: unknown,
|
|
445
|
+
run: LinkCanonicaliseRun,
|
|
446
|
+
where: string,
|
|
447
|
+
asUrl = false,
|
|
448
|
+
): unknown {
|
|
449
|
+
if (typeof value === "string") {
|
|
450
|
+
if (asUrl) return canonicaliseUrl(value, run, where);
|
|
451
|
+
if (value.includes("<")) return canonicaliseHtmlLinks(value, run, where);
|
|
452
|
+
// A text value is never a link as a whole, but one that starts like a
|
|
453
|
+
// reference and breaks the grammar renders as a dead `caelo:` href
|
|
454
|
+
// wherever a module puts it in an attribute.
|
|
455
|
+
reportMalformedRefs(value, run, where);
|
|
456
|
+
return value;
|
|
457
|
+
}
|
|
458
|
+
if (Array.isArray(value)) {
|
|
459
|
+
let changed = false;
|
|
460
|
+
const next = value.map((v, i) => {
|
|
461
|
+
const r = canonicaliseJsonLinks(v, run, `${where}[${i}]`, asUrl);
|
|
462
|
+
if (r !== v) changed = true;
|
|
463
|
+
return r;
|
|
464
|
+
});
|
|
465
|
+
return changed ? next : value;
|
|
466
|
+
}
|
|
467
|
+
if (value !== null && typeof value === "object") {
|
|
468
|
+
let changed = false;
|
|
469
|
+
const out: Record<string, unknown> = {};
|
|
470
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
471
|
+
const r = canonicaliseJsonLinks(v, run, `${where}.${k}`, isLinkKeyName(k));
|
|
472
|
+
if (r !== v) changed = true;
|
|
473
|
+
out[k] = r;
|
|
474
|
+
}
|
|
475
|
+
return changed ? out : value;
|
|
476
|
+
}
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A module field as canonicalisation needs it. */
|
|
481
|
+
export interface CanonicaliseField {
|
|
482
|
+
readonly name: string;
|
|
483
|
+
readonly kind: string;
|
|
484
|
+
readonly default?: unknown;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Field kinds whose whole value is a URL. */
|
|
488
|
+
const URL_KINDS: ReadonlySet<string> = new Set(["url", "link", "image"]);
|
|
489
|
+
/** Field kinds that hold nested-module refs, never links. */
|
|
490
|
+
const NESTED_KINDS: ReadonlySet<string> = new Set(["module", "module-list"]);
|
|
491
|
+
|
|
492
|
+
function canonicaliseFieldValue(
|
|
493
|
+
field: CanonicaliseField | undefined,
|
|
494
|
+
name: string,
|
|
495
|
+
value: unknown,
|
|
496
|
+
run: LinkCanonicaliseRun,
|
|
497
|
+
where: string,
|
|
498
|
+
): unknown {
|
|
499
|
+
if (field && NESTED_KINDS.has(field.kind)) return value;
|
|
500
|
+
const asUrl = (field !== undefined && URL_KINDS.has(field.kind)) || isLinkKeyName(name);
|
|
501
|
+
return canonicaliseJsonLinks(value, run, where, asUrl);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Canonicalise a content instance's values against its module's fields.
|
|
506
|
+
* Values without a declared field are still scanned (key-name rule +
|
|
507
|
+
* HTML) — a stale value renders nowhere today but may again tomorrow.
|
|
508
|
+
*/
|
|
509
|
+
export function canonicaliseContentValues(
|
|
510
|
+
fields: readonly CanonicaliseField[],
|
|
511
|
+
values: Readonly<Record<string, unknown>>,
|
|
512
|
+
run: LinkCanonicaliseRun,
|
|
513
|
+
): Record<string, unknown> {
|
|
514
|
+
const byName = new Map(fields.map((f) => [f.name, f]));
|
|
515
|
+
const out: Record<string, unknown> = {};
|
|
516
|
+
for (const [k, v] of Object.entries(values)) {
|
|
517
|
+
out[k] = canonicaliseFieldValue(byName.get(k), k, v, run, `values.${k}`);
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Canonicalise every field's `default` (layout chrome content lives there). */
|
|
523
|
+
export function canonicaliseFieldDefaults<F extends CanonicaliseField>(
|
|
524
|
+
fields: readonly F[],
|
|
525
|
+
run: LinkCanonicaliseRun,
|
|
526
|
+
): F[] {
|
|
527
|
+
return fields.map((f) => {
|
|
528
|
+
if (f.default === undefined || f.default === null) return f;
|
|
529
|
+
const next = canonicaliseFieldValue(f, f.name, f.default, run, `fields.${f.name}.default`);
|
|
530
|
+
return next === f.default ? f : { ...f, default: next };
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Every link literal inside a stored state (values, defaults, set items,
|
|
536
|
+
* module HTML) — the {@link LinkCanonicaliseRun.grandfathered} set of the
|
|
537
|
+
* next write. Collected with a lookup that knows nothing, so every
|
|
538
|
+
* candidate surfaces as an "issue" carrying its raw value.
|
|
539
|
+
*/
|
|
540
|
+
export function collectLinkLiterals(state: unknown): Set<string> {
|
|
541
|
+
const run = linkCanonicaliseRun(NOTHING_RESOLVES, { mode: "report" });
|
|
542
|
+
canonicaliseJsonLinks(state, run, "", false);
|
|
543
|
+
// Whole-value positions are only known with field context; strings
|
|
544
|
+
// that look like a link are therefore also taken verbatim.
|
|
545
|
+
const out = new Set(run.issues.map((i) => i.value));
|
|
546
|
+
collectStrings(state, out);
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function collectStrings(value: unknown, out: Set<string>): void {
|
|
551
|
+
if (typeof value === "string") {
|
|
552
|
+
const v = value.trim();
|
|
553
|
+
if (v.startsWith("/") || v.startsWith("caelo:") || /^https?:\/\//i.test(v)) out.add(v);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (Array.isArray(value)) for (const v of value) collectStrings(v, out);
|
|
557
|
+
else if (value !== null && typeof value === "object") {
|
|
558
|
+
for (const v of Object.values(value as Record<string, unknown>)) collectStrings(v, out);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const NOTHING_RESOLVES: LinkTargetLookup = {
|
|
563
|
+
pageIdForPath: () => null,
|
|
564
|
+
mediaIdForSlug: () => null,
|
|
565
|
+
pageExists: () => false,
|
|
566
|
+
mediaExists: () => false,
|
|
567
|
+
isOwnHost: () => true,
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const ISSUE_TEXT: Readonly<Record<LinkCanonicaliseIssue["kind"], string>> = {
|
|
571
|
+
"unknown-page-path": "is not the path of any page on this site",
|
|
572
|
+
"unknown-media": "names no image or file in the media library",
|
|
573
|
+
"dangling-page-ref": "links to a page that was deleted",
|
|
574
|
+
"dangling-media-ref": "links to an image or file that was deleted",
|
|
575
|
+
"unknown-file":
|
|
576
|
+
"is a file path nothing on this site serves (files are served from the media library: upload it, then link its media URL from find_media)",
|
|
577
|
+
"unsupported-link-form":
|
|
578
|
+
"has a query or #anchor a stored link cannot carry (page links allow plain query/anchor characters only; media links allow neither)",
|
|
579
|
+
"malformed-link-ref":
|
|
580
|
+
"starts like an internal reference (caelo:page/ or caelo:media/) but is broken, e.g. a truncated id or extra characters after it; write the page's path (/about#team) or the media URL (/_caelo/media/<slug>) instead, never a caelo: reference",
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* One AI-actionable message for a refused write (CLAUDE.md §11): what is
|
|
585
|
+
* wrong with each link and the next step. Paths only — the author never
|
|
586
|
+
* sees reference ids except for a reference that is already broken.
|
|
587
|
+
*/
|
|
588
|
+
export function describeLinkIssues(issues: readonly LinkCanonicaliseIssue[]): string {
|
|
589
|
+
const lines = issues.slice(0, 10).map((i) => `${i.where}: "${i.value}" ${ISSUE_TEXT[i.kind]}`);
|
|
590
|
+
const more = issues.length > 10 ? ` (and ${issues.length - 10} more)` : "";
|
|
591
|
+
return (
|
|
592
|
+
`Internal link check failed — ${lines.join("; ")}${more}. ` +
|
|
593
|
+
"Internal links must point at an existing page or media asset. Next step: use the exact path " +
|
|
594
|
+
"of an existing page (list_pages shows every page's path) or an existing media URL (find_media); " +
|
|
595
|
+
"if the page does not exist yet, create it first (or leave the link out and add it once the page " +
|
|
596
|
+
"exists). For a link to another site, write the full https:// URL."
|
|
597
|
+
);
|
|
598
|
+
}
|