@glw907/cairn-cms 0.54.0 → 0.56.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/components/ConceptList.svelte +216 -75
  3. package/dist/components/ConceptList.svelte.d.ts +6 -4
  4. package/dist/components/MarkdownEditor.svelte +64 -45
  5. package/dist/components/cairn-admin.css +50 -0
  6. package/dist/components/editor-folding.d.ts +4 -3
  7. package/dist/components/editor-folding.js +129 -97
  8. package/dist/components/editor-highlight.js +1 -13
  9. package/dist/content/concepts.js +3 -1
  10. package/dist/content/manifest.d.ts +1 -0
  11. package/dist/content/manifest.js +6 -0
  12. package/dist/content/types.d.ts +5 -0
  13. package/dist/delivery/content-index.js +1 -1
  14. package/dist/delivery/data.d.ts +1 -1
  15. package/dist/delivery/data.js +1 -1
  16. package/dist/sveltekit/content-routes.d.ts +6 -0
  17. package/dist/sveltekit/content-routes.js +11 -6
  18. package/dist/sveltekit/index.d.ts +1 -0
  19. package/dist/vite/index.d.ts +6 -4
  20. package/dist/vite/index.js +11 -7
  21. package/dist/vite/resolve-root.d.ts +16 -0
  22. package/dist/vite/resolve-root.js +16 -0
  23. package/package.json +2 -1
  24. package/src/lib/components/ConceptList.svelte +216 -75
  25. package/src/lib/components/MarkdownEditor.svelte +64 -45
  26. package/src/lib/components/editor-folding.ts +137 -104
  27. package/src/lib/components/editor-highlight.ts +0 -12
  28. package/src/lib/content/concepts.ts +3 -1
  29. package/src/lib/content/manifest.ts +7 -0
  30. package/src/lib/content/types.ts +5 -0
  31. package/src/lib/delivery/content-index.ts +1 -1
  32. package/src/lib/delivery/data.ts +1 -1
  33. package/src/lib/sveltekit/content-routes.ts +17 -6
  34. package/src/lib/sveltekit/index.ts +2 -0
  35. package/src/lib/vite/index.ts +11 -7
  36. package/src/lib/vite/resolve-root.ts +24 -0
  37. /package/dist/{delivery → content}/excerpt.d.ts +0 -0
  38. /package/dist/{delivery → content}/excerpt.js +0 -0
  39. /package/src/lib/{delivery → content}/excerpt.ts +0 -0
@@ -3440,6 +3440,10 @@
3440
3440
  }
3441
3441
  }
3442
3442
 
3443
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-0\.5 {
3444
+ margin-top: calc(var(--spacing) * .5);
3445
+ }
3446
+
3443
3447
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .mt-1 {
3444
3448
  margin-top: calc(var(--spacing) * 1);
3445
3449
  }
@@ -3985,6 +3989,10 @@
3985
3989
  min-height: 50vh;
3986
3990
  }
3987
3991
 
3992
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .min-h-\[56vh\] {
3993
+ min-height: 56vh;
3994
+ }
3995
+
3988
3996
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .min-h-full {
3989
3997
  min-height: 100%;
3990
3998
  }
@@ -4051,6 +4059,10 @@
4051
4059
  width: calc(var(--spacing) * 12);
4052
4060
  }
4053
4061
 
4062
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-28 {
4063
+ width: calc(var(--spacing) * 28);
4064
+ }
4065
+
4054
4066
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-44 {
4055
4067
  width: calc(var(--spacing) * 44);
4056
4068
  }
@@ -4083,6 +4095,10 @@
4083
4095
  width: 1px;
4084
4096
  }
4085
4097
 
4098
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-0 {
4099
+ max-width: calc(var(--spacing) * 0);
4100
+ }
4101
+
4086
4102
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-3xl {
4087
4103
  max-width: var(--container-3xl);
4088
4104
  }
@@ -4481,6 +4497,10 @@
4481
4497
  border-color: var(--color-base-300);
4482
4498
  }
4483
4499
 
