@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
@@ -100,23 +100,20 @@ through the adapter's render. Swapping the editor stays a one-file change.
100
100
  `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
101
101
  // With `active`, the row's own (deepest) bar takes the full-strength -active mix at the same
102
102
  // 2px width. The emphasis is strength only: a rail column carrying both an active and a
103
- // quiet segment (two sibling containers at one depth) keeps one weight top to bottom.
104
- // With `dropInnermost`, the row's own deepest bar is omitted: a fold chevron replaces it on a
105
- // paired opener row, so the bar would double the chevron's positional cue. The outer bars and
106
- // their spacers stay, so the nesting still reads.
107
- const rails = (depth: number, active = false, dropInnermost = false): string => {
103
+ // quiet segment (two sibling containers at one depth) keeps one weight top to bottom. A paired
104
+ // opener row paints its full rail like any other fence row; the fold chevron lives in the gutter
105
+ // column left of the rails, so the opener no longer drops its innermost bar.
106
+ const rails = (depth: number, active = false): string => {
108
107
  const layers: string[] = [];
109
108
  for (let d = 1; d <= depth; d++) {
110
109
  const edge = 8 * d - 6;
111
110
  if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
112
- if (dropInnermost && d === depth) continue;
113
111
  const own = active && d === depth;
114
112
  layers.push(
115
113
  `inset ${edge}px 0 0 0 ${own ? railColor('active', '100%') : railColor(d, railFallbacks[d - 1] ?? '92%')}`,
116
114
  );
117
115
  }
118
- // A depth-1 opener drops its only bar, so the row paints no rail at all.
119
- return layers.length ? layers.join(', ') : 'none';
116
+ return layers.join(', ');
120
117
  };
121
118
  const directiveInk = {
122
119
  backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
@@ -132,14 +129,6 @@ through the adapter's render. Swapping the editor stays a one-file change.
132
129
  `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
133
130
  railRules[row('')] = { boxShadow: rails(depth) };
134
131
  railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
135
- // A paired opener row drops its own innermost bar (the fold chevron stands in its place),
136
- // both quiet and caret-active. The extra opener class outranks the base fence rule above.
137
- railRules[`.cm-cairn-directive-fence.cm-cairn-directive-opener.cm-cairn-depth-${depth}`] = {
138
- boxShadow: rails(depth, false, true),
139
- };
140
- railRules[`.cm-cairn-caret-block.cm-cairn-directive-opener.cm-cairn-depth-${depth}`] = {
141
- boxShadow: rails(depth, true, true),
142
- };
143
132
  }
144
133
  const theme = EditorView.theme(
145
134
  {
@@ -199,39 +188,67 @@ through the adapter's render. Swapping the editor stays a one-file change.
199
188
  },
200
189
  '.cm-cairn-directive-leaf': directiveInk,
201
190
  '.cm-cairn-directive-inline': directiveInk,
202
- // Container folding. The fold band is the 28px gutter click target on an opener row; the
203
- // line is the positioning context so the chevron sits over the container's own bar x. The
204
- // band is laid over the gutter (a zero-width inline widget at line start, expanded by the
205
- // absolute children), so only the gutter shows the pointer cursor, never the opener text.
206
- '.cm-line:has(.cm-cairn-fold-band)': { position: 'relative' },
207
- '.cm-cairn-fold-band': {
208
- position: 'absolute',
209
- left: '0',
210
- top: '0',
211
- width: '28px',
212
- height: '100%',
191
+ // Container folding lives in a real gutter column now, not an in-text band. The gutter is a
192
+ // fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
193
+ // the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
194
+ // caret is inside the container. One rotating chevron in the directive ink; the rails carry
195
+ // depth, so the ink does not restep. The lone gutter's wrapper loses its default background
196
+ // and border so the column blends into the quiet surface.
197
+ // Neutralize the gutter wrapper so the column blends in. This assumes the fold gutter is the
198
+ // only gutter (it is today: no lineNumbers or foldGutter in the build); a future line-number
199
+ // or lint gutter would need its own chrome and a narrower selector here.
200
+ '.cm-gutters': { backgroundColor: 'transparent', border: '0', color: 'inherit' },
201
+ // 24px wide so the cell clears the WCAG 2.5.8 target-size floor unconditionally.
202
+ '.cm-cairn-fold-gutter': { width: '24px' },
203
+ '.cm-cairn-fold-gutter .cm-gutterElement': { display: 'flex', alignItems: 'stretch', padding: '0' },
204
+ '.cm-cairn-fold-btn': {
205
+ display: 'flex',
206
+ alignItems: 'center',
207
+ justifyContent: 'center',
208
+ width: '100%',
209
+ padding: '0',
210
+ background: 'transparent',
211
+ border: '0',
213
212
  cursor: 'pointer',
214
- zIndex: '1',
213
+ color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
215
214
  },
216
- '.cm-cairn-fold-band svg': {
217
- position: 'absolute',
218
- top: '50%',
219
- transform: 'translateY(-50%)',
215
+ '.cm-cairn-fold-btn svg': {
220
216
  width: '11px',
221
217
  height: '11px',
222
- // The chevron fades in on rail-band hover; folded and caret-inside states force it on.
218
+ // Empty at rest; the gutter-cell hover, the folded state, and the caret-active state each
219
+ // force it on. A 120ms fade in and out, and a 120ms rotate for the folded turn.
223
220
  opacity: '0',
224
- transition: 'opacity 120ms ease',
225
- color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
221
+ transition: 'opacity 120ms ease, transform 120ms ease',
222
+ },
223
+ // Reveal on gutter-cell hover, on the folded and caret-active states, and on keyboard focus
224
+ // so a focused control shows its glyph, not just the ring.
225
+ '.cm-cairn-fold-gutter .cm-gutterElement:hover .cm-cairn-fold-btn svg, .cm-cairn-fold-btn:focus-visible svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg':
226
+ { opacity: '1' },
227
+ // Folded rotates the single chevron to point right; caret-active takes the stronger ink.
228
+ '.cm-cairn-fold-folded svg': { transform: 'rotate(-90deg)' },
229
+ '.cm-cairn-fold-active': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
230
+ // A visible focus ring for keyboard users landing on the gutter button or the pill, reusing
231
+ // the surface hairline's 70% primary mix (3:1+ non-text contrast on both themes).
232
+ '.cm-cairn-fold-btn:focus-visible': {
233
+ outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
234
+ outlineOffset: '-2px',
235
+ borderRadius: '4px',
236
+ },
237
+ '.cm-cairn-fold-pill:focus-visible': {
238
+ outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
239
+ outlineOffset: '1px',
240
+ },
241
+ // No-hover pointers (touch) cannot reveal on hover, so the rest-state chevron is persistent
242
+ // and legible. Scoped to the rest state (not folded, not caret-active) so those forced-on
243
+ // states still read at full strength on touch rather than this rule clamping them to 0.65.
244
+ '@media (hover: none)': {
245
+ '.cm-cairn-fold-btn:not(.cm-cairn-fold-folded):not(.cm-cairn-fold-active) svg': { opacity: '0.65' },
226
246
  },
227
- '.cm-cairn-fold-band:hover svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg': {
228
- opacity: '1',
247
+ // Respect a reduced-motion preference: drop the chevron fade/rotate and the unfold flash.
248
+ '@media (prefers-reduced-motion: reduce)': {
249
+ '.cm-cairn-fold-btn svg': { transition: 'none' },
250
+ '.cm-cairn-fold-flash': { transition: 'none' },
229
251
  },
230
- // The chevron steps its ink with the container's depth, matching the label inks; the
231
- // caret-inside state takes the strongest ink.
232
- '.cm-cairn-fold-depth-1 svg': { color: 'var(--color-accent)' },
233
- '.cm-cairn-fold-depth-3 svg': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
234
- '.cm-cairn-fold-active svg': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
235
252
  // The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
236
253
  // so folded spots read in a scan. The rails are inset box-shadows on the same line element
237
254
  // and render above this background, so the rail column runs through the wash unbroken.
@@ -274,9 +291,11 @@ through the adapter's render. Swapping the editor stays a one-file change.
274
291
  color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
275
292
  backgroundColor: 'transparent',
276
293
  },
277
- // The fold machinery dims with its row: a folded opener row under focus mode drops its
278
- // chevron, pill, and wash to the dim tone like any other machinery line.
279
- '.cm-cairn-focus-dim .cm-cairn-fold-band svg, .cm-cairn-focus-dim .cm-cairn-fold-pill': {
294
+ // The fold pill dims with its folded opener row like any machinery line (the pill is a
295
+ // widget inside the line). The gutter chevron lives in a separate DOM column that focus-dim
296
+ // cannot reach by descendant selector, and it is already hidden at rest and forced visible
297
+ // only when folded or caret-active, so a folded chevron stays findable without a dim rule.
298
+ '.cm-cairn-focus-dim .cm-cairn-fold-pill': {
280
299
  color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
281
300
  },
282
301
  '.cm-cairn-focus-dim.cm-cairn-folded-row': { backgroundColor: 'transparent' },
@@ -1,21 +1,23 @@
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
15
  import {
15
16
  Decoration,
16
17
  EditorView,
18
+ GutterMarker,
17
19
  ViewPlugin,
18
- WidgetType,
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
- // Deeper nesting shares the third visual step, matching the rail stepping in editor-highlight.
28
- const DEPTH_STEPS = 3;
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(direction: 'down' | 'right'): SVGSVGElement {
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', direction === 'down' ? CHEVRON_DOWN : CHEVRON_RIGHT);
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 = fenceScan(docLines(view));
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(view: EditorView): string[] {
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 chevron-and-wash plugin: a widget on each opener row of a paired container (the band with
262
- // the chevron) and the folded-row wash line decoration. Rebuilds on doc change (the container set
263
- // moves), selection change (the caret-inside chevron state), viewport change, and any fold change
264
- // (a fold flips a chevron and adds a wash). The hover reveal is the band's own CSS :hover in the
265
- // theme, so it costs no rebuild.
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
- const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
269
- const inner = caretContainerRange(scan, caretLine);
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
- const folded = foldExists(view.state, span.from, span.to);
279
- // The folded opener row carries the wash; a line decoration so the rails (box-shadows on the
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.scan = fenceScan(docLines(view));
302
- this.ranges = containerRanges(this.scan);
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.scan = fenceScan(docLines(update.view));
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 || update.selectionSet || foldChanged) {
314
- this.decorations = foldDecorations(update.view, this.scan, this.ranges);
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 and
345
- * wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap. Session-local and never
346
- * persisted: the fold state lives in CodeMirror's foldState field, which this never serializes.
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: config.label ?? defaultLabel(id),
106
+ label,
107
+ singular: config.singular ?? label,
106
108
  dir: config.dir,
107
109
  routing: conceptRouting,
108
110
  permalink: policy.permalink ?? defaultPermalink(id),
@@ -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, type CairnRef, type LinkResolve } from './links.js';
9
10
  import type { ConceptDescriptor } from './types.js';
@@ -15,6 +16,7 @@ export interface ManifestEntry {
15
16
  title: string;
16
17
  date?: string;
17
18
  permalink: string;
19
+ summary?: string;
18
20
  draft: boolean;
19
21
  links: CairnRef[];
20
22
  }
@@ -47,6 +49,9 @@ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { pat
47
49
  title: asString(frontmatter.title) ?? id,
48
50
  date,
49
51
  permalink,
52
+ // Coalesce an empty excerpt to undefined, so an empty-body entry carries no summary key at all
53
+ // (matching serialize's optional-spread) and the in-memory and serialized shapes agree.
54
+ summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
50
55
  draft: frontmatter.draft === true,
51
56
  links: extractCairnLinks(body),
52
57
  };
@@ -70,6 +75,7 @@ export function serializeManifest(manifest: Manifest): string {
70
75
  title: e.title,
71
76
  ...(e.date ? { date: e.date } : {}),
72
77
  permalink: e.permalink,
78
+ ...(e.summary ? { summary: e.summary } : {}),
73
79
  draft: e.draft,
74
80
  links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
75
81
  }));
@@ -102,6 +108,7 @@ export function parseManifest(raw: string): Manifest {
102
108
  typeof e.permalink === 'string' &&
103
109
  typeof e.draft === 'boolean' &&
104
110
  (e.date === undefined || typeof e.date === 'string') &&
111
+ (e.summary === undefined || typeof e.summary === 'string') &&
105
112
  Array.isArray(e.links);
106
113
  if (!ok) {
107
114
  throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
@@ -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`. */
@@ -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
  import type { ConceptDescriptor } from '../content/types.js';
9
9
 
10
10
  /** A raw content file before parsing: the glob key and the file's full markdown text. */
@@ -8,7 +8,7 @@ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
8
8
  export { createSiteIndexes } from './site-indexes.js';
9
9
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
10
10
  export { siteDescriptors } from './site-descriptors.js';
11
- export { deriveExcerpt, wordCount } from './excerpt.js';
11
+ export { deriveExcerpt, wordCount } from '../content/excerpt.js';
12
12
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
13
13
  export type { FeedChannel, FeedItem } from './feeds.js';
14
14
  export { buildSitemap } from './sitemap.js';