@glw907/cairn-cms 0.57.1 → 0.58.0
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/CHANGELOG.md +29 -0
- package/dist/components/CairnMediaLibrary.svelte +978 -8
- package/dist/components/admin-icons.d.ts +4 -0
- package/dist/components/admin-icons.js +4 -0
- package/dist/components/cairn-admin.css +255 -3
- package/dist/content/media-rewrite.d.ts +65 -0
- package/dist/content/media-rewrite.js +442 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/rewrite-plan.d.ts +65 -0
- package/dist/media/rewrite-plan.js +61 -0
- package/dist/sveltekit/cairn-admin.d.ts +5 -0
- package/dist/sveltekit/cairn-admin.js +9 -0
- package/dist/sveltekit/content-routes.d.ts +85 -4
- package/dist/sveltekit/content-routes.js +326 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +978 -8
- package/src/lib/components/admin-icons.ts +4 -0
- package/src/lib/content/media-rewrite.ts +555 -0
- package/src/lib/log/events.ts +4 -1
- package/src/lib/media/rewrite-plan.ts +122 -0
- package/src/lib/sveltekit/cairn-admin.ts +9 -0
- package/src/lib/sveltekit/content-routes.ts +434 -5
- package/src/lib/sveltekit/index.ts +2 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// cairn-cms: the replace-in-place rewrite transform. Given one entry's raw markdown and an old
|
|
2
|
+
// content-hash, it rewrites every reference to that hash (a body image, a figure-wrapped image, or
|
|
3
|
+
// the frontmatter hero image.src) to a new asset's canonical `media:` token, and returns a per
|
|
4
|
+
// placement diff. This is the heart of the media-library "replace" action: the same bytes pointed
|
|
5
|
+
// at a new asset, with the surrounding entry left exact.
|
|
6
|
+
//
|
|
7
|
+
// The output is byte-for-byte identical to the input except for the `media:` token substrings that
|
|
8
|
+
// are replaced. The transform never round-trips through gray-matter or a markdown serializer (those
|
|
9
|
+
// reformat YAML and are not byte stable); it splices strings by source offset. The match keys on the
|
|
10
|
+
// parsed hash, the immutable truth, never the cosmetic slug, so a bare `media:<hash>` and a
|
|
11
|
+
// `media:<slug>.<hash>` for the same bytes both repoint. A malformed or non-matching token is left
|
|
12
|
+
// untouched.
|
|
13
|
+
//
|
|
14
|
+
// The body arm parses with the same figure-aware pipeline the render and Edit-block transforms use
|
|
15
|
+
// (remark-parse + gfm + directive), so a `media:` token inside a code span or fence is not an image
|
|
16
|
+
// node and is correctly never matched, matching extractMediaRefs. It also lets the arm classify an
|
|
17
|
+
// image inside a `:::figure` as a 'figure' placement.
|
|
18
|
+
import matter from 'gray-matter';
|
|
19
|
+
import { unified } from 'unified';
|
|
20
|
+
import remarkParse from 'remark-parse';
|
|
21
|
+
import remarkGfm from 'remark-gfm';
|
|
22
|
+
import remarkDirective from 'remark-directive';
|
|
23
|
+
import { visit } from 'unist-util-visit';
|
|
24
|
+
import { parseMediaToken } from '../media/reference.js';
|
|
25
|
+
import { escapeLinkText } from './links.js';
|
|
26
|
+
/** Drop any span that overlaps a span already kept, in source order. A final safety net so two
|
|
27
|
+
* splices can never target the same or overlapping bytes and clobber each other into a corrupt
|
|
28
|
+
* result, no matter how the locating arms behaved. A pure-insert span (`start === end`) overlaps
|
|
29
|
+
* another span only when it sits strictly inside it, so adjacent inserts and edits are kept. */
|
|
30
|
+
function dropOverlappingEdits(edits) {
|
|
31
|
+
const kept = [];
|
|
32
|
+
for (const e of edits) {
|
|
33
|
+
const clashes = kept.some((k) => e.start < k.end && k.start < e.end);
|
|
34
|
+
if (!clashes)
|
|
35
|
+
kept.push(e);
|
|
36
|
+
}
|
|
37
|
+
return kept;
|
|
38
|
+
}
|
|
39
|
+
/** A locating scan for candidate `media:` token substrings. Deliberately broad (it accepts
|
|
40
|
+
* uppercase and other out-of-grammar characters) so a malformed token is still found and then
|
|
41
|
+
* rejected by parseMediaToken, never silently skipped by the locator. The character class stops at
|
|
42
|
+
* whitespace, a quote, or any YAML or markdown delimiter, so a frontmatter value or an image
|
|
43
|
+
* destination ends the candidate. */
|
|
44
|
+
const MEDIA_TOKEN_SCAN = /media:[A-Za-z0-9._-]+/g;
|
|
45
|
+
/** Split a leading frontmatter block off the markdown. `fmBlock` is the `---` fenced block including
|
|
46
|
+
* both fences and the trailing newline (empty when there is none); `body` is everything after it.
|
|
47
|
+
* The block leads the document, so a frontmatter offset is already absolute and a body offset needs
|
|
48
|
+
* `fmBlock.length` added. Shared by every arm so they agree on the boundary. */
|
|
49
|
+
function splitFrontmatter(markdown) {
|
|
50
|
+
const m = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
51
|
+
const fmBlock = m ? m[0] : '';
|
|
52
|
+
return { fmBlock, body: markdown.slice(fmBlock.length) };
|
|
53
|
+
}
|
|
54
|
+
/** Parse a doc with the figure-aware pipeline, so the body arm agrees with what remarkFigure renders
|
|
55
|
+
* and can see the enclosing `:::figure` container. Mirrors parseFigureDoc in markdown-format.ts. */
|
|
56
|
+
function parseFigureDoc(doc) {
|
|
57
|
+
return unified().use(remarkParse).use(remarkGfm).use(remarkDirective).parse(doc);
|
|
58
|
+
}
|
|
59
|
+
/** Whether `target` sits inside a `figure`-named container directive. Walks the tree to find the
|
|
60
|
+
* ancestor, since unist-util-visit's per-call ancestors are not retained across the traversal.
|
|
61
|
+
* Mirrors enclosingFigure in markdown-format.ts, reduced to a boolean. */
|
|
62
|
+
function inFigure(tree, target) {
|
|
63
|
+
let found = false;
|
|
64
|
+
visit(tree, 'containerDirective', (dir) => {
|
|
65
|
+
if (dir.name !== 'figure')
|
|
66
|
+
return;
|
|
67
|
+
visit(dir, 'image', (img) => {
|
|
68
|
+
if (img === target)
|
|
69
|
+
found = true;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
/** Split fmBlock into lines once, so the locator helpers walk a shared structure instead of
|
|
75
|
+
* re-scanning the block per call. */
|
|
76
|
+
function fmLines(fmBlock) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
let pos = 0;
|
|
79
|
+
while (pos <= fmBlock.length) {
|
|
80
|
+
const nl = fmBlock.indexOf('\n', pos);
|
|
81
|
+
const end = nl === -1 ? fmBlock.length : nl;
|
|
82
|
+
lines.push({ start: pos, end });
|
|
83
|
+
if (nl === -1)
|
|
84
|
+
break;
|
|
85
|
+
pos = nl + 1;
|
|
86
|
+
}
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
/** The inclusive line-index range `[lo, hi]` of the block-style mapping a top-level key opens: the
|
|
90
|
+
* line `^<key>:` at indent 0 through the last line before the next top-level key (or the document
|
|
91
|
+
* end). A flow-style value (`key: { ... }` all on one line) yields a single-line range. Returns null
|
|
92
|
+
* when the key has no top-level line, which a malformed or non-canonical block can cause. Scoping the
|
|
93
|
+
* per-key search to this range is what lets two image fields that share one hash, or an image field
|
|
94
|
+
* whose hash also appears in a sibling text value, resolve to distinct, correct spans. */
|
|
95
|
+
function frontmatterKeyRange(lines, fmBlock, key) {
|
|
96
|
+
const opener = new RegExp(`^${escapeForRegExp(key)}:`);
|
|
97
|
+
const topLevelKey = /^[^\s#][^:]*:/;
|
|
98
|
+
const isBoundary = (i) => {
|
|
99
|
+
const text = fmBlock.slice(lines[i].start, lines[i].end);
|
|
100
|
+
// A new top-level key or the closing `---` fence ends the current key's block.
|
|
101
|
+
return topLevelKey.test(text) || text === '---';
|
|
102
|
+
};
|
|
103
|
+
let lo = -1;
|
|
104
|
+
for (let i = 1; i < lines.length - 1; i += 1) {
|
|
105
|
+
// Skip the leading `---` fence (line 0) and the trailing empty line after the closing fence.
|
|
106
|
+
if (opener.test(fmBlock.slice(lines[i].start, lines[i].end))) {
|
|
107
|
+
lo = i;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (lo === -1)
|
|
112
|
+
return null;
|
|
113
|
+
let hi = lo;
|
|
114
|
+
for (let i = lo + 1; i < lines.length - 1; i += 1) {
|
|
115
|
+
if (isBoundary(i))
|
|
116
|
+
break;
|
|
117
|
+
hi = i;
|
|
118
|
+
}
|
|
119
|
+
return [lo, hi];
|
|
120
|
+
}
|
|
121
|
+
/** Find the block-style `src:` line within `[lo, hi]` whose value token parses to `hash`. The token
|
|
122
|
+
* is located by the broad scan and validated through parseMediaToken (matching on hash), so a
|
|
123
|
+
* malformed token is found then rejected. Returns null for a flow-style value (no own `src:` line),
|
|
124
|
+
* which leaves that shape unanchorable rather than splicing a guessed span. */
|
|
125
|
+
function findSrcLineInRange(lines, fmBlock, range, hash) {
|
|
126
|
+
const srcKeyRe = /^(\s*)src:[ \t]?/;
|
|
127
|
+
for (let i = range[0]; i <= range[1]; i += 1) {
|
|
128
|
+
const lineText = fmBlock.slice(lines[i].start, lines[i].end);
|
|
129
|
+
const keyMatch = srcKeyRe.exec(lineText);
|
|
130
|
+
if (!keyMatch)
|
|
131
|
+
continue;
|
|
132
|
+
const valueStart = lines[i].start + keyMatch[0].length;
|
|
133
|
+
const valueText = fmBlock.slice(valueStart, lines[i].end);
|
|
134
|
+
for (const m of valueText.matchAll(MEDIA_TOKEN_SCAN)) {
|
|
135
|
+
const token = m[0];
|
|
136
|
+
const ref = parseMediaToken(token);
|
|
137
|
+
if (!ref || ref.hash !== hash)
|
|
138
|
+
continue;
|
|
139
|
+
const tokenStart = valueStart + m.index;
|
|
140
|
+
return {
|
|
141
|
+
lineStart: lines[i].start,
|
|
142
|
+
lineEnd: lines[i].end,
|
|
143
|
+
indent: keyMatch[1],
|
|
144
|
+
tokenStart,
|
|
145
|
+
tokenEnd: tokenStart + token.length,
|
|
146
|
+
token,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
/** The image-like top-level frontmatter keys whose `src` parses to `hash`, in source order. A key is
|
|
153
|
+
* image-like when its value is an object carrying a string `src`; this is the same shape
|
|
154
|
+
* extractMediaRefs reads, so a token in a plain-text value (a `title:`/`note:`) is never treated as a
|
|
155
|
+
* reference. The bucket-classifying data comes from gray-matter (which handles every quoting form);
|
|
156
|
+
* the byte edit is located structurally by the caller, keyed back to this key name. */
|
|
157
|
+
function imageFieldKeys(data, hash) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const [key, value] of Object.entries(data)) {
|
|
160
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
161
|
+
continue;
|
|
162
|
+
const obj = value;
|
|
163
|
+
if (typeof obj.src !== 'string')
|
|
164
|
+
continue;
|
|
165
|
+
const ref = parseMediaToken(obj.src);
|
|
166
|
+
if (!ref || ref.hash !== hash)
|
|
167
|
+
continue;
|
|
168
|
+
out.push({ key, obj });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** Collect hero src-token edits inside the frontmatter block. Only an image-field `src:` line is
|
|
173
|
+
* rewritten: the structure is read via gray-matter (image-like keys), and each key's `src:` line is
|
|
174
|
+
* located structurally within that key's block. A `media:` token sitting in a plain-text value (a
|
|
175
|
+
* `title:` or `description:`) is on no `src:` line, so it is left untouched, keeping the byte-exact
|
|
176
|
+
* contract and agreeing with extractMediaRefs. A flow-style hero has no `src:` line and is skipped. */
|
|
177
|
+
function frontmatterEdits(markdown, fmBlock, oldHash) {
|
|
178
|
+
if (fmBlock === '')
|
|
179
|
+
return [];
|
|
180
|
+
const data = matter(markdown).data;
|
|
181
|
+
const lines = fmLines(fmBlock);
|
|
182
|
+
const edits = [];
|
|
183
|
+
for (const { key } of imageFieldKeys(data, oldHash)) {
|
|
184
|
+
const range = frontmatterKeyRange(lines, fmBlock, key);
|
|
185
|
+
if (!range)
|
|
186
|
+
continue;
|
|
187
|
+
const src = findSrcLineInRange(lines, fmBlock, range, oldHash);
|
|
188
|
+
if (!src)
|
|
189
|
+
continue;
|
|
190
|
+
edits.push({ start: src.tokenStart, end: src.tokenEnd, before: src.token, kind: 'hero' });
|
|
191
|
+
}
|
|
192
|
+
return edits;
|
|
193
|
+
}
|
|
194
|
+
/** Locate the exact `media:` token substring inside one image node's source span. The destination
|
|
195
|
+
* begins at the `](` that follows the alt text, so the search starts there to avoid a false match on
|
|
196
|
+
* a `media:`-like string inside the alt. Returns null when the token cannot be located, which leaves
|
|
197
|
+
* the image untouched rather than splicing a guessed range. */
|
|
198
|
+
function locateImageToken(span, url) {
|
|
199
|
+
const destStart = span.indexOf('](');
|
|
200
|
+
const from = destStart === -1 ? 0 : destStart + 2;
|
|
201
|
+
const at = span.indexOf(url, from);
|
|
202
|
+
if (at === -1)
|
|
203
|
+
return null;
|
|
204
|
+
return { start: at, end: at + url.length };
|
|
205
|
+
}
|
|
206
|
+
/** Find every body image whose url parses to `hash`, in source order, with absolute offsets. Parses
|
|
207
|
+
* with the figure-aware pipeline, so a `media:` token inside a code span or fence is not an image
|
|
208
|
+
* node and is correctly skipped, matching extractMediaRefs. */
|
|
209
|
+
function matchedBodyImages(body, blockLength, hash) {
|
|
210
|
+
const tree = parseFigureDoc(body);
|
|
211
|
+
const hits = [];
|
|
212
|
+
visit(tree, 'image', (node) => {
|
|
213
|
+
const ref = parseMediaToken(node.url);
|
|
214
|
+
if (!ref || ref.hash !== hash)
|
|
215
|
+
return;
|
|
216
|
+
const from = node.position?.start?.offset;
|
|
217
|
+
const to = node.position?.end?.offset;
|
|
218
|
+
if (from == null || to == null)
|
|
219
|
+
return;
|
|
220
|
+
hits.push({
|
|
221
|
+
node,
|
|
222
|
+
nodeFrom: blockLength + from,
|
|
223
|
+
nodeTo: blockLength + to,
|
|
224
|
+
kind: inFigure(tree, node) ? 'figure' : 'body',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
return hits;
|
|
228
|
+
}
|
|
229
|
+
/** Collect body edits over the body slice. Each matching image is located within its own source span
|
|
230
|
+
* and recorded with an absolute offset. The kind is 'figure' when the image is inside a `:::figure`,
|
|
231
|
+
* else 'body'. */
|
|
232
|
+
function bodyEdits(body, blockLength, oldHash) {
|
|
233
|
+
const edits = [];
|
|
234
|
+
for (const hit of matchedBodyImages(body, blockLength, oldHash)) {
|
|
235
|
+
const span = body.slice(hit.nodeFrom - blockLength, hit.nodeTo - blockLength);
|
|
236
|
+
const loc = locateImageToken(span, hit.node.url);
|
|
237
|
+
if (!loc)
|
|
238
|
+
continue;
|
|
239
|
+
const start = hit.nodeFrom + loc.start;
|
|
240
|
+
const end = hit.nodeFrom + loc.end;
|
|
241
|
+
edits.push({ start, end, before: hit.node.url, kind: hit.kind });
|
|
242
|
+
}
|
|
243
|
+
return edits;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Rewrite every reference to `oldHash` in one entry's raw markdown to `newToken`, and return the
|
|
247
|
+
* rewritten markdown plus a per-placement diff. Only an image-field `src:` line is rewritten in the
|
|
248
|
+
* frontmatter: the image-like keys are read via gray-matter and each key's `src:` line is located
|
|
249
|
+
* structurally within its own block, so a `media:` token that merely appears in a plain-text value (a
|
|
250
|
+
* `title:` or `description:`) is left untouched, matching extractMediaRefs. Body and figure images are
|
|
251
|
+
* matched by mdast offset over the body slice. The output is byte-for-byte identical to the input
|
|
252
|
+
* apart from the replaced token substrings, so the rest of the entry (alt text, captions, the
|
|
253
|
+
* `:::figure` fences, every other frontmatter key) is preserved exactly. A non-matching hash returns
|
|
254
|
+
* the markdown unchanged with an empty placement list; a malformed `media:` reference is left
|
|
255
|
+
* untouched. Pure and node-safe.
|
|
256
|
+
*/
|
|
257
|
+
export function repointMediaRef(markdown, oldHash, newToken) {
|
|
258
|
+
const { fmBlock, body } = splitFrontmatter(markdown);
|
|
259
|
+
const heroEdits = frontmatterEdits(markdown, fmBlock, oldHash);
|
|
260
|
+
const bodyEditList = bodyEdits(body, fmBlock.length, oldHash);
|
|
261
|
+
const edits = dropOverlappingEdits([...heroEdits, ...bodyEditList]);
|
|
262
|
+
if (edits.length === 0)
|
|
263
|
+
return { markdown, placements: [] };
|
|
264
|
+
// placements read in document order (frontmatter first, then body in source order, which is the
|
|
265
|
+
// order each arm already emits). The diff lists each changed reference once.
|
|
266
|
+
const placements = edits.map((e) => ({
|
|
267
|
+
kind: e.kind,
|
|
268
|
+
before: e.before,
|
|
269
|
+
after: newToken,
|
|
270
|
+
}));
|
|
271
|
+
// Apply from last offset to first so each splice leaves the earlier offsets valid.
|
|
272
|
+
const byOffset = [...edits].sort((a, b) => b.start - a.start);
|
|
273
|
+
let out = markdown;
|
|
274
|
+
for (const e of byOffset) {
|
|
275
|
+
out = out.slice(0, e.start) + newToken + out.slice(e.end);
|
|
276
|
+
}
|
|
277
|
+
return { markdown: out, placements };
|
|
278
|
+
}
|
|
279
|
+
/** Classify an existing alt into its non-decorative bucket: an empty (or whitespace-only) alt is
|
|
280
|
+
* filled, a non-empty alt is a custom alt the caller may opt in to overwrite. Mirrors the empty-alt
|
|
281
|
+
* test findMediaImagesNeedingAlt uses. */
|
|
282
|
+
function classifyAlt(existing) {
|
|
283
|
+
return existing.trim() === '' ? 'will-fill' : 'customized';
|
|
284
|
+
}
|
|
285
|
+
/** Whether a bucket plus the overwrite choice means the alt text is actually rewritten. A will-fill
|
|
286
|
+
* always writes; a customized alt writes only on opt-in; a decorative hero never writes. */
|
|
287
|
+
function altIsEdited(bucket, overwrite) {
|
|
288
|
+
if (bucket === 'will-fill')
|
|
289
|
+
return true;
|
|
290
|
+
if (bucket === 'customized')
|
|
291
|
+
return overwrite;
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
/** Collect the body and figure alt edits over the body slice. The alt source span sits between ` is spliced there. The existing alt is the parser's already
|
|
297
|
+
* unescaped `node.alt`. A body image has no decorative slot, so an empty alt is always will-fill. */
|
|
298
|
+
function bodyAltEdits(body, blockLength, hash, defaultAlt, overwrite) {
|
|
299
|
+
const edits = [];
|
|
300
|
+
for (const hit of matchedBodyImages(body, blockLength, hash)) {
|
|
301
|
+
const span = body.slice(hit.nodeFrom - blockLength, hit.nodeTo - blockLength);
|
|
302
|
+
if (!span.startsWith('` lands inside an
|
|
306
|
+
// alt that itself contains `](` or a nested `` and would truncate the image on overwrite.
|
|
307
|
+
const loc = locateImageToken(span, hit.node.url);
|
|
308
|
+
if (!loc)
|
|
309
|
+
continue;
|
|
310
|
+
const close = span.lastIndexOf('](', loc.start);
|
|
311
|
+
if (close === -1)
|
|
312
|
+
continue;
|
|
313
|
+
const before = hit.node.alt ?? '';
|
|
314
|
+
const bucket = classifyAlt(before);
|
|
315
|
+
const write = altIsEdited(bucket, overwrite);
|
|
316
|
+
const after = write ? defaultAlt : before;
|
|
317
|
+
const placement = { kind: hit.kind, bucket, before, after };
|
|
318
|
+
if (!write) {
|
|
319
|
+
edits.push({ apply: false, start: hit.nodeFrom, end: hit.nodeFrom, text: '', placement });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// Replace the alt text between `.
|
|
324
|
+
const altStart = hit.nodeFrom - blockLength + 2;
|
|
325
|
+
const altEnd = hit.nodeFrom - blockLength + close;
|
|
326
|
+
edits.push({
|
|
327
|
+
apply: true,
|
|
328
|
+
start: blockLength + altStart,
|
|
329
|
+
end: blockLength + altEnd,
|
|
330
|
+
text: escapeLinkText(defaultAlt),
|
|
331
|
+
placement,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return edits;
|
|
335
|
+
}
|
|
336
|
+
/** Escape a literal string for safe interpolation into a RegExp source. A key name or an indent is
|
|
337
|
+
* matched literally, so its characters must not act as metacharacters. */
|
|
338
|
+
function escapeForRegExp(literal) {
|
|
339
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
340
|
+
}
|
|
341
|
+
/** Find a sibling key line (`alt:` or `decorative:`) at exactly `indent` within the inclusive
|
|
342
|
+
* line-index range `[lo, hi]` of one mapping. The range is the mapping's own block, so the search
|
|
343
|
+
* spans the whole mapping rather than a same-indent contiguous run: a blank line or a deeper-nested
|
|
344
|
+
* child between `src:` and `alt:` no longer hides the existing key (which would otherwise insert a
|
|
345
|
+
* duplicate key and break the YAML). Returns the key line's value span (after the key and its space,
|
|
346
|
+
* to end of line) or null when the mapping has no such key at that indent. */
|
|
347
|
+
function findSiblingKeyValue(lines, fmBlock, range, indent, key) {
|
|
348
|
+
const keyRe = new RegExp(`^${escapeForRegExp(indent)}${escapeForRegExp(key)}:[ \\t]?`);
|
|
349
|
+
for (let i = range[0]; i <= range[1]; i += 1) {
|
|
350
|
+
const lineText = fmBlock.slice(lines[i].start, lines[i].end);
|
|
351
|
+
const m = keyRe.exec(lineText);
|
|
352
|
+
if (m)
|
|
353
|
+
return { start: lines[i].start + m[0].length, end: lines[i].end };
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
/** Collect the hero alt edits inside the frontmatter block. The image-field objects (and their
|
|
358
|
+
* decorative and alt values) are read via gray-matter to classify the bucket robustly across quoting
|
|
359
|
+
* forms; the byte edit is then located structurally, scoped to each field's own mapping block, keyed
|
|
360
|
+
* back by the top-level field name. Iterating the fields in source order keeps the hero placements in
|
|
361
|
+
* document order. A decorative hero is reported and never edited; an empty alt is filled; a custom
|
|
362
|
+
* alt is overwritten only on opt-in. An alt key that is present (anywhere in the mapping, even below a
|
|
363
|
+
* blank line or a nested child) has its value replaced; an absent one is inserted right after the
|
|
364
|
+
* `src:` line at the same indent. The new value is a JSON-quoted scalar, valid YAML that handles a
|
|
365
|
+
* colon, a quote, or an empty string. A flow-style hero (`image: { ... }`, no own `src:` line) is
|
|
366
|
+
* unanchorable, so it is reported from the gray-matter read but never spliced. */
|
|
367
|
+
function heroAltEdits(markdown, fmBlock, hash, defaultAlt, overwrite) {
|
|
368
|
+
if (fmBlock === '')
|
|
369
|
+
return [];
|
|
370
|
+
const data = matter(markdown).data;
|
|
371
|
+
const lines = fmLines(fmBlock);
|
|
372
|
+
const edits = [];
|
|
373
|
+
const quoted = JSON.stringify(defaultAlt);
|
|
374
|
+
for (const { key, obj } of imageFieldKeys(data, hash)) {
|
|
375
|
+
const decorative = obj.decorative === true;
|
|
376
|
+
const before = typeof obj.alt === 'string' ? obj.alt : '';
|
|
377
|
+
const bucket = decorative ? 'decorative-skipped' : classifyAlt(before);
|
|
378
|
+
const write = altIsEdited(bucket, overwrite);
|
|
379
|
+
const after = write ? defaultAlt : before;
|
|
380
|
+
const placement = { kind: 'hero', bucket, before, after };
|
|
381
|
+
const range = write ? frontmatterKeyRange(lines, fmBlock, key) : null;
|
|
382
|
+
const src = range ? findSrcLineInRange(lines, fmBlock, range, hash) : null;
|
|
383
|
+
if (!write || !range || !src) {
|
|
384
|
+
// Reported but not edited: a kept custom alt, a decorative hero, or an unanchorable flow-style
|
|
385
|
+
// hero (no own `src:` line). It carries a diff entry but no splice, so the bytes stay exact.
|
|
386
|
+
edits.push({ apply: false, start: 0, end: 0, text: '', placement });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const altSpan = findSiblingKeyValue(lines, fmBlock, range, src.indent, 'alt');
|
|
390
|
+
if (altSpan) {
|
|
391
|
+
edits.push({ apply: true, start: altSpan.start, end: altSpan.end, text: quoted, placement });
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
// No alt key: insert one on its own line right after the src line, at the sibling indent.
|
|
395
|
+
edits.push({
|
|
396
|
+
apply: true,
|
|
397
|
+
start: src.lineEnd,
|
|
398
|
+
end: src.lineEnd,
|
|
399
|
+
text: `\n${src.indent}alt: ${quoted}`,
|
|
400
|
+
placement,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return edits;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Set the alt at each placement of `hash` in one entry's raw markdown, and return the rewritten
|
|
408
|
+
* markdown plus a per-placement diff. An empty alt is filled with `defaultAlt` (bucket will-fill). A
|
|
409
|
+
* non-empty alt is overwritten with `defaultAlt` only when `opts.overwrite` is true (bucket
|
|
410
|
+
* customized; otherwise left unchanged but still reported, so the preview can show it and offer the
|
|
411
|
+
* opt-in). A frontmatter hero with `decorative: true` is bucket decorative-skipped and never changed.
|
|
412
|
+
* A body or figure image has no decorative slot, so its empty alt is always will-fill.
|
|
413
|
+
*
|
|
414
|
+
* The output is byte-for-byte identical to the input apart from the alt text it actually changes. The
|
|
415
|
+
* hero alt is edited inside the frontmatter block by string splice (no gray-matter serialize round
|
|
416
|
+
* trip, which would reformat the YAML); the structure read uses gray-matter only to classify buckets
|
|
417
|
+
* and read the hero alt and decorative flag. A body alt is written escaped (the way insertImage
|
|
418
|
+
* escapes it) so a `]` in the alt cannot break the image; a hero alt is written as a JSON-quoted YAML
|
|
419
|
+
* scalar so a colon, a quote, or an empty value is robust. Placements read in document order (hero
|
|
420
|
+
* first, then body in source order). A non-matching hash returns the markdown unchanged with an empty
|
|
421
|
+
* placement list. Pure and node-safe.
|
|
422
|
+
*/
|
|
423
|
+
export function fillAltForHash(markdown, hash, defaultAlt, opts) {
|
|
424
|
+
const { fmBlock, body } = splitFrontmatter(markdown);
|
|
425
|
+
const heroEditList = heroAltEdits(markdown, fmBlock, hash, defaultAlt, opts.overwrite);
|
|
426
|
+
const bodyEditList = bodyAltEdits(body, fmBlock.length, hash, defaultAlt, opts.overwrite);
|
|
427
|
+
const edits = [...heroEditList, ...bodyEditList];
|
|
428
|
+
if (edits.length === 0)
|
|
429
|
+
return { markdown, placements: [] };
|
|
430
|
+
const placements = edits.map((e) => e.placement);
|
|
431
|
+
// Apply only the edits that change bytes, from last offset to first so the earlier offsets stay
|
|
432
|
+
// valid. A reported-but-unchanged placement (a kept custom alt, a decorative hero) carries no span.
|
|
433
|
+
// The overlap guard runs in source order over the writes as a final safety net, so two splices can
|
|
434
|
+
// never target overlapping bytes and clobber each other into invalid output.
|
|
435
|
+
const writes = dropOverlappingEdits(edits.filter((e) => e.apply));
|
|
436
|
+
const byOffset = [...writes].sort((a, b) => b.start - a.start);
|
|
437
|
+
let out = markdown;
|
|
438
|
+
for (const e of byOffset) {
|
|
439
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
440
|
+
}
|
|
441
|
+
return { markdown: out, placements };
|
|
442
|
+
}
|
package/dist/log/events.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.deleted' | 'media.delete_blocked';
|
|
1
|
+
export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.deleted' | 'media.delete_blocked' | 'media.replaced' | 'media.replace_blocked' | 'media.alt_propagated';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ConceptDescriptor } from '../content/types.js';
|
|
2
|
+
import type { RepoRef } from '../github/types.js';
|
|
3
|
+
import type { Manifest } from '../content/manifest.js';
|
|
4
|
+
/** One main entry the rewrite will touch: its identity, its file path, the transform's per-placement
|
|
5
|
+
* diff, and the rewritten markdown a later apply commits. `P` is the transform's placement type
|
|
6
|
+
* (a RepointPlacement for replace, an AltPlacement for fill-alt). */
|
|
7
|
+
export interface PlannedEntry<P = unknown> {
|
|
8
|
+
/** The concept id, e.g. "posts". */
|
|
9
|
+
concept: string;
|
|
10
|
+
/** The entry id (its filename stem). */
|
|
11
|
+
id: string;
|
|
12
|
+
/** The entry's repo path, `${concept.dir}/${filenameFromId(id)}`. */
|
|
13
|
+
path: string;
|
|
14
|
+
/** The transform's diff for this entry: one placement per rewritten reference. */
|
|
15
|
+
placements: P[];
|
|
16
|
+
/** The entry's markdown after the transform, byte-identical to the source apart from the rewrite. */
|
|
17
|
+
newMarkdown: string;
|
|
18
|
+
}
|
|
19
|
+
/** One open edit branch that also references the asset, with the entries on it. Report-only: an apply
|
|
20
|
+
* rewrites main, never a branch, so the screen surfaces these as a delta the editor handles by
|
|
21
|
+
* republishing the draft. */
|
|
22
|
+
export interface BranchRef {
|
|
23
|
+
/** The cairn/* branch name. */
|
|
24
|
+
branch: string;
|
|
25
|
+
/** The entries on that branch that reference the asset. */
|
|
26
|
+
entries: {
|
|
27
|
+
concept: string;
|
|
28
|
+
id: string;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
/** The preview plan: the main entries to rewrite, the report-only branch delta, and the distinct
|
|
32
|
+
* count of affected main entries (the entries the transform actually changed). */
|
|
33
|
+
export interface RewritePlan<P = unknown> {
|
|
34
|
+
entries: PlannedEntry<P>[];
|
|
35
|
+
branchDelta: BranchRef[];
|
|
36
|
+
affectedCount: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
|
|
40
|
+
* unverifiable branch read rejects, failing closed), then splits the rows for `args.hash` by origin:
|
|
41
|
+
*
|
|
42
|
+
* - Published rows are the main work. Each entry's file is read in parallel and run through
|
|
43
|
+
* `args.transform`. An entry is included only when the transform reports at least one placement, so
|
|
44
|
+
* a row whose body holds the token in a non-image position (a code span, raw HTML) drops out rather
|
|
45
|
+
* than committing an unchanged file. A row whose concept is not configured, or whose file read
|
|
46
|
+
* returns null (a stale manifest row), is skipped.
|
|
47
|
+
* - Branch rows are the report-only delta, grouped by branch in first-seen order. Branch rows are
|
|
48
|
+
* never the published origin, so main never appears in the delta.
|
|
49
|
+
*
|
|
50
|
+
* `affectedCount` is the number of distinct entries in `entries` (the ones the transform changed). The
|
|
51
|
+
* planner does not read the media manifest: the transform closure already carries the new token or
|
|
52
|
+
* the default alt, so the planner needs only the entry markdown and the usage index. Pure of the
|
|
53
|
+
* editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planMediaRewrite<P = unknown>(args: {
|
|
56
|
+
backend: RepoRef;
|
|
57
|
+
token: string;
|
|
58
|
+
concepts: ConceptDescriptor[];
|
|
59
|
+
contentManifest: Manifest;
|
|
60
|
+
hash: string;
|
|
61
|
+
transform: (markdown: string) => {
|
|
62
|
+
markdown: string;
|
|
63
|
+
placements: P[];
|
|
64
|
+
};
|
|
65
|
+
}): Promise<RewritePlan<P>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { findConcept } from '../content/concepts.js';
|
|
2
|
+
import { filenameFromId } from '../content/ids.js';
|
|
3
|
+
import { readRaw } from '../github/repo.js';
|
|
4
|
+
import { buildUsageIndex } from './usage.js';
|
|
5
|
+
/**
|
|
6
|
+
* Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
|
|
7
|
+
* unverifiable branch read rejects, failing closed), then splits the rows for `args.hash` by origin:
|
|
8
|
+
*
|
|
9
|
+
* - Published rows are the main work. Each entry's file is read in parallel and run through
|
|
10
|
+
* `args.transform`. An entry is included only when the transform reports at least one placement, so
|
|
11
|
+
* a row whose body holds the token in a non-image position (a code span, raw HTML) drops out rather
|
|
12
|
+
* than committing an unchanged file. A row whose concept is not configured, or whose file read
|
|
13
|
+
* returns null (a stale manifest row), is skipped.
|
|
14
|
+
* - Branch rows are the report-only delta, grouped by branch in first-seen order. Branch rows are
|
|
15
|
+
* never the published origin, so main never appears in the delta.
|
|
16
|
+
*
|
|
17
|
+
* `affectedCount` is the number of distinct entries in `entries` (the ones the transform changed). The
|
|
18
|
+
* planner does not read the media manifest: the transform closure already carries the new token or
|
|
19
|
+
* the default alt, so the planner needs only the entry markdown and the usage index. Pure of the
|
|
20
|
+
* editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
|
|
21
|
+
*/
|
|
22
|
+
export async function planMediaRewrite(args) {
|
|
23
|
+
// Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
|
|
24
|
+
// Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
|
|
25
|
+
const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
|
|
26
|
+
strict: true,
|
|
27
|
+
});
|
|
28
|
+
const rows = index.get(args.hash) ?? [];
|
|
29
|
+
// The main arm: read each referencing published entry in parallel (one round-trip latency floor,
|
|
30
|
+
// mirroring buildUsageIndex's per-branch batch), run the transform, and keep only the entries it
|
|
31
|
+
// changed. A null is a row whose concept is not configured or whose file is absent: it is skipped.
|
|
32
|
+
const published = rows.filter((row) => row.origin.kind === 'published');
|
|
33
|
+
const planned = await Promise.all(published.map(async (row) => {
|
|
34
|
+
const concept = findConcept(args.concepts, row.concept);
|
|
35
|
+
if (!concept)
|
|
36
|
+
return null;
|
|
37
|
+
const path = `${concept.dir}/${filenameFromId(row.id)}`;
|
|
38
|
+
const markdown = await readRaw(args.backend, path, args.token);
|
|
39
|
+
if (markdown === null)
|
|
40
|
+
return null;
|
|
41
|
+
const result = args.transform(markdown);
|
|
42
|
+
if (result.placements.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
return { concept: row.concept, id: row.id, path, placements: result.placements, newMarkdown: result.markdown };
|
|
45
|
+
}));
|
|
46
|
+
const entries = planned.filter((entry) => entry !== null);
|
|
47
|
+
// The branch arm: group the branch rows by branch in first-seen order, preserving the row order the
|
|
48
|
+
// index emits within each group. Branch rows are never the published origin, so main never appears.
|
|
49
|
+
const byBranch = new Map();
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (row.origin.kind !== 'branch')
|
|
52
|
+
continue;
|
|
53
|
+
const list = byBranch.get(row.origin.branch);
|
|
54
|
+
if (list)
|
|
55
|
+
list.push({ concept: row.concept, id: row.id });
|
|
56
|
+
else
|
|
57
|
+
byBranch.set(row.origin.branch, [{ concept: row.concept, id: row.id }]);
|
|
58
|
+
}
|
|
59
|
+
const branchDelta = [...byBranch].map(([branch, branchEntries]) => ({ branch, entries: branchEntries }));
|
|
60
|
+
return { entries, branchDelta, affectedCount: entries.length };
|
|
61
|
+
}
|
|
@@ -81,6 +81,11 @@ export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdmi
|
|
|
81
81
|
delete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
82
82
|
mediaDelete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
83
83
|
mediaUpdate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
84
|
+
mediaUpload: (event: AdminEvent) => Promise<import("./content-routes.js").UploadResult | import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
85
|
+
mediaReplacePreview: (event: AdminEvent) => Promise<import("./content-routes.js").MediaReplacePreviewPlan | import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
86
|
+
mediaReplace: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
87
|
+
mediaAltPreview: (event: AdminEvent) => Promise<import("./content-routes.js").MediaAltPreviewPlan | import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
88
|
+
mediaAltPropagate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
|
|
84
89
|
publishAll: (event: AdminEvent) => Promise<never>;
|
|
85
90
|
addEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
|
|
86
91
|
error: string;
|
|
@@ -125,6 +125,15 @@ export function createCairnAdmin(runtime, deps = {}) {
|
|
|
125
125
|
: content.listDeleteAction(contentEvent(event, { concept: view.concept.id }))),
|
|
126
126
|
mediaDelete: viewAction(['media'], (event) => content.mediaDeleteAction(contentEvent(event, {}))),
|
|
127
127
|
mediaUpdate: viewAction(['media'], (event) => content.mediaUpdateAction(contentEvent(event, {}))),
|
|
128
|
+
// The Library is not entry-scoped, so a replace uploads its new file through the same content-
|
|
129
|
+
// addressed ingest mounted media-scoped (uploadAction reads no concept/id), then previews and
|
|
130
|
+
// applies the repoint. Alt propagation previews and applies the alt fill. The preview pair are 2a
|
|
131
|
+
// fetch actions; the apply pair are form posts. All gate on the media view.
|
|
132
|
+
mediaUpload: viewAction(['media'], (event) => content.uploadAction(contentEvent(event, {}))),
|
|
133
|
+
mediaReplacePreview: viewAction(['media'], (event) => content.mediaReplacePreview(contentEvent(event, {}))),
|
|
134
|
+
mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
|
|
135
|
+
mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
|
|
136
|
+
mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
|
|
128
137
|
publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
|
|
129
138
|
addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
|
|
130
139
|
removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
|