4500
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-transparent {
4501
+ border-color: #0000;
4502
+ }
4503
+
4484
4504
  @layer daisyui.l1.l2 {
4485
4505
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) :where(:not(ul, details, .menu-title, .btn)).menu-active {
4486
4506
  --tw-outline-style: none;
@@ -4712,6 +4732,10 @@
4712
4732
  padding-block: calc(var(--spacing) * 2);
4713
4733
  }
4714
4734
 
4735
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-3 {
4736
+ padding-block: calc(var(--spacing) * 3);
4737
+ }
4738
+
4715
4739
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-3\.5 {
4716
4740
  padding-block: calc(var(--spacing) * 3.5);
4717
4741
  }
@@ -4886,6 +4910,11 @@
4886
4910
  letter-spacing: -.01em;
4887
4911
  }
4888
4912
 
4913
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-\[0\.02em\] {
4914
+ --tw-tracking: .02em;
4915
+ letter-spacing: .02em;
4916
+ }
4917
+
4889
4918
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tracking-\[0\.08em\] {
4890
4919
  --tw-tracking: .08em;
4891
4920
  letter-spacing: .08em;
@@ -4987,6 +5016,11 @@
4987
5016
  font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, );
4988
5017
  }
4989
5018
 
5019
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .tabular-nums {
5020
+ --tw-numeric-spacing: tabular-nums;
5021
+ font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, );
5022
+ }
5023
+
4990
5024
  @layer daisyui.l1.l2 {
4991
5025
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .link-hover {
4992
5026
  text-decoration-line: none;
@@ -5053,6 +5087,10 @@
5053
5087
  opacity: .7;
5054
5088
  }
5055
5089
 
5090
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .opacity-\[0\.62\] {
5091
+ opacity: .62;
5092
+ }
5093
+
5056
5094
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .shadow {
5057
5095
  --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);
5058
5096
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -5306,6 +5344,18 @@
5306
5344
  }
5307
5345
  }
5308
5346
 
