@glw907/cairn-cms 0.11.0 → 0.17.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 (124) hide show
  1. package/dist/auth/crypto.d.ts +8 -2
  2. package/dist/auth/crypto.d.ts.map +1 -1
  3. package/dist/auth/crypto.js +12 -2
  4. package/dist/auth/store.d.ts +2 -0
  5. package/dist/auth/store.d.ts.map +1 -1
  6. package/dist/auth/store.js +17 -5
  7. package/dist/components/ComponentForm.svelte +33 -10
  8. package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
  9. package/dist/components/EditPage.svelte +4 -6
  10. package/dist/components/EditPage.svelte.d.ts +1 -1
  11. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  12. package/dist/components/IconPicker.svelte +53 -7
  13. package/dist/components/IconPicker.svelte.d.ts +7 -3
  14. package/dist/components/IconPicker.svelte.d.ts.map +1 -1
  15. package/dist/content/adapter.d.ts +4 -0
  16. package/dist/content/adapter.d.ts.map +1 -0
  17. package/dist/content/adapter.js +4 -0
  18. package/dist/content/concepts.js +2 -2
  19. package/dist/content/schema.d.ts +75 -0
  20. package/dist/content/schema.d.ts.map +1 -0
  21. package/dist/content/schema.js +72 -0
  22. package/dist/content/types.d.ts +30 -7
  23. package/dist/content/types.d.ts.map +1 -1
  24. package/dist/content/validate.d.ts +5 -3
  25. package/dist/content/validate.d.ts.map +1 -1
  26. package/dist/content/validate.js +14 -7
  27. package/dist/delivery/content-index.d.ts +8 -0
  28. package/dist/delivery/content-index.d.ts.map +1 -1
  29. package/dist/delivery/content-index.js +23 -12
  30. package/dist/delivery/feeds.d.ts +1 -1
  31. package/dist/delivery/feeds.d.ts.map +1 -1
  32. package/dist/delivery/feeds.js +31 -16
  33. package/dist/delivery/index.d.ts +5 -1
  34. package/dist/delivery/index.d.ts.map +1 -1
  35. package/dist/delivery/index.js +2 -0
  36. package/dist/delivery/seo-fields.d.ts +22 -0
  37. package/dist/delivery/seo-fields.d.ts.map +1 -0
  38. package/dist/delivery/seo-fields.js +32 -0
  39. package/dist/delivery/site-index.d.ts +2 -2
  40. package/dist/delivery/site-index.d.ts.map +1 -1
  41. package/dist/delivery/site-index.js +16 -18
  42. package/dist/delivery/site-indexes.d.ts +26 -0
  43. package/dist/delivery/site-indexes.d.ts.map +1 -0
  44. package/dist/delivery/site-indexes.js +30 -0
  45. package/dist/env.d.ts.map +1 -1
  46. package/dist/env.js +14 -0
  47. package/dist/github/signing.d.ts +12 -0
  48. package/dist/github/signing.d.ts.map +1 -1
  49. package/dist/github/signing.js +22 -0
  50. package/dist/index.d.ts +9 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +5 -2
  53. package/dist/render/component-grammar.d.ts +7 -0
  54. package/dist/render/component-grammar.d.ts.map +1 -1
  55. package/dist/render/component-grammar.js +27 -8
  56. package/dist/render/component-validate.js +3 -3
  57. package/dist/render/glyph.d.ts +4 -1
  58. package/dist/render/glyph.d.ts.map +1 -1
  59. package/dist/render/glyph.js +6 -2
  60. package/dist/render/pipeline.d.ts +10 -0
  61. package/dist/render/pipeline.d.ts.map +1 -1
  62. package/dist/render/pipeline.js +15 -1
  63. package/dist/render/registry.d.ts +23 -5
  64. package/dist/render/registry.d.ts.map +1 -1
  65. package/dist/render/registry.js +6 -0
  66. package/dist/render/rehype-dispatch.d.ts +1 -5
  67. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  68. package/dist/render/rehype-dispatch.js +71 -19
  69. package/dist/render/remark-directives.d.ts +1 -1
  70. package/dist/render/remark-directives.d.ts.map +1 -1
  71. package/dist/render/remark-directives.js +37 -0
  72. package/dist/render/sanitize-schema.d.ts +20 -0
  73. package/dist/render/sanitize-schema.d.ts.map +1 -0
  74. package/dist/render/sanitize-schema.js +48 -0
  75. package/dist/sveltekit/auth-routes.d.ts.map +1 -1
  76. package/dist/sveltekit/auth-routes.js +29 -11
  77. package/dist/sveltekit/content-routes.js +2 -2
  78. package/dist/sveltekit/guard.d.ts +1 -1
  79. package/dist/sveltekit/guard.d.ts.map +1 -1
  80. package/dist/sveltekit/guard.js +25 -10
  81. package/dist/sveltekit/nav-routes.js +2 -2
  82. package/dist/sveltekit/public-routes.d.ts +3 -0
  83. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  84. package/dist/sveltekit/public-routes.js +10 -3
  85. package/dist/sveltekit/types.d.ts +6 -0
  86. package/dist/sveltekit/types.d.ts.map +1 -1
  87. package/package.json +3 -2
  88. package/src/lib/auth/crypto.ts +14 -2
  89. package/src/lib/auth/store.ts +18 -5
  90. package/src/lib/components/ComponentForm.svelte +33 -10
  91. package/src/lib/components/EditPage.svelte +4 -6
  92. package/src/lib/components/IconPicker.svelte +53 -7
  93. package/src/lib/content/adapter.ts +10 -0
  94. package/src/lib/content/concepts.ts +2 -2
  95. package/src/lib/content/schema.ts +133 -0
  96. package/src/lib/content/types.ts +30 -7
  97. package/src/lib/content/validate.ts +10 -7
  98. package/src/lib/delivery/content-index.ts +32 -12
  99. package/src/lib/delivery/feeds.ts +34 -19
  100. package/src/lib/delivery/index.ts +5 -1
  101. package/src/lib/delivery/seo-fields.ts +43 -0
  102. package/src/lib/delivery/site-index.ts +15 -16
  103. package/src/lib/delivery/site-indexes.ts +64 -0
  104. package/src/lib/env.ts +13 -0
  105. package/src/lib/github/signing.ts +32 -0
  106. package/src/lib/index.ts +8 -2
  107. package/src/lib/render/component-grammar.ts +34 -10
  108. package/src/lib/render/component-validate.ts +3 -3
  109. package/src/lib/render/glyph.ts +6 -2
  110. package/src/lib/render/pipeline.ts +25 -1
  111. package/src/lib/render/registry.ts +27 -5
  112. package/src/lib/render/rehype-dispatch.ts +67 -20
  113. package/src/lib/render/remark-directives.ts +39 -1
  114. package/src/lib/render/sanitize-schema.ts +57 -0
  115. package/src/lib/sveltekit/auth-routes.ts +30 -11
  116. package/src/lib/sveltekit/content-routes.ts +2 -2
  117. package/src/lib/sveltekit/guard.ts +25 -10
  118. package/src/lib/sveltekit/nav-routes.ts +2 -2
  119. package/src/lib/sveltekit/public-routes.ts +13 -3
  120. package/src/lib/sveltekit/types.ts +5 -1
  121. package/dist/render/sanitize.d.ts +0 -8
  122. package/dist/render/sanitize.d.ts.map +0 -1
  123. package/dist/render/sanitize.js +0 -26
  124. package/src/lib/render/sanitize.ts +0 -27
