@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.
@@ -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`. */
@@ -52,6 +52,9 @@ export interface EntrySummary {
52
52
  export interface ListData {
53
53
  conceptId: string;
54
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;
55
58
  /** Posts carry a date in the new-entry form; pages do not (concept routing, spec §7.2). */
56
59
  dated: boolean;
57
60
  entries: EntrySummary[];
@@ -154,7 +154,7 @@ export function createContentRoutes(runtime, deps = {}) {
154
154
  const formError = event.url.searchParams.get('error');
155
155
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
156
156
  const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
157
- const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError, publishedAll };
157
+ const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
158
158
  let token;
159
159
  try {
160
160
  token = await mintToken(event.platform?.env ?? {});
@@ -10,3 +10,4 @@ export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-a
10
10
  export { healthLoad, type HealthData } from './health.js';
11
11
  export type { RequestContext, CookieJar, HandleInput } from './types.js';
12
12
  export type { GithubKeyEnv } from '../github/credentials.js';
13
+ export type { AuthEnv } from '../auth/types.js';
@@ -25,10 +25,12 @@ export declare function buildManifestFromVite(opts: CairnManifestOptions, root:
25
25
  * buildStart, evaluates it through a nested Vite SSR load so a manifest drift fails the build. */
26
26
  export declare function cairnManifest(opts: CairnManifestOptions): Plugin;
27
27
  /** Regenerate the committed manifest from the consumer's corpus and write it to the configured
28
- * manifestPath. It loads the consumer's Vite config from `cwd`, reads the cairnManifest plugin's
29
- * options off the instance, evaluates the write-mode virtual module through the build's own
30
- * resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
31
- * so the write logic is testable apart from the CLI shell. */
28
+ * manifestPath. It searches for the consumer's Vite config from `cwd`, derives the authoritative
29
+ * Vite root from the loaded config (so a configured `root` or a non-root cwd resolves correctly),
30
+ * reads the cairnManifest plugin's options off the instance, evaluates the write-mode virtual
31
+ * module through the build's own resolution, and writes the serialized manifest under the Vite
32
+ * root. The cairn-manifest bin calls this; it is exported so the write logic is testable apart
33
+ * from the CLI shell. */
32
34
  export declare function writeManifest(cwd?: string): Promise<void>;
33
35
  /** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
34
36
  export interface AdapterFacts {
@@ -1,5 +1,6 @@
1
1
  import { writeFile, mkdir } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
+ import { resolveViteRoot } from './resolve-root.js';
3
4
  /** The key the cairnManifest plugin stashes its options under, so the write path can read them off the
4
5
  * plugin instance in the consumer's loaded config without re-parsing the config file. */
5
6
  const CAIRN_OPTIONS = Symbol.for('cairn-cms.manifest-options');
@@ -121,10 +122,12 @@ export function cairnManifest(opts) {
121
122
  return plugin;
122
123
  }
123
124
  /** Regenerate the committed manifest from the consumer's corpus and write it to the configured
124
- * manifestPath. It loads the consumer's Vite config from `cwd`, reads the cairnManifest plugin's
125
- * options off the instance, evaluates the write-mode virtual module through the build's own
126
- * resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
127
- * so the write logic is testable apart from the CLI shell. */
125
+ * manifestPath. It searches for the consumer's Vite config from `cwd`, derives the authoritative
126
+ * Vite root from the loaded config (so a configured `root` or a non-root cwd resolves correctly),
127
+ * reads the cairnManifest plugin's options off the instance, evaluates the write-mode virtual
128
+ * module through the build's own resolution, and writes the serialized manifest under the Vite
129
+ * root. The cairn-manifest bin calls this; it is exported so the write logic is testable apart
130
+ * from the CLI shell. */
128
131
  export async function writeManifest(cwd = process.cwd()) {
129
132
  const { loadConfigFromFile } = await import('vite');
130
133
  const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd);
@@ -135,11 +138,12 @@ export async function writeManifest(cwd = process.cwd()) {
135
138
  if (!opts) {
136
139
  throw new Error('cairn-manifest: the Vite config has no cairnManifest() plugin. Add it so the bin shares the build options.');
137
140
  }
138
- const serialized = await buildManifestFromVite(opts, cwd);
141
+ const root = resolveViteRoot(loaded, cwd);
142
+ const serialized = await buildManifestFromVite(opts, root);
139
143
  const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
140
144
  // The manifest path is app-root-absolute (a leading slash relative to the project), so resolve it
141
- // against cwd, not the filesystem root.
142
- const outPath = join(cwd, manifestPath.replace(/^\//, ''));
145
+ // against the Vite root, not the filesystem root or the config-search cwd.
146
+ const outPath = join(root, manifestPath.replace(/^\//, ''));
143
147
  await mkdir(dirname(outPath), { recursive: true });
144
148
  await writeFile(outPath, serialized);
145
149
  }
@@ -0,0 +1,16 @@
1
+ /** The shape of `loadConfigFromFile`'s result that the root derivation reads: the config file's own
2
+ * path and its `root` field. Typed structurally so the helper is testable without a real load. */
3
+ export interface LoadedViteConfig {
4
+ /** The resolved path of the config file Vite loaded. */
5
+ path: string;
6
+ /** The user config, of which only `root` is read here. */
7
+ config: {
8
+ root?: string;
9
+ };
10
+ }
11
+ /** The authoritative Vite root for the manifest bin, derived from the loaded config the way Vite
12
+ * resolves a relative `root`: against the config file's own directory, not cwd. An absolute `root`
13
+ * stands as given, and no `root` falls back to `cwd` (the directory the bin was run from). This
14
+ * separates the config-search dir (cwd) from the Vite root, so a non-root cwd or a config that
15
+ * sets `root` reads and writes the manifest under the real app root. */
16
+ export declare function resolveViteRoot(loaded: LoadedViteConfig, cwd: string): string;
@@ -0,0 +1,16 @@
1
+ // The manifest bin's root derivation, split out so it is unit-testable without widening the public
2
+ // /vite surface (only src/lib/vite/index.ts is the package subpath; this sibling is internal).
3
+ import { dirname, isAbsolute, resolve } from 'node:path';
4
+ /** The authoritative Vite root for the manifest bin, derived from the loaded config the way Vite
5
+ * resolves a relative `root`: against the config file's own directory, not cwd. An absolute `root`
6
+ * stands as given, and no `root` falls back to `cwd` (the directory the bin was run from). This
7
+ * separates the config-search dir (cwd) from the Vite root, so a non-root cwd or a config that
8
+ * sets `root` reads and writes the manifest under the real app root. */
9
+ export function resolveViteRoot(loaded, cwd) {
10
+ const root = loaded.config.root;
11
+ if (!root)
12
+ return cwd;
13
+ if (isAbsolute(root))
14
+ return root;
15
+ return resolve(dirname(loaded.path), root);
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -29,6 +29,7 @@
29
29
  "package": "svelte-package && node scripts/build-admin-css.mjs && chmod +x dist/vite/bin.js dist/doctor/bin.js",
30
30
  "check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
31
31
  "check:reference": "npm run package && node scripts/reference-coverage.mjs",
32
+ "check:reference:signatures": "npm run package && node scripts/check-reference-signatures.mjs",
32
33
  "check:readiness": "npm run package && node scripts/check-readiness.mjs",
33
34
  "check:docs": "node scripts/docs-links.mjs",
34
35
  "check:prose": "node scripts/check-admin-prose.mjs",
@@ -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 {data.label}
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 {data.label}
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 {data.label}
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 {data.label}</h2>
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
- // 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' },