5347
+ @media (hover: hover) {
5348
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-primary\/\[0\.06\]:hover {
5349
+ background-color: var(--color-primary);
5350
+ }
5351
+
5352
+ @supports (color: color-mix(in lab, red, red)) {
5353
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-primary\/\[0\.06\]:hover {
5354
+ background-color: color-mix(in oklab, var(--color-primary) 6%, transparent);
5355
+ }
5356
+ }
5357
+ }
5358
+
5309
5359
  @media (hover: hover) {
5310
5360
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:text-\[var\(--color-primary\)\]:hover {
5311
5361
  color: var(--color-primary);
@@ -1,7 +1,8 @@
1
1
  import { type Extension } from '@codemirror/state';
2
2
  /**
3
- * The cairn fold extension: the CodeMirror fold system with the pill placeholder, the chevron and
4
- * wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap. Session-local and never
5
- * persisted: the fold state lives in CodeMirror's foldState field, which this never serializes.
3
+ * The cairn fold extension: the CodeMirror fold system with the pill placeholder, the gutter chevron
4
+ * and folded-row wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap.
5
+ * Session-local and never persisted: the fold state lives in CodeMirror's foldState field, which
6
+ * this never serializes.
6
7
  */
7
8
  export declare function cairnFolding(): Extension;
@@ -1,29 +1,25 @@
1
- // Container folding: CodeMirror's fold system driven by cairn's directive grammar, with the rail
2
- // band as the affordance. Client-only like editor-highlight and editor-modes; MarkdownEditor
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 (plan decision 4): @codemirror/language's codeFolding plus foldEffect/
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 chevrons are
10
- // widget decorations from this module's own ViewPlugin (the rails are box-shadows, there is no CM
11
- // gutter element to hang a foldGutter on).
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
- import { Decoration, EditorView, ViewPlugin, WidgetType, keymap, } from '@codemirror/view';
15
+ import { Decoration, EditorView, GutterMarker, ViewPlugin, gutter, keymap, } from '@codemirror/view';
15
16
  import { EditorState, Prec, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
16
17
  import { codeFolding, foldEffect, foldedRanges, unfoldEffect } from '@codemirror/language';
17
18
  import { caretContainerRange, containerRanges, fenceScan } from './markdown-directives.js';
18
- // Deeper nesting shares the third visual step, matching the rail stepping in editor-highlight.
19
- const DEPTH_STEPS = 3;
20
- // The chevron sits over the container's own innermost bar: depth 1 at x0, depth 2 at x8, depth 3
21
- // at x16, so indentation telegraphs the nesting and depth 3 never collides with depth 2.
22
- const chevronX = (depth) => (Math.min(depth, DEPTH_STEPS) - 1) * 8;
23
- // The two chevron glyphs from the gold-standard mockup: down (caret inside) and right (folded).
19
+ // One chevron glyph; CSS rotates it (down open, right folded) and reveals it on gutter hover. The
20
+ // gutter is the fixed-x home, so the chevron no longer encodes depth by position.
24
21
  const CHEVRON_DOWN = 'm6 9 6 6 6-6';
25
- const CHEVRON_RIGHT = 'm9 6 6 6-6 6';
26
- function chevronSvg(direction) {
22
+ function chevronSvg() {
27
23
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
28
24
  svg.setAttribute('viewBox', '0 0 24 24');
29
25
  svg.setAttribute('fill', 'none');
@@ -33,55 +29,10 @@ function chevronSvg(direction) {
33
29
  svg.setAttribute('stroke-linejoin', 'round');
34
30
  svg.setAttribute('aria-hidden', 'true');
35
31
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
36
- path.setAttribute('d', direction === 'down' ? CHEVRON_DOWN : CHEVRON_RIGHT);
32
+ path.setAttribute('d', CHEVRON_DOWN);
37
33
  svg.appendChild(path);
38
34
  return svg;
39
35
  }
40
- // The opener-row band: the whole 28px gutter is the click target (cursor pointer over the band
41
- // only, the opener text never folds), and the chevron lives inside it at the container's bar x.
42
- // The widget knows its own container and whether it is folded, so the click toggles the right
43
- // range; the depth class lets the theme step the chevron ink.
44
- class FoldBandWidget extends WidgetType {
45
- range;
46
- folded;
47
- caretInside;
48
- constructor(range, folded, caretInside) {
49
- super();
50
- this.range = range;
51
- this.folded = folded;
52
- this.caretInside = caretInside;
53
- }
54
- eq(other) {
55
- return (other.range.fromLine === this.range.fromLine &&
56
- other.range.toLine === this.range.toLine &&
57
- other.range.depth === this.range.depth &&
58
- other.folded === this.folded &&
59
- other.caretInside === this.caretInside);
60
- }
61
- toDOM(view) {
62
- const band = document.createElement('span');
63
- const depth = Math.min(this.range.depth, DEPTH_STEPS);
64
- // Folded rows always show the chevron (right); an open container shows it down while the caret
65
- // is inside; otherwise it fades in on rail-band hover (the band's own :hover, in the theme).
66
- // The depth class carries the stepped ink.
67
- const state = this.folded ? ' cm-cairn-fold-folded' : this.caretInside ? ' cm-cairn-fold-active' : '';
68
- band.className = `cm-cairn-fold-band cm-cairn-fold-depth-${depth}${state}`;
69
- const chevron = chevronSvg(this.folded ? 'right' : 'down');
70
- chevron.style.left = `${chevronX(this.range.depth)}px`;
71
- band.appendChild(chevron);
72
- band.addEventListener('mousedown', (e) => {
73
- // mousedown, not click: a click would first move the caret into the line. preventDefault
74
- // keeps the caret where it is and stops the band from stealing focus from the editor.
75
- e.preventDefault();
76
- e.stopPropagation();
77
- toggleFold(view, this.range);
78
- });
79
- return band;
80
- }
81
- ignoreEvent() {
82
- return false;
83
- }
84
- }
85
36
  // The pill placeholder: a real focusable button counting the hidden lines, the screen-reader story
86
37
  // for a fold. preparePlaceholder computes the count off the folded char range so placeholderDOM
87
38
  // renders it without re-deriving. Clicking unfolds through CodeMirror's own onclick handler.
@@ -99,8 +50,7 @@ function placeholderDOM(view, onclick, lines) {
99
50
  }
100
51
  // The char range a container folds: end-of-opener-line to end-of-closer-line, so the bare closer
101
52
  // never dangles. Null when the opener and closer share a line (nothing to hide). The one place
102
- // that turns a line range into the fold range, shared by the toggle, the keymap, and the
103
- // decoration build.
53
+ // that turns a line range into the fold range, shared by the toggle, the keymap, and the gutter.
104
54
  function foldCharRange(state, range) {
105
55
  const opener = state.doc.line(range.fromLine + 1);
106
56
  const closer = state.doc.line(range.toLine + 1);
@@ -129,20 +79,44 @@ function foldExists(state, from, to) {
129
79
  // returns the nearest enclosing container, but a container that never closes (an unbalanced
130
80
  // opener) must not fold, so the result is only honored when containerRanges actually pairs it.
131
81
  function caretFoldRange(view) {
132
- const scan = fenceScan(docLines(view));
82
+ const { scan, ranges } = foldScanFor(view.state);
133
83
  const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
134
84
  const inner = caretContainerRange(scan, caretLine);
135
85
  if (!inner)
136
86
  return null;
137
- return (containerRanges(scan).find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null);
87
+ return ranges.find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null;
138
88
  }
139
- function docLines(view) {
140
- const doc = view.state.doc;
89
+ function docLines(state) {
141
90
  const lines = [];
142
- for (let n = 1; n <= doc.lines; n++)
143
- lines.push(doc.line(n).text);
91
+ for (let n = 1; n <= state.doc.lines; n++)
92
+ lines.push(state.doc.line(n).text);
144
93
  return lines;
145
94
  }
95
+ // The scan and its paired ranges, memoized per state so the gutter's per-line lookups stay linear
96
+ // rather than rescanning the whole document on every line.
97
+ const scanCache = new WeakMap();
98
+ function foldScanFor(state) {
99
+ let cached = scanCache.get(state);
100
+ if (!cached) {
101
+ const scan = fenceScan(docLines(state));
102
+ cached = { scan, ranges: containerRanges(scan) };
103
+ scanCache.set(state, cached);
104
+ }
105
+ return cached;
106
+ }
107
+ // The paired container whose opener sits at this line start, or null (a closer, a prose line, or an
108
+ // unbalanced opener gets nothing). The sole source of which rows carry a fold control.
109
+ function openerRangeAt(state, lineFrom) {
110
+ const lineIndex = state.doc.lineAt(lineFrom).number - 1;
111
+ return foldScanFor(state).ranges.find((r) => r.fromLine === lineIndex) ?? null;
112
+ }
113
+ // Whether the caret's innermost container is exactly this one, the caret-inside active state.
114
+ function caretInside(state, range) {
115
+ const { scan } = foldScanFor(state);
116
+ const caretLine = state.doc.lineAt(state.selection.main.head).number - 1;
117
+ const inner = caretContainerRange(scan, caretLine);
118
+ return !!inner && inner.fromLine === range.fromLine && inner.toLine === range.toLine;
119
+ }
146
120
  // The keymap: Ctrl+Shift+[ folds, Ctrl+Shift+] unfolds, the innermost container at the caret. No
147
121
  // fold-all, no chords. (CodeMirror's own foldKeymap binds the same keys to its tree-folding
148
122
  // commands; this module replaces them with the container-aware versions and never adds foldKeymap.)
@@ -242,31 +216,23 @@ function safetyExtender() {
242
216
  return effects.length ? { effects } : null;
243
217
  });
244
218
  }
245
- // The chevron-and-wash plugin: a widget on each opener row of a paired container (the band with
246
- // the chevron) and the folded-row wash line decoration. Rebuilds on doc change (the container set
247
- // moves), selection change (the caret-inside chevron state), viewport change, and any fold change
248
- // (a fold flips a chevron and adds a wash). The hover reveal is the band's own CSS :hover in the
249
- // theme, so it costs no rebuild.
250
- function foldDecorations(view, scan, ranges) {
219
+ // The folded-row wash plugin: a line decoration on each folded opener row, square and full-row so
220
+ // folded spots read in a scan. Rebuilds on doc change (the container set moves), viewport change,
221
+ // and any fold change (a fold adds a wash, an unfold removes it). The chevron lives in the gutter
222
+ // now, not here; this plugin carries only the wash and the unfold flash scheduling.
223
+ function foldDecorations(view, ranges) {
251
224
  const builder = new RangeSetBuilder();
252
- const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
253
- const inner = caretContainerRange(scan, caretLine);
254
- // One opener row may host several enclosing containers' bars, but only its OWN container opens
255
- // there, so a single band per opener row. Sort by opener line so the builder receives ascending
256
- // positions; equal openers cannot happen (one open per line).
225
+ // Sort by opener line so the builder receives ascending positions; equal openers cannot happen
226
+ // (one open per line).
257
227
  const byOpener = [...ranges].sort((a, b) => a.fromLine - b.fromLine);
258
228
  for (const range of byOpener) {
259
229
  const span = foldCharRange(view.state, range);
260
230
  if (!span)
261
231
  continue;
262
232
  const opener = view.state.doc.line(range.fromLine + 1);
263
- const folded = foldExists(view.state, span.from, span.to);
264
- // The folded opener row carries the wash; a line decoration so the rails (box-shadows on the
265
- // same element) run through it unbroken.
266
- if (folded)
233
+ // A line decoration so the rails (box-shadows on the same element) run through it unbroken.
234
+ if (foldExists(view.state, span.from, span.to))
267
235
  builder.add(opener.from, opener.from, washLine);
268
- const caretInside = !!inner && inner.fromLine === range.fromLine && inner.toLine === range.toLine;
269
- builder.add(opener.from, opener.from, Decoration.widget({ widget: new FoldBandWidget(range, folded, caretInside), side: -1 }));
270
236
  }
271
237
  return builder.finish();
272
238
  }
@@ -274,21 +240,18 @@ const washLine = Decoration.line({ class: 'cm-cairn-folded-row' });
274
240
  function foldPlugin() {
275
241
  return ViewPlugin.fromClass(class {
276
242
  decorations;
277
- scan;
278
243
  ranges;
279
244
  constructor(view) {
280
- this.scan = fenceScan(docLines(view));
281
- this.ranges = containerRanges(this.scan);
282
- this.decorations = foldDecorations(view, this.scan, this.ranges);
245
+ this.ranges = foldScanFor(view.state).ranges;
246
+ this.decorations = foldDecorations(view, this.ranges);
283
247
  }
284
248
  update(update) {
285
249
  if (update.docChanged) {
286
- this.scan = fenceScan(docLines(update.view));
287
- this.ranges = containerRanges(this.scan);
250
+ this.ranges = foldScanFor(update.view.state).ranges;
288
251
  }
289
252
  const foldChanged = update.transactions.some((tr) => tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)));
290
- if (update.docChanged || update.viewportChanged || update.selectionSet || foldChanged) {
291
- this.decorations = foldDecorations(update.view, this.scan, this.ranges);
253
+ if (update.docChanged || update.viewportChanged || foldChanged) {
254
+ this.decorations = foldDecorations(update.view, this.ranges);
292
255
  }
293
256
  // Flash the revealed lines on an unfold, then schedule the clear. Folding adds no flash.
294
257
  for (const tr of update.transactions) {
@@ -311,14 +274,82 @@ function foldPlugin() {
311
274
  }
312
275
  }, { decorations: (v) => v.decorations });
313
276
  }
277
+ const FOLD_KEY_HINT = ' (Ctrl+Shift+[)';
278
+ const UNFOLD_KEY_HINT = ' (Ctrl+Shift+])';
279
+ // The gutter control: a real focusable button per paired-opener row, holding one chevron that CSS
280
+ // rotates (down open, right folded) and reveals on gutter hover. mousedown keeps the caret and
281
+ // focus where they are; click toggles, so one handler serves a mouse click and a keyboard
282
+ // activation (Enter/Space) with no double-toggle. The folded and caret-active classes carry the
283
+ // state the theme reads.
284
+ class FoldMarker extends GutterMarker {
285
+ container;
286
+ folded;
287
+ active;
288
+ constructor(container, folded, active) {
289
+ super();
290
+ this.container = container;
291
+ this.folded = folded;
292
+ this.active = active;
293
+ }
294
+ eq(other) {
295
+ return (other instanceof FoldMarker &&
296
+ other.container.fromLine === this.container.fromLine &&
297
+ other.container.toLine === this.container.toLine &&
298
+ other.folded === this.folded &&
299
+ other.active === this.active);
300
+ }
301
+ toDOM(view) {
302
+ const btn = document.createElement('button');
303
+ btn.type = 'button';
304
+ const label = this.folded ? 'Unfold this section' : 'Fold this section';
305
+ btn.className =
306
+ 'cm-cairn-fold-btn' +
307
+ (this.folded ? ' cm-cairn-fold-folded' : '') +
308
+ (this.active ? ' cm-cairn-fold-active' : '');
309
+ btn.setAttribute('aria-label', label);
310
+ btn.title = label + (this.folded ? UNFOLD_KEY_HINT : FOLD_KEY_HINT);
311
+ btn.appendChild(chevronSvg());
312
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
313
+ btn.addEventListener('click', (e) => {
314
+ e.preventDefault();
315
+ toggleFold(view, this.container);
316
+ });
317
+ return btn;
318
+ }
319
+ }
320
+ // The fold gutter: a fixed-x column (width from the theme) carrying one FoldMarker per paired
321
+ // opener. lineMarker returns null for every other row, so the column is empty whitespace down the
322
+ // rest of the surface. lineMarkerChange recomputes the markers on a doc change, a selection change
323
+ // (the caret-inside state follows the cursor), and any fold effect (a fold flips the chevron).
324
+ function foldGutterColumn() {
325
+ return gutter({
326
+ class: 'cm-cairn-fold-gutter',
327
+ lineMarker(view, line) {
328
+ const range = openerRangeAt(view.state, line.from);
329
+ if (!range)
330
+ return null;
331
+ const span = foldCharRange(view.state, range);
332
+ if (!span)
333
+ return null;
334
+ const folded = foldExists(view.state, span.from, span.to);
335
+ return new FoldMarker(range, folded, caretInside(view.state, range));
336
+ },
337
+ lineMarkerChange(update) {
338
+ return (update.docChanged ||
339
+ update.selectionSet ||
340
+ update.transactions.some((tr) => tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect))));
341
+ },
342
+ });
343
+ }
314
344
  // The design notes' diagnostics rules (a deliberately refolded erroring container keeps its fold
315
345
  // and tints the pill warning ink instead of re-springing on every lint) have no trigger today: the
316
346
  // editor carries no lint source, so there is nothing to refold around or to tint. Deliberately not
317
347
  // built; do not invent a lint system to satisfy a rule with no input.
318
348
  /**
319
- * The cairn fold extension: the CodeMirror fold system with the pill placeholder, the chevron and
320
- * wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap. Session-local and never
321
- * persisted: the fold state lives in CodeMirror's foldState field, which this never serializes.
349
+ * The cairn fold extension: the CodeMirror fold system with the pill placeholder, the gutter chevron
350
+ * and folded-row wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap.
351
+ * Session-local and never persisted: the fold state lives in CodeMirror's foldState field, which
352
+ * this never serializes.
322
353
  */
323
354
  export function cairnFolding() {
324
355
  return [
@@ -326,6 +357,7 @@ export function cairnFolding() {
326
357
  flashField,
327
358
  safetyExtender(),
328
359
  foldPlugin(),
360
+ foldGutterColumn(),
329
361
  foldKeymap,
330
362
  ];
331
363
  }
@@ -5,7 +5,7 @@ import { HighlightStyle } from '@codemirror/language';
5
5
  import { tags } from '@lezer/highlight';
6
6
  import { Decoration, ViewPlugin } from '@codemirror/view';
7
7
  import { RangeSetBuilder } from '@codemirror/state';
8
- import { caretContainerRange, containerRanges, directiveLineKind, fenceScan, fenceTokens, findInlineDirectives, markerPrefix, } from './markdown-directives.js';
8
+ import { caretContainerRange, directiveLineKind, fenceScan, fenceTokens, findInlineDirectives, markerPrefix, } from './markdown-directives.js';
9
9
  /** Markdown token colors over the admin theme variables. */
10
10
  export function cairnHighlightStyle() {
11
11
  // Rule order is load-bearing. HighlightStyle emits its CSS in spec order, so on a span that
@@ -48,11 +48,6 @@ const DEPTH_STEPS = [1, 2, 3];
48
48
  const fenceLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-fence cm-cairn-depth-${d}`, attributes: { title: MACHINERY_HINT } }));
49
49
  const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
50
50
  const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
51
- // A paired container's opener row, where the fold chevron replaces the container's own innermost
52
- // rail bar. The class is additive over the fence depth class, so the theme drops only the deepest
53
- // bar on this row while the outer bars stay; an unbalanced opener never gets it (no chevron, bar
54
- // intact). Added just after the fence depth line decoration so the row carries both classes.
55
- const openerLine = Decoration.line({ class: 'cm-cairn-directive-opener' });
56
51
  const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
57
52
  // Within a fence line, machinery (colons, brackets, braces) dims to the marker tone while the
58
53
  // directive name and label keep a depth-stepped ink: meaning over machinery.
@@ -101,9 +96,6 @@ function buildDirectiveDecorations(view, scan) {
101
96
  // as its own line decoration just ahead of the row's depth decoration.
102
97
  const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
103
98
  const caret = caretContainerRange(scan, caretLine);
104
- // The opener rows that carry a fold chevron, so the rail rule drops their innermost bar. Only
105
- // paired containers fold, so an unbalanced opener is absent here and keeps its full rail.
106
- const openerLines = new Set(containerRanges(scan).map((r) => r.fromLine));
107
99
  for (const { from, to } of view.visibleRanges) {
108
100
  for (let pos = from; pos <= to;) {
109
101
  const line = view.state.doc.lineAt(pos);
@@ -116,10 +108,6 @@ function buildDirectiveDecorations(view, scan) {
116
108
  // inside a code block, outside any container); it gets no machinery treatment.
117
109
  if (kind === 'fence' && depth > 0) {
118
110
  builder.add(line.from, line.from, fenceLines[depth - 1]);
119
- // A paired opener row gets the opener class just after its fence depth class, so the rail
120
- // rule below drops the innermost bar where the chevron renders.
121
- if (openerLines.has(line.number - 1))
122
- builder.add(line.from, line.from, openerLine);
123
111
  }
124
112
  else if (kind === 'leaf') {
125
113
  builder.add(line.from, line.from, leafLine);
@@ -76,9 +76,11 @@ export function normalizeConcepts(content, urlPolicy = {}, routing = CONCEPT_ROU
76
76
  const conceptRouting = routing[id] ?? DEFAULT_ROUTING;
77
77
  const policy = urlPolicy[id] ?? {};
78
78
  validateUrlPolicy(id, policy, conceptRouting.dated);
79
+ const label = config.label ?? defaultLabel(id);
79
80
  descriptors.push({
80
81
  id,
81
- label: config.label ?? defaultLabel(id),
82
+ label,
83
+ singular: config.singular ?? label,
82
84
  dir: config.dir,
83
85
  routing: conceptRouting,
84
86
  permalink: policy.permalink ?? defaultPermalink(id),
@@ -7,6 +7,7 @@ export interface ManifestEntry {
7
7
  title: string;
8
8
  date?: string;
9
9
  permalink: string;
10
+ summary?: string;
10
11
  draft: boolean;
11
12
  links: CairnRef[];
12
13
  }
@@ -4,6 +4,7 @@
4
4
  // it; the save path patches one entry and commits it with the content in one commit. Each entry
5
5
  // carries its identity and its outbound cairn: edges, so the manifest is the link graph.
6
6
  import { parseMarkdown } from './frontmatter.js';
7
+ import { deriveExcerpt } from './excerpt.js';
7
8
  import { entryIdentity, asString } from './identity.js';
8
9
  import { extractCairnLinks } from './links.js';
9
10
  /** Build one manifest entry from a content file. Drafts are included and flagged. The id, date, and
@@ -18,6 +19,9 @@ export function manifestEntryFromFile(descriptor, file) {
18
19
  title: asString(frontmatter.title) ?? id,
19
20
  date,
20
21
  permalink,
22
+ // Coalesce an empty excerpt to undefined, so an empty-body entry carries no summary key at all
23
+ // (matching serialize's optional-spread) and the in-memory and serialized shapes agree.
24
+ summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
21
25
  draft: frontmatter.draft === true,
22
26
  links: extractCairnLinks(body),
23
27
  };
@@ -38,6 +42,7 @@ export function serializeManifest(manifest) {
38
42
  title: e.title,
39
43
  ...(e.date ? { date: e.date } : {}),
40
44
  permalink: e.permalink,
45
+ ...(e.summary ? { summary: e.summary } : {}),
41
46
  draft: e.draft,
42
47
  links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
43
48
  }));
@@ -68,6 +73,7 @@ export function parseManifest(raw) {
68
73
  typeof e.permalink === 'string' &&
69
74
  typeof e.draft === 'boolean' &&
70
75
  (e.date === undefined || typeof e.date === 'string') &&
76
+ (e.summary === undefined || typeof e.summary === 'string') &&
71
77
  Array.isArray(e.links);
72
78
  if (!ok) {
73
79
  throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
@@ -92,6 +92,8 @@ export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
92
92
  dir: string;
93
93
  /** Sidebar label; defaults from the concept id when omitted. */
94
94
  label?: string;
95
+ /** The singular noun for the create affordances ("New post"); defaults to `label` when omitted. */
96
+ singular?: string;
95
97
  /** The concept's schema: the form projection, the generated validator, and the inferred type. */
96
98
  schema: S;
97
99
  /** Frontmatter keys to surface on each `ContentSummary.fields`, so a list card reads an authored
@@ -221,6 +223,9 @@ export interface ConceptDescriptor {
221
223
  /** Concept id, the key under `content`, e.g. "posts". */
222
224
  id: string;
223
225
  label: string;
226
+ /** The singular noun for the create affordances ("New post"); resolved from `ConceptConfig.singular`,
227
+ * defaulting to `label` when the config omits it. */
228
+ singular: string;
224
229
  dir: string;
225
230
  routing: RoutingRule;
226
231
  /** The resolved permalink pattern, defaulted by `normalizeConcepts`. */
@@ -4,7 +4,7 @@
4
4
  // every operation reads the descriptor and its routing rule, never a hardcoded concept id.
5
5
  import { parseMarkdown } from '../content/frontmatter.js';
6
6
  import { entryId, entryIdentity, asDate, asString, asTags } from '../content/identity.js';
7
- import { deriveExcerpt, wordCount } from './excerpt.js';
7
+ import { deriveExcerpt, wordCount } from '../content/excerpt.js';
8
8
  /** Map a Vite eager `?raw` glob record (`{ path: raw }`) to `RawFile[]`. */
9
9
  export function fromGlob(record) {
10
10
  return Object.entries(record).map(([path, raw]) => ({ path, raw }));
@@ -5,7 +5,7 @@ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
5
5
  export { createSiteIndexes } from './site-indexes.js';
6
6
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
8
- export { deriveExcerpt, wordCount } from './excerpt.js';
8
+ export { deriveExcerpt, wordCount } from '../content/excerpt.js';
9
9
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
10
10
  export type { FeedChannel, FeedItem } from './feeds.js';
11
11
  export { buildSitemap } from './sitemap.js';
@@ -5,7 +5,7 @@ export { createContentIndex, fromGlob } from './content-index.js';
5
5
  export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
6
6
  export { createSiteIndexes } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
8
- export { deriveExcerpt, wordCount } from './excerpt.js';
8
+ export { deriveExcerpt, wordCount } from '../content/excerpt.js';
9
9
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
10
10
  export { buildSitemap } from './sitemap.js';
11
11
  export { buildRobots } from './robots.js';
@@ -44,11 +44,17 @@ export interface EntrySummary {
44
44
  draft: boolean;
45
45
  /** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
46
46
  status: 'published' | 'edited' | 'new';
47
+ /** The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
48
+ * frontmatter/body excerpt for a pending one, and null when neither yields text. */
49
+ summary: string | null;
47
50
  }
48
51
  /** The concept list view's data. */
49
52
  export interface ListData {
50
53
  conceptId: string;
51
54
  label: string;
55
+ /** The singular noun for the create affordances ("New post"); from the descriptor, which defaults
56
+ * it to `label`. */
57
+ singular: string;
52
58
  /** Posts carry a date in the new-entry form; pages do not (concept routing, spec §7.2). */
53
59
  dated: boolean;
54
60
  entries: EntrySummary[];