@@ -12,14 +12,13 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
12
12
  import type { IconSet } from '../render/glyph.js';
13
13
  import type { EditData } from '../sveltekit/content-routes.js';
14
14
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
15
- import { sanitizePreviewHtml } from '../render/sanitize.js';
16
15
 
17
16
  interface Props {
18
17
  /** The edit load's data, plus the site name for the heading. */
19
18
  data: EditData & { siteName: string };
20
19
  /** The site's component registry, for the insert palette. */
21
20
  registry?: ComponentRegistry;
22
- /** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
21
+ /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
23
22
  render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
24
23
  /** The site's icon set, for the guided form's icon fields. */
25
24
  icons?: IconSet;
@@ -46,8 +45,8 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
46
45
  localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
47
46
  }
48
47
 
49
- // Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
50
- // The sanitize is the one barrier between editor-authored markdown and the page (the editor is unsanitized).
48
+ // Render the design-accurate preview as the body changes, debounced. The site's render is the
49
+ // floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
51
50
  // previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
52
51
  // async render call resolves after a newer one has started, the stale result is discarded.
53
52
  let previewRun = 0;
@@ -58,8 +57,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
58
57
  const handle = setTimeout(async () => {
59
58
  try {
60
59
  const html = await render(md);
61
- const safe = await sanitizePreviewHtml(html);
62
- if (run === previewRun) previewHtml = safe;
60
+ if (run === previewRun) previewHtml = html;
63
61
  } catch {
64
62
  if (run === previewRun) previewHtml = '';
65
63
  }
@@ -1,10 +1,13 @@
1
1
  <!--
2
2
  @component
3
- A visual icon choice over the site's IconSet. Each glyph is a toggle button; the selected one carries
4
- aria-pressed. When the field is optional, a None button clears the value. The glyph renders inline from
5
- the IconSet path data, matching the renderer's 256-unit viewBox.
3
+ A visual icon choice over the site's IconSet. The choices form a radiogroup; each glyph is a radio
4
+ button carrying aria-checked, and the selected one carries btn-primary for the visible state. When the
5
+ field is optional, a None radio clears the value. A roving tabindex keeps a single tab stop and arrow
6
+ keys move the selection, the standard radiogroup keyboard model. The glyph renders inline from the
7
+ IconSet path data, matching the renderer's 256-unit viewBox.
6
8
  -->
7
9
  <script lang="ts">
10
+ import { tick } from 'svelte';
8
11
  import type { IconSet } from '../render/glyph.js';
9
12
 
10
13
  interface Props {
@@ -16,20 +19,60 @@ the IconSet path data, matching the renderer's 256-unit viewBox.
16
19
  required: boolean;
17
20
  /** Called with the new glyph name (or '' for none). */
18
21
  onChange: (name: string) => void;
22
+ /** The group's accessible name, threaded from the field label. Defaults to Icon. */
23
+ label?: string;
19
24
  }
20
25
 
21
- let { icons, value, required, onChange }: Props = $props();
26
+ let { icons, value, required, onChange, label = 'Icon' }: Props = $props();
27
+
28
+ // The radiogroup container, used to move focus with the selection per the ARIA radiogroup pattern.
29
+ let group: HTMLDivElement;
22
30
 
23
31
  const names = $derived(Object.keys(icons));
32
+ // The selectable keys in DOM order: the optional None choice ('') first, then each glyph name.
33
+ // Arrow-key navigation walks this list, and the roving tabindex marks the selected key (or the
34
+ // first key when nothing is selected) as the single tab stop.
35
+ const choices = $derived(required ? names : ['', ...names]);
36
+ const tabStop = $derived(choices.includes(value) ? value : choices[0]);
37
+
38
+ function move(delta: number): void {
39
+ // Navigate relative to the focused element (the current tab stop), not the bound value. In a
40
+ // required group with no value, tabStop is the first radio while value is '', so a value-based
41
+ // origin would skip the first step.
42
+ const from = Math.max(0, choices.indexOf(tabStop));
43
+ const next = (from + delta + choices.length) % choices.length;
44
+ onChange(choices[next]);
45
+ // The roving tabindex updates reactively, so wait for the DOM then move focus onto the new tab
46
+ // stop. The keydown handler runs only when focus is already inside the group, so this never
47
+ // steals focus on mount.
48
+ void tick().then(() => {
49
+ const target = group?.querySelector<HTMLElement>('[tabindex="0"]');
50
+ target?.focus();
51
+ });
52
+ }
53
+
54
+ function onKeydown(e: KeyboardEvent): void {
55
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
56
+ e.preventDefault();
57
+ move(1);
58
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
59
+ e.preventDefault();
60
+ move(-1);
61
+ }
62
+ }
24
63
  </script>
25
64
 
26
- <div class="flex flex-wrap gap-2" role="group" aria-label="Icon">
65
+ <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} bind:this={group}>
27
66
  {#if !required}
28
67
  <button
29
68
  type="button"
30
69
  class="btn btn-sm"
31
70
  class:btn-primary={value === ''}
32
- aria-pressed={value === ''}
71
+ role="radio"
72
+ aria-checked={value === ''}
73
+ aria-label="None"
74
+ tabindex={tabStop === '' ? 0 : -1}
75
+ onkeydown={onKeydown}
33
76
  onclick={() => onChange('')}
34
77
  >None</button>
35
78
  {/if}
@@ -38,8 +81,11 @@ the IconSet path data, matching the renderer's 256-unit viewBox.
38
81
  type="button"
39
82
  class="btn btn-sm gap-1"
40
83
  class:btn-primary={value === name}
41
- aria-pressed={value === name}
84
+ role="radio"
85
+ aria-checked={value === name}
42
86
  aria-label={name}
87
+ tabindex={tabStop === name ? 0 : -1}
88
+ onkeydown={onKeydown}
43
89
  onclick={() => onChange(name)}
44
90
  >
45
91
  <svg class="ec-glyph" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" width="16" height="16">
@@ -0,0 +1,10 @@
1
+ // cairn-cms: the adapter-authoring helper. A plain `const adapter: CairnAdapter = {...}` annotation
2
+ // widens each concept's schema type away and breaks typed reads. defineAdapter captures the adapter
3
+ // through a `const` type parameter, so each concept's concrete ConceptSchema<F> survives for the
4
+ // full-auto typed reads in createSiteIndexes, while still checking the adapter against the contract.
5
+ import type { CairnAdapter } from './types.js';
6
+
7
+ /** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
8
+ export function defineAdapter<const A extends CairnAdapter>(adapter: A): A {
9
+ return adapter;
10
+ }
@@ -51,8 +51,8 @@ export function normalizeConcepts(
51
51
  routing: routing[id] ?? DEFAULT_ROUTING,
52
52
  permalink: policy.permalink ?? defaultPermalink(id),
53
53
  datePrefix: policy.datePrefix ?? 'day',
54
- fields: config.fields,
55
- validate: config.validate,
54
+ fields: config.schema.fields,
55
+ validate: config.schema.validate,
56
56
  });
57
57
  }
58
58
  return descriptors;
@@ -0,0 +1,133 @@
1
+ // cairn-cms: the concept schema primitive (schema-source-of-truth design). One field
2
+ // declaration yields a plain-data field projection for the editor form, a generated validator,
3
+ // and an inferred frontmatter type. Plan 1 builds the additive primitive; the adapter-contract
4
+ // cutover and the typed reads are Plan 2.
5
+ import type { FrontmatterField, ValidationResult } from './types.js';
6
+ import { validateFields } from './validate.js';
7
+
8
+ /** The validate input the cairn adapter takes: the raw frontmatter and the body. */
9
+ export interface StandardInput {
10
+ frontmatter: Record<string, unknown>;
11
+ body: string;
12
+ }
13
+
14
+ /** A minimal local copy of the Standard Schema v1 interface (https://standardschema.dev), so the
15
+ * schema is a drop-in where the ecosystem accepts a validator, with no runtime dependency. */
16
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
17
+ readonly '~standard': {
18
+ readonly version: 1;
19
+ readonly vendor: string;
20
+ readonly validate: (value: unknown) => StandardResult<Output>;
21
+ readonly types?: { readonly input: Input; readonly output: Output };
22
+ };
23
+ }
24
+ type StandardResult<Output> =
25
+ | { readonly value: Output; readonly issues?: undefined }
26
+ | { readonly issues: ReadonlyArray<{ readonly message: string; readonly path?: ReadonlyArray<PropertyKey> }> };
27
+
28
+ /** Map one field descriptor to the TS type of its normalized value. text, textarea, and date
29
+ * normalize to a string; a closed-vocabulary `tags` field to the option-union array. */
30
+ type FieldValue<K extends FrontmatterField> = K extends { type: 'boolean' }
31
+ ? boolean
32
+ : K extends { type: 'tags'; options: readonly (infer O extends string)[] }
33
+ ? O[]
34
+ : K extends { type: 'tags' | 'freetags' }
35
+ ? string[]
36
+ : string;
37
+
38
+ /** Flatten an intersection into a single readable object type. */
39
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
40
+
41
+ /** The normalized frontmatter type inferred from a field tuple. A field declared
42
+ * `required: true` is a required key; every other field is optional. */
43
+ export type InferFields<F extends readonly FrontmatterField[]> = Prettify<
44
+ { [K in F[number] as K extends { required: true } ? K['name'] : never]: FieldValue<K> } & {
45
+ [K in F[number] as K extends { required: true } ? never : K['name']]?: FieldValue<K>;
46
+ }
47
+ >;
48
+
49
+ /** A concept's schema: the plain-data field projection, the generated validator, and the
50
+ * Standard Schema conformance property. */
51
+ export interface ConceptSchema<F extends readonly FrontmatterField[] = readonly FrontmatterField[]> {
52
+ /** The declared fields as plain serializable data, for the editor form. */
53
+ readonly fields: FrontmatterField[];
54
+ /** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
55
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
56
+ /** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
57
+ readonly '~standard': StandardSchemaV1<StandardInput, InferFields<F>>['~standard'];
58
+ }
59
+
60
+ /** Extract the inferred frontmatter type from a `ConceptSchema`. */
61
+ export type Infer<S> = S extends ConceptSchema<infer F> ? InferFields<F> : never;
62
+
63
+ // Enforce the declarative per-field rules on an already-coerced value. Rules run only on a
64
+ // present, non-empty string value, so an absent optional field is never flagged. The first
65
+ // failing rule per field wins, so the author sees one clear message at a time.
66
+ function applyRules(field: FrontmatterField, value: unknown, errors: Record<string, string>, patterns: Map<string, RegExp>): void {
67
+ if (typeof value !== 'string' || value === '') return;
68
+ if (field.type === 'text' || field.type === 'textarea') {
69
+ if (field.min != null && value.length < field.min) errors[field.name] = `${field.label} must be at least ${field.min} characters`;
70
+ else if (field.max != null && value.length > field.max) errors[field.name] = `${field.label} must be at most ${field.max} characters`;
71
+ else if (field.length != null && value.length !== field.length) errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
72
+ else if (field.pattern != null) {
73
+ const re = patterns.get(field.name);
74
+ if (re && !re.test(value)) errors[field.name] = `${field.label} is not in the expected format`;
75
+ }
76
+ } else if (field.type === 'date') {
77
+ if (field.min != null && value < field.min) errors[field.name] = `${field.label} must be on or after ${field.min}`;
78
+ else if (field.max != null && value > field.max) errors[field.name] = `${field.label} must be on or before ${field.max}`;
79
+ }
80
+ }
81
+
82
+ /** Options for `defineFields`. `refine` runs after the per-field rules pass, for cross-field and
83
+ * body-dependent checks. It is validation-only: it returns field-keyed errors to merge, or
84
+ * nothing, and never transforms the data. */
85
+ export interface DefineFieldsOptions<F extends readonly FrontmatterField[]> {
86
+ refine?: (data: InferFields<F>, body: string) => Record<string, string> | undefined;
87
+ }
88
+
89
+ // Compile each declared text/textarea pattern once, so a malformed pattern fails loudly at
90
+ // declaration (a site config error) instead of throwing from inside validate() on every save.
91
+ function compilePatterns(fields: FrontmatterField[]): Map<string, RegExp> {
92
+ const compiled = new Map<string, RegExp>();
93
+ for (const field of fields) {
94
+ if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
95
+ try {
96
+ compiled.set(field.name, new RegExp(field.pattern));
97
+ } catch (cause) {
98
+ throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
99
+ }
100
+ }
101
+ }
102
+ return compiled;
103
+ }
104
+
105
+ /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
106
+ export function defineFields<const F extends readonly FrontmatterField[]>(
107
+ fields: F,
108
+ options: DefineFieldsOptions<F> = {},
109
+ ): ConceptSchema<F> {
110
+ const list = [...fields] as FrontmatterField[];
111
+ const patterns = compilePatterns(list);
112
+ const validate = (frontmatter: Record<string, unknown>, body: string): ValidationResult => {
113
+ const base = validateFields(list, frontmatter);
114
+ if (!base.ok) return base;
115
+ const errors: Record<string, string> = {};
116
+ for (const field of list) applyRules(field, base.data[field.name], errors, patterns);
117
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
118
+ const refined = options.refine?.(base.data as InferFields<F>, body);
119
+ return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : base;
120
+ };
121
+ const standard: StandardSchemaV1<StandardInput, InferFields<F>>['~standard'] = {
122
+ version: 1,
123
+ vendor: 'cairn',
124
+ validate: (value) => {
125
+ const { frontmatter = {}, body = '' } = (value ?? {}) as Partial<StandardInput>;
126
+ const result = validate(frontmatter ?? {}, body ?? '');
127
+ return result.ok
128
+ ? { value: result.data as InferFields<F> }
129
+ : { issues: Object.entries(result.errors).map(([field, message]) => ({ message, path: [field] })) };
130
+ },
131
+ };
132
+ return { fields: list, validate, '~standard': standard };
133
+ }
@@ -10,6 +10,7 @@
10
10
  import type { ComponentRegistry } from '../render/registry.js';
