@glw907/cairn-cms 0.55.0 → 0.56.1
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 +39 -0
- package/README.md +4 -3
- package/dist/components/ConceptList.svelte +8 -4
- package/dist/components/MarkdownEditor.svelte +64 -45
- package/dist/components/editor-folding.d.ts +4 -3
- package/dist/components/editor-folding.js +129 -97
- package/dist/components/editor-highlight.js +1 -13
- package/dist/content/concepts.js +3 -1
- package/dist/content/types.d.ts +5 -0
- package/dist/sveltekit/content-routes.d.ts +3 -0
- package/dist/sveltekit/content-routes.js +1 -1
- package/dist/sveltekit/index.d.ts +1 -0
- package/dist/vite/index.d.ts +6 -4
- package/dist/vite/index.js +11 -7
- package/dist/vite/resolve-root.d.ts +16 -0
- package/dist/vite/resolve-root.js +16 -0
- package/package.json +2 -1
- package/src/lib/components/ConceptList.svelte +8 -4
- package/src/lib/components/MarkdownEditor.svelte +64 -45
- package/src/lib/components/editor-folding.ts +137 -104
- package/src/lib/components/editor-highlight.ts +0 -12
- package/src/lib/content/concepts.ts +3 -1
- package/src/lib/content/types.ts +5 -0
- package/src/lib/sveltekit/content-routes.ts +4 -1
- package/src/lib/sveltekit/index.ts +2 -0
- package/src/lib/vite/index.ts +11 -7
- package/src/lib/vite/resolve-root.ts +24 -0
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
// Container folding: CodeMirror's fold system driven by cairn's directive grammar, with
|
|
2
|
-
//
|
|
1
|
+
// Container folding: CodeMirror's fold system driven by cairn's directive grammar, with a real
|
|
2
|
+
// gutter column as the affordance. Client-only like editor-highlight and editor-modes; MarkdownEditor
|
|
3
3
|
// reaches this module through a dynamic import, so the static @codemirror imports here never enter
|
|
4
4
|
// a server bundle (guarded by the editor-boundary test).
|
|
5
5
|
//
|
|
6
|
-
// The architecture (
|
|
6
|
+
// The architecture (spec 2026-06-14): @codemirror/language's codeFolding plus foldEffect/
|
|
7
7
|
// unfoldEffect, never a custom fold store. Fold ranges come only from containerRanges, the pure
|
|
8
8
|
// pairing helper beside fenceScan. The safety invariant lives in one transactionExtender that
|
|
9
|
-
// appends unfold effects when a change or selection touches a folded range. The
|
|
10
|
-
//
|
|
11
|
-
// gutter
|
|
9
|
+
// appends unfold effects when a change or selection touches a folded range. The control is a custom
|
|
10
|
+
// gutter() whose GutterMarker is a focusable button on each paired-opener row; a lower-level
|
|
11
|
+
// gutter (not foldGutter) is what lets the caret-inside state stay live, since its lineMarkerChange
|
|
12
|
+
// recomputes on selection changes.
|
|
12
13
|
//
|
|
13
14
|
// The safety invariant: an author never edits, deletes, or fails to see hidden text.
|
|
14
15
|
import {
|
|
15
16
|
Decoration,
|
|
16
17
|
EditorView,
|
|
18
|
+
GutterMarker,
|
|
17
19
|
ViewPlugin,
|
|
18
|
-
|
|
20
|
+
gutter,
|
|
19
21
|
keymap,
|
|
20
22
|
type DecorationSet,
|
|
21
23
|
type ViewUpdate,
|
|
@@ -24,17 +26,11 @@ import { EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Exten
|
|
|
24
26
|
import { codeFolding, foldEffect, foldedRanges, unfoldEffect } from '@codemirror/language';
|
|
25
27
|
import { caretContainerRange, containerRanges, fenceScan, type ContainerRange } from './markdown-directives.js';
|
|
26
28
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
// The chevron sits over the container's own innermost bar: depth 1 at x0, depth 2 at x8, depth 3
|
|
30
|
-
// at x16, so indentation telegraphs the nesting and depth 3 never collides with depth 2.
|
|
31
|
-
const chevronX = (depth: number) => (Math.min(depth, DEPTH_STEPS) - 1) * 8;
|
|
32
|
-
|
|
33
|
-
// The two chevron glyphs from the gold-standard mockup: down (caret inside) and right (folded).
|
|
29
|
+
// One chevron glyph; CSS rotates it (down open, right folded) and reveals it on gutter hover. The
|
|
30
|
+
// gutter is the fixed-x home, so the chevron no longer encodes depth by position.
|
|
34
31
|
const CHEVRON_DOWN = 'm6 9 6 6 6-6';
|
|
35
|
-
const CHEVRON_RIGHT = 'm9 6 6 6-6 6';
|
|
36
32
|
|
|
37
|
-
function chevronSvg(
|
|
33
|
+
function chevronSvg(): SVGSVGElement {
|
|
38
34
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
39
35
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
40
36
|
svg.setAttribute('fill', 'none');
|
|
@@ -44,57 +40,11 @@ function chevronSvg(direction: 'down' | 'right'): SVGSVGElement {
|
|
|
44
40
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
45
41
|
svg.setAttribute('aria-hidden', 'true');
|
|
46
42
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
47
|
-
path.setAttribute('d',
|
|
43
|
+
path.setAttribute('d', CHEVRON_DOWN);
|
|
48
44
|
svg.appendChild(path);
|
|
49
45
|
return svg;
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
// The opener-row band: the whole 28px gutter is the click target (cursor pointer over the band
|
|
53
|
-
// only, the opener text never folds), and the chevron lives inside it at the container's bar x.
|
|
54
|
-
// The widget knows its own container and whether it is folded, so the click toggles the right
|
|
55
|
-
// range; the depth class lets the theme step the chevron ink.
|
|
56
|
-
class FoldBandWidget extends WidgetType {
|
|
57
|
-
constructor(
|
|
58
|
-
readonly range: ContainerRange,
|
|
59
|
-
readonly folded: boolean,
|
|
60
|
-
readonly caretInside: boolean,
|
|
61
|
-
) {
|
|
62
|
-
super();
|
|
63
|
-
}
|
|
64
|
-
eq(other: FoldBandWidget) {
|
|
65
|
-
return (
|
|
66
|
-
other.range.fromLine === this.range.fromLine &&
|
|
67
|
-
other.range.toLine === this.range.toLine &&
|
|
68
|
-
other.range.depth === this.range.depth &&
|
|
69
|
-
other.folded === this.folded &&
|
|
70
|
-
other.caretInside === this.caretInside
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
toDOM(view: EditorView): HTMLElement {
|
|
74
|
-
const band = document.createElement('span');
|
|
75
|
-
const depth = Math.min(this.range.depth, DEPTH_STEPS);
|
|
76
|
-
// Folded rows always show the chevron (right); an open container shows it down while the caret
|
|
77
|
-
// is inside; otherwise it fades in on rail-band hover (the band's own :hover, in the theme).
|
|
78
|
-
// The depth class carries the stepped ink.
|
|
79
|
-
const state = this.folded ? ' cm-cairn-fold-folded' : this.caretInside ? ' cm-cairn-fold-active' : '';
|
|
80
|
-
band.className = `cm-cairn-fold-band cm-cairn-fold-depth-${depth}${state}`;
|
|
81
|
-
const chevron = chevronSvg(this.folded ? 'right' : 'down');
|
|
82
|
-
chevron.style.left = `${chevronX(this.range.depth)}px`;
|
|
83
|
-
band.appendChild(chevron);
|
|
84
|
-
band.addEventListener('mousedown', (e) => {
|
|
85
|
-
// mousedown, not click: a click would first move the caret into the line. preventDefault
|
|
86
|
-
// keeps the caret where it is and stops the band from stealing focus from the editor.
|
|
87
|
-
e.preventDefault();
|
|
88
|
-
e.stopPropagation();
|
|
89
|
-
toggleFold(view, this.range);
|
|
90
|
-
});
|
|
91
|
-
return band;
|
|
92
|
-
}
|
|
93
|
-
ignoreEvent() {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
48
|
// The pill placeholder: a real focusable button counting the hidden lines, the screen-reader story
|
|
99
49
|
// for a fold. preparePlaceholder computes the count off the folded char range so placeholderDOM
|
|
100
50
|
// renders it without re-deriving. Clicking unfolds through CodeMirror's own onclick handler.
|
|
@@ -114,8 +64,7 @@ function placeholderDOM(view: EditorView, onclick: (event: Event) => void, lines
|
|
|
114
64
|
|
|
115
65
|
// The char range a container folds: end-of-opener-line to end-of-closer-line, so the bare closer
|
|
116
66
|
// never dangles. Null when the opener and closer share a line (nothing to hide). The one place
|
|
117
|
-
// that turns a line range into the fold range, shared by the toggle, the keymap, and the
|
|
118
|
-
// decoration build.
|
|
67
|
+
// that turns a line range into the fold range, shared by the toggle, the keymap, and the gutter.
|
|
119
68
|
function foldCharRange(state: EditorState, range: ContainerRange): { from: number; to: number } | null {
|
|
120
69
|
const opener = state.doc.line(range.fromLine + 1);
|
|
121
70
|
const closer = state.doc.line(range.toLine + 1);
|
|
@@ -144,22 +93,47 @@ function foldExists(state: EditorState, from: number, to: number): boolean {
|
|
|
144
93
|
// returns the nearest enclosing container, but a container that never closes (an unbalanced
|
|
145
94
|
// opener) must not fold, so the result is only honored when containerRanges actually pairs it.
|
|
146
95
|
function caretFoldRange(view: EditorView): ContainerRange | null {
|
|
147
|
-
const scan =
|
|
96
|
+
const { scan, ranges } = foldScanFor(view.state);
|
|
148
97
|
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
|
149
98
|
const inner = caretContainerRange(scan, caretLine);
|
|
150
99
|
if (!inner) return null;
|
|
151
|
-
return (
|
|
152
|
-
containerRanges(scan).find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null
|
|
153
|
-
);
|
|
100
|
+
return ranges.find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null;
|
|
154
101
|
}
|
|
155
102
|
|
|
156
|
-
function docLines(
|
|
157
|
-
const doc = view.state.doc;
|
|
103
|
+
function docLines(state: EditorState): string[] {
|
|
158
104
|
const lines: string[] = [];
|
|
159
|
-
for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
|
|
105
|
+
for (let n = 1; n <= state.doc.lines; n++) lines.push(state.doc.line(n).text);
|
|
160
106
|
return lines;
|
|
161
107
|
}
|
|
162
108
|
|
|
109
|
+
// The scan and its paired ranges, memoized per state so the gutter's per-line lookups stay linear
|
|
110
|
+
// rather than rescanning the whole document on every line.
|
|
111
|
+
const scanCache = new WeakMap<EditorState, { scan: ReturnType<typeof fenceScan>; ranges: ContainerRange[] }>();
|
|
112
|
+
function foldScanFor(state: EditorState): { scan: ReturnType<typeof fenceScan>; ranges: ContainerRange[] } {
|
|
113
|
+
let cached = scanCache.get(state);
|
|
114
|
+
if (!cached) {
|
|
115
|
+
const scan = fenceScan(docLines(state));
|
|
116
|
+
cached = { scan, ranges: containerRanges(scan) };
|
|
117
|
+
scanCache.set(state, cached);
|
|
118
|
+
}
|
|
119
|
+
return cached;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The paired container whose opener sits at this line start, or null (a closer, a prose line, or an
|
|
123
|
+
// unbalanced opener gets nothing). The sole source of which rows carry a fold control.
|
|
124
|
+
function openerRangeAt(state: EditorState, lineFrom: number): ContainerRange | null {
|
|
125
|
+
const lineIndex = state.doc.lineAt(lineFrom).number - 1;
|
|
126
|
+
return foldScanFor(state).ranges.find((r) => r.fromLine === lineIndex) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Whether the caret's innermost container is exactly this one, the caret-inside active state.
|
|
130
|
+
function caretInside(state: EditorState, range: ContainerRange): boolean {
|
|
131
|
+
const { scan } = foldScanFor(state);
|
|
132
|
+
const caretLine = state.doc.lineAt(state.selection.main.head).number - 1;
|
|
133
|
+
const inner = caretContainerRange(scan, caretLine);
|
|
134
|
+
return !!inner && inner.fromLine === range.fromLine && inner.toLine === range.toLine;
|
|
135
|
+
}
|
|
136
|
+
|
|
163
137
|
// The keymap: Ctrl+Shift+[ folds, Ctrl+Shift+] unfolds, the innermost container at the caret. No
|
|
164
138
|
// fold-all, no chords. (CodeMirror's own foldKeymap binds the same keys to its tree-folding
|
|
165
139
|
// commands; this module replaces them with the container-aware versions and never adds foldKeymap.)
|
|
@@ -258,33 +232,21 @@ function safetyExtender(): Extension {
|
|
|
258
232
|
});
|
|
259
233
|
}
|
|
260
234
|
|
|
261
|
-
// The
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
function foldDecorations(view: EditorView, scan: ReturnType<typeof fenceScan>, ranges: ContainerRange[]): DecorationSet {
|
|
235
|
+
// The folded-row wash plugin: a line decoration on each folded opener row, square and full-row so
|
|
236
|
+
// folded spots read in a scan. Rebuilds on doc change (the container set moves), viewport change,
|
|
237
|
+
// and any fold change (a fold adds a wash, an unfold removes it). The chevron lives in the gutter
|
|
238
|
+
// now, not here; this plugin carries only the wash and the unfold flash scheduling.
|
|
239
|
+
function foldDecorations(view: EditorView, ranges: ContainerRange[]): DecorationSet {
|
|
267
240
|
const builder = new RangeSetBuilder<Decoration>();
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// One opener row may host several enclosing containers' bars, but only its OWN container opens
|
|
271
|
-
// there, so a single band per opener row. Sort by opener line so the builder receives ascending
|
|
272
|
-
// positions; equal openers cannot happen (one open per line).
|
|
241
|
+
// Sort by opener line so the builder receives ascending positions; equal openers cannot happen
|
|
242
|
+
// (one open per line).
|
|
273
243
|
const byOpener = [...ranges].sort((a, b) => a.fromLine - b.fromLine);
|
|
274
244
|
for (const range of byOpener) {
|
|
275
245
|
const span = foldCharRange(view.state, range);
|
|
276
246
|
if (!span) continue;
|
|
277
247
|
const opener = view.state.doc.line(range.fromLine + 1);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// same element) run through it unbroken.
|
|
281
|
-
if (folded) builder.add(opener.from, opener.from, washLine);
|
|
282
|
-
const caretInside = !!inner && inner.fromLine === range.fromLine && inner.toLine === range.toLine;
|
|
283
|
-
builder.add(
|
|
284
|
-
opener.from,
|
|
285
|
-
opener.from,
|
|
286
|
-
Decoration.widget({ widget: new FoldBandWidget(range, folded, caretInside), side: -1 }),
|
|
287
|
-
);
|
|
248
|
+
// A line decoration so the rails (box-shadows on the same element) run through it unbroken.
|
|
249
|
+
if (foldExists(view.state, span.from, span.to)) builder.add(opener.from, opener.from, washLine);
|
|
288
250
|
}
|
|
289
251
|
return builder.finish();
|
|
290
252
|
}
|
|
@@ -295,23 +257,20 @@ function foldPlugin() {
|
|
|
295
257
|
return ViewPlugin.fromClass(
|
|
296
258
|
class {
|
|
297
259
|
decorations: DecorationSet;
|
|
298
|
-
scan: ReturnType<typeof fenceScan>;
|
|
299
260
|
ranges: ContainerRange[];
|
|
300
261
|
constructor(view: EditorView) {
|
|
301
|
-
this.
|
|
302
|
-
this.
|
|
303
|
-
this.decorations = foldDecorations(view, this.scan, this.ranges);
|
|
262
|
+
this.ranges = foldScanFor(view.state).ranges;
|
|
263
|
+
this.decorations = foldDecorations(view, this.ranges);
|
|
304
264
|
}
|
|
305
265
|
update(update: ViewUpdate) {
|
|
306
266
|
if (update.docChanged) {
|
|
307
|
-
this.
|
|
308
|
-
this.ranges = containerRanges(this.scan);
|
|
267
|
+
this.ranges = foldScanFor(update.view.state).ranges;
|
|
309
268
|
}
|
|
310
269
|
const foldChanged = update.transactions.some((tr) =>
|
|
311
270
|
tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)),
|
|
312
271
|
);
|
|
313
|
-
if (update.docChanged || update.viewportChanged ||
|
|
314
|
-
this.decorations = foldDecorations(update.view, this.
|
|
272
|
+
if (update.docChanged || update.viewportChanged || foldChanged) {
|
|
273
|
+
this.decorations = foldDecorations(update.view, this.ranges);
|
|
315
274
|
}
|
|
316
275
|
// Flash the revealed lines on an unfold, then schedule the clear. Folding adds no flash.
|
|
317
276
|
for (const tr of update.transactions) {
|
|
@@ -335,15 +294,88 @@ function foldPlugin() {
|
|
|
335
294
|
);
|
|
336
295
|
}
|
|
337
296
|
|
|
297
|
+
const FOLD_KEY_HINT = ' (Ctrl+Shift+[)';
|
|
298
|
+
const UNFOLD_KEY_HINT = ' (Ctrl+Shift+])';
|
|
299
|
+
|
|
300
|
+
// The gutter control: a real focusable button per paired-opener row, holding one chevron that CSS
|
|
301
|
+
// rotates (down open, right folded) and reveals on gutter hover. mousedown keeps the caret and
|
|
302
|
+
// focus where they are; click toggles, so one handler serves a mouse click and a keyboard
|
|
303
|
+
// activation (Enter/Space) with no double-toggle. The folded and caret-active classes carry the
|
|
304
|
+
// state the theme reads.
|
|
305
|
+
class FoldMarker extends GutterMarker {
|
|
306
|
+
constructor(
|
|
307
|
+
readonly container: ContainerRange,
|
|
308
|
+
readonly folded: boolean,
|
|
309
|
+
readonly active: boolean,
|
|
310
|
+
) {
|
|
311
|
+
super();
|
|
312
|
+
}
|
|
313
|
+
eq(other: GutterMarker) {
|
|
314
|
+
return (
|
|
315
|
+
other instanceof FoldMarker &&
|
|
316
|
+
other.container.fromLine === this.container.fromLine &&
|
|
317
|
+
other.container.toLine === this.container.toLine &&
|
|
318
|
+
other.folded === this.folded &&
|
|
319
|
+
other.active === this.active
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
toDOM(view: EditorView) {
|
|
323
|
+
const btn = document.createElement('button');
|
|
324
|
+
btn.type = 'button';
|
|
325
|
+
const label = this.folded ? 'Unfold this section' : 'Fold this section';
|
|
326
|
+
btn.className =
|
|
327
|
+
'cm-cairn-fold-btn' +
|
|
328
|
+
(this.folded ? ' cm-cairn-fold-folded' : '') +
|
|
329
|
+
(this.active ? ' cm-cairn-fold-active' : '');
|
|
330
|
+
btn.setAttribute('aria-label', label);
|
|
331
|
+
btn.title = label + (this.folded ? UNFOLD_KEY_HINT : FOLD_KEY_HINT);
|
|
332
|
+
btn.appendChild(chevronSvg());
|
|
333
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
334
|
+
btn.addEventListener('click', (e) => {
|
|
335
|
+
e.preventDefault();
|
|
336
|
+
toggleFold(view, this.container);
|
|
337
|
+
});
|
|
338
|
+
return btn;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The fold gutter: a fixed-x column (width from the theme) carrying one FoldMarker per paired
|
|
343
|
+
// opener. lineMarker returns null for every other row, so the column is empty whitespace down the
|
|
344
|
+
// rest of the surface. lineMarkerChange recomputes the markers on a doc change, a selection change
|
|
345
|
+
// (the caret-inside state follows the cursor), and any fold effect (a fold flips the chevron).
|
|
346
|
+
function foldGutterColumn(): Extension {
|
|
347
|
+
return gutter({
|
|
348
|
+
class: 'cm-cairn-fold-gutter',
|
|
349
|
+
lineMarker(view, line) {
|
|
350
|
+
const range = openerRangeAt(view.state, line.from);
|
|
351
|
+
if (!range) return null;
|
|
352
|
+
const span = foldCharRange(view.state, range);
|
|
353
|
+
if (!span) return null;
|
|
354
|
+
const folded = foldExists(view.state, span.from, span.to);
|
|
355
|
+
return new FoldMarker(range, folded, caretInside(view.state, range));
|
|
356
|
+
},
|
|
357
|
+
lineMarkerChange(update) {
|
|
358
|
+
return (
|
|
359
|
+
update.docChanged ||
|
|
360
|
+
update.selectionSet ||
|
|
361
|
+
update.transactions.some((tr) =>
|
|
362
|
+
tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)),
|
|
363
|
+
)
|
|
364
|
+
);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
338
369
|
// The design notes' diagnostics rules (a deliberately refolded erroring container keeps its fold
|
|
339
370
|
// and tints the pill warning ink instead of re-springing on every lint) have no trigger today: the
|
|
340
371
|
// editor carries no lint source, so there is nothing to refold around or to tint. Deliberately not
|
|
341
372
|
// built; do not invent a lint system to satisfy a rule with no input.
|
|
342
373
|
|
|
343
374
|
/**
|
|
344
|
-
* The cairn fold extension: the CodeMirror fold system with the pill placeholder, the chevron
|
|
345
|
-
* wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap.
|
|
346
|
-
* persisted: the fold state lives in CodeMirror's foldState field, which
|
|
375
|
+
* The cairn fold extension: the CodeMirror fold system with the pill placeholder, the gutter chevron
|
|
376
|
+
* and folded-row wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap.
|
|
377
|
+
* Session-local and never persisted: the fold state lives in CodeMirror's foldState field, which
|
|
378
|
+
* this never serializes.
|
|
347
379
|
*/
|
|
348
380
|
export function cairnFolding(): Extension {
|
|
349
381
|
return [
|
|
@@ -351,6 +383,7 @@ export function cairnFolding(): Extension {
|
|
|
351
383
|
flashField,
|
|
352
384
|
safetyExtender(),
|
|
353
385
|
foldPlugin(),
|
|
386
|
+
foldGutterColumn(),
|
|
354
387
|
foldKeymap,
|
|
355
388
|
];
|
|
356
389
|
}
|
|
@@ -7,7 +7,6 @@ import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewU
|
|
|
7
7
|
import { RangeSetBuilder } from '@codemirror/state';
|
|
8
8
|
import {
|
|
9
9
|
caretContainerRange,
|
|
10
|
-
containerRanges,
|
|
11
10
|
directiveLineKind,
|
|
12
11
|
fenceScan,
|
|
13
12
|
fenceTokens,
|
|
@@ -63,11 +62,6 @@ const fenceLines = DEPTH_STEPS.map((d) =>
|
|
|
63
62
|
);
|
|
64
63
|
const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
|
|
65
64
|
const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
|
|
66
|
-
// A paired container's opener row, where the fold chevron replaces the container's own innermost
|
|
67
|
-
// rail bar. The class is additive over the fence depth class, so the theme drops only the deepest
|
|
68
|
-
// bar on this row while the outer bars stay; an unbalanced opener never gets it (no chevron, bar
|
|
69
|
-
// intact). Added just after the fence depth line decoration so the row carries both classes.
|
|
70
|
-
const openerLine = Decoration.line({ class: 'cm-cairn-directive-opener' });
|
|
71
65
|
const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
|
|
72
66
|
// Within a fence line, machinery (colons, brackets, braces) dims to the marker tone while the
|
|
73
67
|
// directive name and label keep a depth-stepped ink: meaning over machinery.
|
|
@@ -118,9 +112,6 @@ function buildDirectiveDecorations(view: EditorView, scan: FenceScan): Decoratio
|
|
|
118
112
|
// as its own line decoration just ahead of the row's depth decoration.
|
|
119
113
|
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
|
120
114
|
const caret = caretContainerRange(scan, caretLine);
|
|
121
|
-
// The opener rows that carry a fold chevron, so the rail rule drops their innermost bar. Only
|
|
122
|
-
// paired containers fold, so an unbalanced opener is absent here and keeps its full rail.
|
|
123
|
-
const openerLines = new Set(containerRanges(scan).map((r) => r.fromLine));
|
|
124
115
|
for (const { from, to } of view.visibleRanges) {
|
|
125
116
|
for (let pos = from; pos <= to; ) {
|
|
126
117
|
const line = view.state.doc.lineAt(pos);
|
|
@@ -133,9 +124,6 @@ function buildDirectiveDecorations(view: EditorView, scan: FenceScan): Decoratio
|
|
|
133
124
|
// inside a code block, outside any container); it gets no machinery treatment.
|
|
134
125
|
if (kind === 'fence' && depth > 0) {
|
|
135
126
|
builder.add(line.from, line.from, fenceLines[depth - 1]);
|
|
136
|
-
// A paired opener row gets the opener class just after its fence depth class, so the rail
|
|
137
|
-
// rule below drops the innermost bar where the chevron renders.
|
|
138
|
-
if (openerLines.has(line.number - 1)) builder.add(line.from, line.from, openerLine);
|
|
139
127
|
} else if (kind === 'leaf') {
|
|
140
128
|
builder.add(line.from, line.from, leafLine);
|
|
141
129
|
} else if (kind === null && depth > 0) {
|
|
@@ -100,9 +100,11 @@ export function normalizeConcepts(
|
|
|
100
100
|
const conceptRouting = routing[id] ?? DEFAULT_ROUTING;
|
|
101
101
|
const policy = urlPolicy[id] ?? {};
|
|
102
102
|
validateUrlPolicy(id, policy, conceptRouting.dated);
|
|
103
|
+
const label = config.label ?? defaultLabel(id);
|
|
103
104
|
descriptors.push({
|
|
104
105
|
id,
|
|
105
|
-
label
|
|
106
|
+
label,
|
|
107
|
+
singular: config.singular ?? label,
|
|
106
108
|
dir: config.dir,
|
|
107
109
|
routing: conceptRouting,
|
|
108
110
|
permalink: policy.permalink ?? defaultPermalink(id),
|
package/src/lib/content/types.ts
CHANGED
|
@@ -108,6 +108,8 @@ export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
|
|
|
108
108
|
dir: string;
|
|
109
109
|
/** Sidebar label; defaults from the concept id when omitted. */
|
|
110
110
|
label?: string;
|
|
111
|
+
/** The singular noun for the create affordances ("New post"); defaults to `label` when omitted. */
|
|
112
|
+
singular?: string;
|
|
111
113
|
/** The concept's schema: the form projection, the generated validator, and the inferred type. */
|
|
112
114
|
schema: S;
|
|
113
115
|
/** Frontmatter keys to surface on each `ContentSummary.fields`, so a list card reads an authored
|
|
@@ -241,6 +243,9 @@ export interface ConceptDescriptor {
|
|
|
241
243
|
/** Concept id, the key under `content`, e.g. "posts". */
|
|
242
244
|
id: string;
|
|
243
245
|
label: string;
|
|
246
|
+
/** The singular noun for the create affordances ("New post"); resolved from `ConceptConfig.singular`,
|
|
247
|
+
* defaulting to `label` when the config omits it. */
|
|
248
|
+
singular: string;
|
|
244
249
|
dir: string;
|
|
245
250
|
routing: RoutingRule;
|
|
246
251
|
/** The resolved permalink pattern, defaulted by `normalizeConcepts`. */
|
|
@@ -67,6 +67,9 @@ export interface EntrySummary {
|
|
|
67
67
|
export interface ListData {
|
|
68
68
|
conceptId: string;
|
|
69
69
|
label: string;
|
|
70
|
+
/** The singular noun for the create affordances ("New post"); from the descriptor, which defaults
|
|
71
|
+
* it to `label`. */
|
|
72
|
+
singular: string;
|
|
70
73
|
/** Posts carry a date in the new-entry form; pages do not (concept routing, spec §7.2). */
|
|
71
74
|
dated: boolean;
|
|
72
75
|
entries: EntrySummary[];
|
|
@@ -306,7 +309,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
306
309
|
const formError = event.url.searchParams.get('error');
|
|
307
310
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
308
311
|
const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
|
|
309
|
-
const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError, publishedAll };
|
|
312
|
+
const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
|
|
310
313
|
let token: string;
|
|
311
314
|
try {
|
|
312
315
|
token = await mintToken(event.platform?.env ?? {});
|
|
@@ -25,3 +25,5 @@ export { healthLoad, type HealthData } from './health.js';
|
|
|
25
25
|
export type { RequestContext, CookieJar, HandleInput } from './types.js';
|
|
26
26
|
// Re-exported here, not from root, so the public ContentRoutesDeps consumer can name it.
|
|
27
27
|
export type { GithubKeyEnv } from '../github/credentials.js';
|
|
28
|
+
// Re-exported here, not just from root, so the app.d.ts Platform block can name it.
|
|
29
|
+
export type { AuthEnv } from '../auth/types.js';
|
package/src/lib/vite/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Plugin, PluginOption } from 'vite';
|
|
9
9
|
import { writeFile, mkdir } from 'node:fs/promises';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
+
import { resolveViteRoot } from './resolve-root.js';
|
|
11
12
|
|
|
12
13
|
/** The key the cairnManifest plugin stashes its options under, so the write path can read them off the
|
|
13
14
|
* plugin instance in the consumer's loaded config without re-parsing the config file. */
|
|
@@ -152,10 +153,12 @@ export function cairnManifest(opts: CairnManifestOptions): Plugin {
|
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
/** Regenerate the committed manifest from the consumer's corpus and write it to the configured
|
|
155
|
-
* manifestPath. It
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
156
|
+
* manifestPath. It searches for the consumer's Vite config from `cwd`, derives the authoritative
|
|
157
|
+
* Vite root from the loaded config (so a configured `root` or a non-root cwd resolves correctly),
|
|
158
|
+
* reads the cairnManifest plugin's options off the instance, evaluates the write-mode virtual
|
|
159
|
+
* module through the build's own resolution, and writes the serialized manifest under the Vite
|
|
160
|
+
* root. The cairn-manifest bin calls this; it is exported so the write logic is testable apart
|
|
161
|
+
* from the CLI shell. */
|
|
159
162
|
export async function writeManifest(cwd: string = process.cwd()): Promise<void> {
|
|
160
163
|
const { loadConfigFromFile } = await import('vite');
|
|
161
164
|
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd);
|
|
@@ -168,11 +171,12 @@ export async function writeManifest(cwd: string = process.cwd()): Promise<void>
|
|
|
168
171
|
'cairn-manifest: the Vite config has no cairnManifest() plugin. Add it so the bin shares the build options.',
|
|
169
172
|
);
|
|
170
173
|
}
|
|
171
|
-
const
|
|
174
|
+
const root = resolveViteRoot(loaded, cwd);
|
|
175
|
+
const serialized = await buildManifestFromVite(opts, root);
|
|
172
176
|
const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
|
|
173
177
|
// The manifest path is app-root-absolute (a leading slash relative to the project), so resolve it
|
|
174
|
-
// against
|
|
175
|
-
const outPath = join(
|
|
178
|
+
// against the Vite root, not the filesystem root or the config-search cwd.
|
|
179
|
+
const outPath = join(root, manifestPath.replace(/^\//, ''));
|
|
176
180
|
await mkdir(dirname(outPath), { recursive: true });
|
|
177
181
|
await writeFile(outPath, serialized);
|
|
178
182
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The manifest bin's root derivation, split out so it is unit-testable without widening the public
|
|
2
|
+
// /vite surface (only src/lib/vite/index.ts is the package subpath; this sibling is internal).
|
|
3
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** The shape of `loadConfigFromFile`'s result that the root derivation reads: the config file's own
|
|
6
|
+
* path and its `root` field. Typed structurally so the helper is testable without a real load. */
|
|
7
|
+
export interface LoadedViteConfig {
|
|
8
|
+
/** The resolved path of the config file Vite loaded. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** The user config, of which only `root` is read here. */
|
|
11
|
+
config: { root?: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The authoritative Vite root for the manifest bin, derived from the loaded config the way Vite
|
|
15
|
+
* resolves a relative `root`: against the config file's own directory, not cwd. An absolute `root`
|
|
16
|
+
* stands as given, and no `root` falls back to `cwd` (the directory the bin was run from). This
|
|
17
|
+
* separates the config-search dir (cwd) from the Vite root, so a non-root cwd or a config that
|
|
18
|
+
* sets `root` reads and writes the manifest under the real app root. */
|
|
19
|
+
export function resolveViteRoot(loaded: LoadedViteConfig, cwd: string): string {
|
|
20
|
+
const root = loaded.config.root;
|
|
21
|
+
if (!root) return cwd;
|
|
22
|
+
if (isAbsolute(root)) return root;
|
|
23
|
+
return resolve(dirname(loaded.path), root);
|
|
24
|
+
}
|