@glw907/cairn-cms 0.55.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.
- package/CHANGELOG.md +30 -0
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are recorded here, most recent first.
|
|
4
4
|
|
|
5
|
+
## 0.56.0
|
|
6
|
+
|
|
7
|
+
Two passes ship together: the markdown editor's folding gets a proper home, and the engine's gates,
|
|
8
|
+
tooling, and docs harden.
|
|
9
|
+
|
|
10
|
+
The editor folds directive containers (`:::name` blocks), and the fold control now lives in a real
|
|
11
|
+
gutter column to the left of the text rather than a chevron hidden in the line. At rest the gutter is
|
|
12
|
+
empty; the chevron reveals when you hover the gutter cell, stays while a block is folded, and shows
|
|
13
|
+
while the caret is inside a block. The control is a real button now, so folding is reachable by
|
|
14
|
+
keyboard and screen reader, where before only unfolding was. The folded-row tint and the "N lines"
|
|
15
|
+
pill carry over unchanged, and the fold scope is the same: directive containers only.
|
|
16
|
+
|
|
17
|
+
For consumers, two additive surface touches from the tooling pass. A concept can now set an optional
|
|
18
|
+
`singular` label, so the create affordances read "New post" instead of "New Posts"; it defaults to the
|
|
19
|
+
concept's `label`, so a concept that sets nothing is unchanged. And `AuthEnv` is now exported from
|
|
20
|
+
`@glw907/cairn-cms/sveltekit` as well as the root, so the `app.d.ts` Platform block can import it from
|
|
21
|
+
the subpath the auth helpers live on (the deploy guide now shows that block verbatim).
|
|
22
|
+
|
|
23
|
+
The rest hardens the engine's own gates and docs. A new `check:reference:signatures` gate compares
|
|
24
|
+
each reference page's declared type signature against the export's real type, so a stale signature in
|
|
25
|
+
an existing page is caught (it found and fixed two on its first run). A plain-Node dist-spawn test
|
|
26
|
+
rot-proofs the `/delivery/data` node-safety guarantee, an admin-shell DOM check guards the drawer
|
|
27
|
+
layout against a silent scoping regression, and the `cairn-manifest` bin now resolves the Vite root
|
|
28
|
+
from the loaded config rather than the current directory. A docs sweep documents the preview frame's
|
|
29
|
+
dual stylesheet emission, the `cairnManifest`-derived `cairn-doctor` inputs, the prerender policy for
|
|
30
|
+
the feed routes, and an interim security contact.
|
|
31
|
+
|
|
32
|
+
No consumer action: every change is additive, the `singular` field is optional, and the folding
|
|
33
|
+
redesign is internal to the admin editor.
|
|
34
|
+
|
|
5
35
|
## 0.55.0
|
|
6
36
|
|
|
7
37
|
The office list rises to the gold standard. The post and page list gains a triage filter layer and
|
|
@@ -183,6 +183,10 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
183
183
|
});
|
|
184
184
|
const derivedSlug = $derived(slugEdited ? slug : slugify(title));
|
|
185
185
|
const slugPlaceholder = $derived(data.dated ? 'my-entry' : 'about-us');
|
|
186
|
+
// The create affordances name one new item, so they read in the singular ("New post"). The
|
|
187
|
+
// descriptor resolves `singular` (defaulting it to the label), so the fallback here only guards an
|
|
188
|
+
// older caller that ships no `singular` on its ListData.
|
|
189
|
+
const createNoun = $derived(data.singular ?? data.label);
|
|
186
190
|
|
|
187
191
|
// Shared column-header typography: small uppercase muted labels. The sort buttons add their own
|
|
188
192
|
// flex layout and a hover affordance on top of this.
|
|
@@ -212,7 +216,7 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
212
216
|
<input type="search" aria-label="Search {data.label}" bind:value={query} placeholder="Search {data.label.toLowerCase()}" oninput={() => (page = 1)} />
|
|
213
217
|
</label>
|
|
214
218
|
<button type="button" class="btn btn-primary btn-sm shrink-0" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
215
|
-
<PlusIcon class="h-4 w-4" /> New {
|
|
219
|
+
<PlusIcon class="h-4 w-4" /> New {createNoun}
|
|
216
220
|
</button>
|
|
217
221
|
</div>
|
|
218
222
|
</header>
|
|
@@ -280,7 +284,7 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
280
284
|
<p class="text-sm text-[var(--color-muted)]">Stack your first one and it will show up here.</p>
|
|
281
285
|
</div>
|
|
282
286
|
<button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
283
|
-
<PlusIcon class="h-4 w-4" /> New {
|
|
287
|
+
<PlusIcon class="h-4 w-4" /> New {createNoun}
|
|
284
288
|
</button>
|
|
285
289
|
</div>
|
|
286
290
|
{:else}
|
|
@@ -367,7 +371,7 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
367
371
|
short list always shows its next step rather than just stopping. Same action as the
|
|
368
372
|
header New button. -->
|
|
369
373
|
<button type="button" class="flex w-full items-center gap-2 border-t border-[var(--cairn-card-border)] px-6 py-3 text-sm font-medium text-primary hover:bg-primary/[0.06]" aria-haspopup="dialog" onclick={() => createDialog?.showModal()}>
|
|
370
|
-
<PlusIcon class="h-4 w-4" /> New {
|
|
374
|
+
<PlusIcon class="h-4 w-4" /> New {createNoun}
|
|
371
375
|
</button>
|
|
372
376
|
{/if}
|
|
373
377
|
</div>
|
|
@@ -399,7 +403,7 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
399
403
|
<dialog class="modal" aria-labelledby="cairn-create-dialog-title" bind:this={createDialog}>
|
|
400
404
|
<div class="modal-box">
|
|
401
405
|
<div class="mb-3 flex items-center justify-between">
|
|
402
|
-
<h2 id="cairn-create-dialog-title" class="text-base font-semibold">New {
|
|
406
|
+
<h2 id="cairn-create-dialog-title" class="text-base font-semibold">New {createNoun}</h2>
|
|
403
407
|
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => createDialog?.close()}>✕</button>
|
|
404
408
|
</div>
|
|
405
409
|
<form method="POST" action="?/create" onsubmit={() => (creating = true)} class="flex flex-col gap-3">
|
|
@@ -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
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
213
|
+
color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
|
|
215
214
|
},
|
|
216
|
-
'.cm-cairn-fold-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
278
|
-
//
|
|
279
|
-
|
|
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,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
|
|
4
|
-
* wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap.
|
|
5
|
-
* persisted: the fold state lives in CodeMirror's foldState field, which
|
|
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
|
|
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
|
-
import { Decoration, EditorView, ViewPlugin,
|
|
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
|
-
//
|
|
19
|
-
|
|
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
|
-
|
|
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',
|
|
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 =
|
|
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
|
|
87
|
+
return ranges.find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null;
|
|
138
88
|
}
|
|
139
|
-
function docLines(
|
|
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
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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.
|
|
281
|
-
this.
|
|
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.
|
|
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 ||
|
|
291
|
-
this.decorations = foldDecorations(update.view, this.
|
|
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
|
|
320
|
-
* wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap.
|
|
321
|
-
* persisted: the fold state lives in CodeMirror's foldState field, which
|
|
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,
|
|
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);
|
package/dist/content/concepts.js
CHANGED
|
@@ -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
|
|
82
|
+
label,
|
|
83
|
+
singular: config.singular ?? label,
|
|
82
84
|
dir: config.dir,
|
|
83
85
|
routing: conceptRouting,
|
|
84
86
|
permalink: policy.permalink ?? defaultPermalink(id),
|