11
11
  import type { IconSet } from '../render/glyph.js';
12
12
  import type { DatePrefix } from './ids.js';
13
+ import type { ConceptSchema } from './schema.js';
13
14
 
14
15
  /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
15
16
  interface FieldBase {
@@ -24,16 +25,37 @@ interface FieldBase {
24
25
  /** A single-line text input. */
25
26
  export interface TextField extends FieldBase {
26
27
  type: 'text';
28
+ /** Minimum character length of a non-empty value. */
29
+ min?: number;
30
+ /** Maximum character length. */
31
+ max?: number;
32
+ /** Exact required character length. */
33
+ length?: number;
34
+ /** A regular-expression source string the value must match. Stored as a string so the field
35
+ * list stays plain serializable data; the validator compiles it. */
36
+ pattern?: string;
27
37
  }
28
38
  /** A multi-line text input. */
29
39
  export interface TextareaField extends FieldBase {
30
40
  type: 'textarea';
31
41
  /** Visible rows; the editor picks a default when omitted. */
32
42
  rows?: number;
43
+ /** Minimum character length of a non-empty value. */
44
+ min?: number;
45
+ /** Maximum character length. */
46
+ max?: number;
47
+ /** Exact required character length. */
48
+ length?: number;
49
+ /** A regular-expression source string the value must match. */
50
+ pattern?: string;
33
51
  }
34
52
  /** A `YYYY-MM-DD` date input. */
35
53
  export interface DateField extends FieldBase {
36
54
  type: 'date';
55
+ /** Earliest allowed date, as `YYYY-MM-DD`. */
56
+ min?: string;
57
+ /** Latest allowed date, as `YYYY-MM-DD`. */
58
+ max?: string;
37
59
  }
38
60
  /** A checkbox; absent means false. */
39
61
  export interface BooleanField extends FieldBase {
@@ -74,18 +96,19 @@ export type ValidationResult =
74
96
  | { ok: false; errors: Record<string, string> };
75
97
 
76
98
  /**
77
- * Per-site configuration for one content concept (spec §8). Concept-fixed behavior such as
78
- * routability is not here; it lives in the engine's routing table (`CONCEPT_ROUTING`).
99
+ * Per-site configuration for one content concept (spec §8). One `schema`, built with
100
+ * `defineFields`, is the single source of truth for the editor form, the validator, and the
101
+ * inferred frontmatter type. Generic over the schema so a concept's concrete type survives for
102
+ * typed reads. Concept-fixed behavior such as routability is not here; it lives in the engine's
103
+ * routing table (`CONCEPT_ROUTING`).
79
104
  */
80
- export interface ConceptConfig {
105
+ export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
81
106
  /** Repo-relative content directory, e.g. "src/content/posts". */
82
107
  dir: string;
83
108
  /** Sidebar label; defaults from the concept id when omitted. */
84
109
  label?: string;
85
- /** Drives the per-concept frontmatter form, in order. */
86
- fields: FrontmatterField[];
87
- /** Validate submitted frontmatter before any commit. */
88
- validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
110
+ /** The concept's schema: the form projection, the generated validator, and the inferred type. */
111
+ schema: S;
89
112
  }
90
113
 
91
114
  /**
@@ -7,9 +7,11 @@ import { dateInputValue } from './frontmatter.js';
7
7
 
8
8
  /**
9
9
  * Validate raw frontmatter against a field list. Required text and date fields must be
10
- * non-empty; required tag fields must be non-empty lists. Booleans coerce to `true`/`false`
11
- * and tag fields to string arrays. Returns the normalized data, or field-keyed errors when
12
- * any required field is empty.
10
+ * non-empty; required tag fields must be non-empty lists. A present boolean coerces to `true`
11
+ * and an unchecked one is omitted; a present tag field coerces to a string array and an empty
12
+ * one is omitted; an empty optional text or date field is omitted, so the normalized data
13
+ * carries only meaningful values and committed frontmatter stays minimal. Returns the
14
+ * normalized data, or field-keyed errors when any required field is empty.
13
15
  *
14
16
  * Frontmatter may arrive from the edit form (all string values) or from `parseMarkdown`,
15
17
  * where gray-matter turns an unquoted YAML date into a JS `Date`. The `date` case coerces a
@@ -25,25 +27,26 @@ export function validateFields(
25
27
  const value = frontmatter[field.name];
26
28
  switch (field.type) {
27
29
  case 'boolean':
28
- data[field.name] = value === true;
30
+ // Absent or unchecked means false; omit it so a published file carries no draft: false noise.
31
+ if (value === true) data[field.name] = true;
29
32
  break;
30
33
  case 'tags':
31
34
  case 'freetags': {
32
35
  const list = Array.isArray(value) ? value.map(String) : [];
33
36
  if (field.required && list.length === 0) errors[field.name] = `${field.label} is required`;
34
- data[field.name] = list;
37
+ if (list.length > 0) data[field.name] = list;
35
38
  break;
36
39
  }
37
40
  case 'date': {
38
41
  const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
39
42
  if (field.required && text === '') errors[field.name] = `${field.label} is required`;
40
- data[field.name] = text;
43
+ if (text !== '') data[field.name] = text;
41
44
  break;
42
45
  }
43
46
  default: {
44
47
  const text = typeof value === 'string' ? value.trim() : '';
45
48
  if (field.required && text === '') errors[field.name] = `${field.label} is required`;
46
- data[field.name] = text;
49
+ if (text !== '') data[field.name] = text;
47
50
  }
48
51
  }
49
52
  }
@@ -36,6 +36,13 @@ export interface ContentEntry<F = Record<string, unknown>> extends ContentSummar
36
36
  body: string;
37
37
  }
38
38
 
39
+ /** One entry's validation failure, recorded at build for the site aggregator's gate. */
40
+ export interface ContentProblem {
41
+ id: string;
42
+ draft: boolean;
43
+ errors: Record<string, string>;
44
+ }
45
+
39
46
  /** The per-concept query surface. */
40
47
  export interface ContentIndex<F = Record<string, unknown>> {
41
48
  all(opts?: { includeDrafts?: boolean }): ContentSummary[];
@@ -43,6 +50,8 @@ export interface ContentIndex<F = Record<string, unknown>> {
43
50
  byTag(tag: string, opts?: { includeDrafts?: boolean }): ContentSummary[];
44
51
  allTags(): { tag: string; count: number }[];
45
52
  adjacent(id: string): { newer?: ContentSummary; older?: ContentSummary };
53
+ /** Per-entry validation failures recorded at build, for the site-level build gate. */
54
+ problems(): ContentProblem[];
46
55
  }
47
56
 
48
57
  /** Map a Vite eager `?raw` glob record (`{ path: raw }`) to `RawFile[]`. */
@@ -74,26 +83,36 @@ export function createContentIndex<F = Record<string, unknown>>(
74
83
  files: RawFile[],
75
84
  descriptor: ConceptDescriptor,
76
85
  ): ContentIndex<F> {
77
- const entries: ContentEntry<F>[] = files.map((file) => {
86
+ const problems: ContentProblem[] = [];
87
+ const entries: ContentEntry<F>[] = [];
88
+ for (const file of files) {
78
89
  const id = idFromFilename(basename(file.path));
79
90
  const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
80
- const { frontmatter, body } = parseMarkdown(file.raw);
81
- const date = asDate(frontmatter.date);
82
- return {
91
+ const { frontmatter: raw, body } = parseMarkdown(file.raw);
92
+ const date = asDate(raw.date);
93
+ const draft = raw.draft === true;
94
+ // Validate once at build. A failure is recorded for the site gate and excluded from the typed
95
+ // read, so every readable entry's frontmatter is the validator's normalized output, never raw.
96
+ const result = descriptor.validate(raw, body);
97
+ if (!result.ok) {
98
+ problems.push({ id, draft, errors: result.errors });
99
+ continue;
100
+ }
101
+ entries.push({
83
102
  id,
84
103
  slug,
85
104
  permalink: permalink(descriptor, { id, slug, date }),
86
- title: asString(frontmatter.title) ?? id,
105
+ title: asString(raw.title) ?? id,
87
106
  date,
88
- updated: asDate(frontmatter.updated),
89
- tags: asTags(frontmatter.tags),
90
- excerpt: deriveExcerpt(body, { description: asString(frontmatter.description) }),
107
+ updated: asDate(raw.updated),
108
+ tags: asTags(raw.tags),
109
+ excerpt: deriveExcerpt(body, { description: asString(raw.description) }),
91
110
  wordCount: wordCount(body),
92
- draft: frontmatter.draft === true,
93
- frontmatter: frontmatter as F,
111
+ draft,
112
+ frontmatter: result.data as F,
94
113
  body,
95
- };
96
- });
114
+ });
115
+ }
97
116
 
98
117
  // Dated concepts sort newest-first; undated concepts (Pages) sort by title.
99
118
  const sorted = [...entries].sort((a, b) =>
@@ -131,5 +150,6 @@ export function createContentIndex<F = Record<string, unknown>>(
131
150
  older: i < list.length - 1 ? summarize(list[i + 1]) : undefined,
132
151
  };
133
152
  },
153
+ problems: () => problems,
134
154
  };
135
155
  }
@@ -17,7 +17,7 @@ export interface FeedChannel {
17
17
  export interface FeedItem {
18
18
  title: string;
19
19
  url: string;
20
- date: string;
20
+ date?: string;
21
21
  updated?: string;
22
22
  summary: string;
23
23
  contentHtml?: string;
@@ -37,14 +37,22 @@ function cdataSafe(value: string): string {
37
37
  return value.replace(/]]>/g, ']]]]><![CDATA[>');
38
38
  }
39
39
 
40
- /** Format a YYYY-MM-DD (or ISO) string as an RFC-822 date in UTC, as RSS wants. */
41
- function rfc822(date: string): string {
42
- return new Date(`${date.slice(0, 10)}T00:00:00.000Z`).toUTCString();
40
+ /** Parse a YYYY-MM-DD (or ISO) string as a UTC instant. Returns undefined for an absent or
41
+ * unparseable date, so a feed omits the date field rather than emit Invalid Date or throw. */
42
+ function parseFeedDate(date?: string): Date | undefined {
43
+ if (!date) return undefined;
44
+ const at = new Date(`${date.slice(0, 10)}T00:00:00.000Z`);
45
+ return Number.isNaN(at.getTime()) ? undefined : at;
43
46
  }
44
47
 
45
- /** Format a YYYY-MM-DD (or ISO) string as an ISO-8601 instant in UTC. */
46
- function iso(date: string): string {
47
- return new Date(`${date.slice(0, 10)}T00:00:00.000Z`).toISOString();
48
+ /** Format a date as an RFC-822 string in UTC, as RSS wants, or undefined when it cannot parse. */
49
+ function rfc822(date?: string): string | undefined {
50
+ return parseFeedDate(date)?.toUTCString();
51
+ }
52
+
53
+ /** Format a date as an ISO-8601 instant in UTC, or undefined when it cannot parse. */
54
+ function iso(date?: string): string | undefined {
55
+ return parseFeedDate(date)?.toISOString();
48
56
  }
49
57
 
50
58
  /** Build an RSS 2.0 document. */
@@ -52,17 +60,20 @@ export function buildRssFeed(channel: FeedChannel, items: FeedItem[]): string {
52
60
  const entries = items
53
61
  .map((item) => {
54
62
  const content = item.contentHtml ?? item.summary;
63
+ const pubDate = rfc822(item.date);
55
64
  return [
56
65
  ' <item>',
57
66
  ` <title>${escapeXml(item.title)}</title>`,
58
67
  ` <link>${escapeXml(item.url)}</link>`,
59
68
  ` <guid isPermaLink="true">${escapeXml(item.url)}</guid>`,
60
- ` <pubDate>${rfc822(item.date)}</pubDate>`,
69
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
61
70
  ` <description>${escapeXml(item.summary)}</description>`,
62
71
  // CDATA cannot contain `]]>`, so split that one sequence rather than escape the body.
63
72
  ` <content:encoded><![CDATA[${cdataSafe(content)}]]></content:encoded>`,
64
73
  ' </item>',
65
- ].join('\n');
74
+ ]
75
+ .filter((line) => line !== '')
76
+ .join('\n');
66
77
  })
67
78
  .join('\n');
68
79
 
@@ -95,16 +106,20 @@ export function buildJsonFeed(channel: FeedChannel, items: FeedItem[]): string {
95
106
  feed_url: channel.feedUrl,
96
107
  ...(channel.language ? { language: channel.language } : {}),
97
108
  ...(channel.author ? { authors: [channel.author] } : {}),
98
- items: items.map((item) => ({
99
- id: item.url,
100
- url: item.url,
101
- title: item.title,
102
- summary: item.summary,
103
- date_published: iso(item.date),
104
- ...(item.updated ? { date_modified: iso(item.updated) } : {}),
105
- ...(item.contentHtml ? { content_html: item.contentHtml } : { content_text: item.summary }),
106
- ...(item.tags && item.tags.length ? { tags: item.tags } : {}),
107
- })),
109
+ items: items.map((item) => {
110
+ const datePublished = iso(item.date);
111
+ const dateModified = iso(item.updated);
112
+ return {
113
+ id: item.url,
114
+ url: item.url,
115
+ title: item.title,
116
+ summary: item.summary,
117
+ ...(datePublished ? { date_published: datePublished } : {}),
118
+ ...(dateModified ? { date_modified: dateModified } : {}),
119
+ ...(item.contentHtml ? { content_html: item.contentHtml } : { content_text: item.summary }),
120
+ ...(item.tags && item.tags.length ? { tags: item.tags } : {}),
121
+ };
122
+ }),
108
123
  },
109
124
  null,
110
125
  2,
@@ -4,9 +4,11 @@
4
4
  // helpers, the catch-all route loaders, and the head component. It imports nothing from auth,
5
5
  // github, or email, so importing it does not pull the server backend into a public bundle.
6
6
  export { createContentIndex, fromGlob } from './content-index.js';
7
- export type { RawFile, ContentSummary, ContentEntry, ContentIndex } from './content-index.js';
7
+ export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
8
8
  export { createSiteIndex } from './site-index.js';
9
9
  export type { SiteIndex, ConceptIndex } from './site-index.js';
10
+ export { createSiteIndexes } from './site-indexes.js';
11
+ export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
10
12
  export { siteDescriptors } from './site-descriptors.js';
11
13
  export { deriveExcerpt, wordCount } from './excerpt.js';
12
14
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
@@ -16,6 +18,8 @@ export type { SitemapUrl } from './sitemap.js';
16
18
  export { buildRobots } from './robots.js';
17
19
  export { buildSeoMeta } from './seo.js';
18
20
  export type { SeoInput, SeoMeta } from './seo.js';
21
+ export { readSeoFields, resolveImageUrl } from './seo-fields.js';
22
+ export type { SeoFields } from './seo-fields.js';
19
23
  export { paginate } from './paginate.js';
20
24
  export type { Page } from './paginate.js';
21
25
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
@@ -0,0 +1,43 @@
1
+ // cairn-cms: the SEO head fields read at the cross-concept boundary (schema-source-of-truth design,
2
+ // Plan 3). The catch-all route resolves any concept by request path, so the entry's frontmatter is
3
+ // typed Record<string, unknown>; this reads the known head fields by name and coerces. Kept apart
4
+ // from seo.ts (the head builder) so reading frontmatter and building the head stay distinct concerns.
5
+
6
+ /** The head fields a concept can carry in frontmatter. Each is optional and omitted when absent.
7
+ * `author` is article-scoped downstream: the head builder emits `article:author` only for a dated
8
+ * entry, so an `author` on an undated Page is read here but not rendered. */
9
+ export interface SeoFields {
10
+ description?: string;
11
+ image?: string;
12
+ robots?: string;
13
+ author?: string;
14
+ }
15
+
16
+ const KEYS = ['description', 'image', 'robots', 'author'] as const;
17
+
18
+ /** Read the known SEO head fields off an entry's normalized frontmatter. Keeps a present string,
19
+ * trimmed, and omits an absent, empty, or non-string value. Trimming the stored value keeps a stray
20
+ * `robots: " noindex "` from reaching the head tag with surrounding whitespace. The field must be
21
+ * declared in the concept's schema to survive the validate-once read; an undeclared key is not on the
22
+ * normalized frontmatter. */
23
+ export function readSeoFields(frontmatter: Record<string, unknown>): SeoFields {
24
+ const fields: SeoFields = {};
25
+ for (const key of KEYS) {
26
+ const value = frontmatter[key];
27
+ if (typeof value === 'string' && value.trim() !== '') fields[key] = value.trim();
28
+ }
29
+ return fields;
30
+ }
31
+
32
+ /** Resolve an author-supplied image path to an absolute URL against the site origin. An absolute or
33
+ * protocol-relative URL passes through; a root-relative path anchors to the origin; a malformed
34
+ * string returns undefined rather than throwing at build. The sites use a bare-domain origin, so a
35
+ * bare path also anchors to the origin root; against a sub-path origin it would resolve relative to
36
+ * that path, per the WHATWG URL rules. */
37
+ export function resolveImageUrl(image: string, origin: string): string | undefined {
38
+ try {
39
+ return new URL(image, origin).href;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }