@glw907/cairn-cms 0.11.0 → 0.14.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 (75) hide show
  1. package/dist/components/ComponentForm.svelte +33 -10
  2. package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
  3. package/dist/components/IconPicker.svelte +53 -7
  4. package/dist/components/IconPicker.svelte.d.ts +7 -3
  5. package/dist/components/IconPicker.svelte.d.ts.map +1 -1
  6. package/dist/content/adapter.d.ts +4 -0
  7. package/dist/content/adapter.d.ts.map +1 -0
  8. package/dist/content/adapter.js +4 -0
  9. package/dist/content/concepts.js +2 -2
  10. package/dist/content/schema.d.ts +75 -0
  11. package/dist/content/schema.d.ts.map +1 -0
  12. package/dist/content/schema.js +72 -0
  13. package/dist/content/types.d.ts +30 -7
  14. package/dist/content/types.d.ts.map +1 -1
  15. package/dist/content/validate.d.ts +5 -3
  16. package/dist/content/validate.d.ts.map +1 -1
  17. package/dist/content/validate.js +14 -7
  18. package/dist/delivery/content-index.d.ts +8 -0
  19. package/dist/delivery/content-index.d.ts.map +1 -1
  20. package/dist/delivery/content-index.js +17 -8
  21. package/dist/delivery/index.d.ts +5 -1
  22. package/dist/delivery/index.d.ts.map +1 -1
  23. package/dist/delivery/index.js +2 -0
  24. package/dist/delivery/seo-fields.d.ts +22 -0
  25. package/dist/delivery/seo-fields.d.ts.map +1 -0
  26. package/dist/delivery/seo-fields.js +32 -0
  27. package/dist/delivery/site-index.d.ts +2 -2
  28. package/dist/delivery/site-index.d.ts.map +1 -1
  29. package/dist/delivery/site-index.js +16 -18
  30. package/dist/delivery/site-indexes.d.ts +26 -0
  31. package/dist/delivery/site-indexes.d.ts.map +1 -0
  32. package/dist/delivery/site-indexes.js +22 -0
  33. package/dist/index.d.ts +9 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -2
  36. package/dist/render/component-grammar.d.ts +7 -0
  37. package/dist/render/component-grammar.d.ts.map +1 -1
  38. package/dist/render/component-grammar.js +27 -8
  39. package/dist/render/component-validate.js +3 -3
  40. package/dist/render/glyph.d.ts +4 -1
  41. package/dist/render/glyph.d.ts.map +1 -1
  42. package/dist/render/glyph.js +6 -2
  43. package/dist/render/registry.d.ts +23 -5
  44. package/dist/render/registry.d.ts.map +1 -1
  45. package/dist/render/registry.js +6 -0
  46. package/dist/render/rehype-dispatch.d.ts +1 -5
  47. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  48. package/dist/render/rehype-dispatch.js +71 -19
  49. package/dist/render/remark-directives.d.ts +1 -1
  50. package/dist/render/remark-directives.d.ts.map +1 -1
  51. package/dist/render/remark-directives.js +37 -0
  52. package/dist/sveltekit/public-routes.d.ts +3 -0
  53. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  54. package/dist/sveltekit/public-routes.js +9 -2
  55. package/package.json +1 -1
  56. package/src/lib/components/ComponentForm.svelte +33 -10
  57. package/src/lib/components/IconPicker.svelte +53 -7
  58. package/src/lib/content/adapter.ts +10 -0
  59. package/src/lib/content/concepts.ts +2 -2
  60. package/src/lib/content/schema.ts +133 -0
  61. package/src/lib/content/types.ts +30 -7
  62. package/src/lib/content/validate.ts +10 -7
  63. package/src/lib/delivery/content-index.ts +25 -8
  64. package/src/lib/delivery/index.ts +5 -1
  65. package/src/lib/delivery/seo-fields.ts +43 -0
  66. package/src/lib/delivery/site-index.ts +15 -16
  67. package/src/lib/delivery/site-indexes.ts +52 -0
  68. package/src/lib/index.ts +8 -2
  69. package/src/lib/render/component-grammar.ts +34 -10
  70. package/src/lib/render/component-validate.ts +3 -3
  71. package/src/lib/render/glyph.ts +6 -2
  72. package/src/lib/render/registry.ts +27 -5
  73. package/src/lib/render/rehype-dispatch.ts +67 -20
  74. package/src/lib/render/remark-directives.ts +39 -1
  75. package/src/lib/sveltekit/public-routes.ts +12 -2
@@ -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,23 +83,30 @@ export function createContentIndex<F = Record<string, unknown>>(
74
83
  files: RawFile[],
75
84
  descriptor: ConceptDescriptor,
76
85
  ): ContentIndex<F> {
86
+ const problems: ContentProblem[] = [];
77
87
  const entries: ContentEntry<F>[] = files.map((file) => {
78
88
  const id = idFromFilename(basename(file.path));
79
89
  const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
80
- const { frontmatter, body } = parseMarkdown(file.raw);
81
- const date = asDate(frontmatter.date);
90
+ const { frontmatter: raw, body } = parseMarkdown(file.raw);
91
+ const date = asDate(raw.date);
92
+ const draft = raw.draft === true;
93
+ // Validate once at build. The cheap summary stays raw-derived and robust; the typed detail
94
+ // frontmatter carries the normalized data on success, the raw frontmatter on failure. A
95
+ // failure is recorded, not thrown, so the query surface does not explode on construction.
96
+ const result = descriptor.validate(raw, body);
97
+ if (!result.ok) problems.push({ id, draft, errors: result.errors });
82
98
  return {
83
99
  id,
84
100
  slug,
85
101
  permalink: permalink(descriptor, { id, slug, date }),
86
- title: asString(frontmatter.title) ?? id,
102
+ title: asString(raw.title) ?? id,
87
103
  date,
88
- updated: asDate(frontmatter.updated),
89
- tags: asTags(frontmatter.tags),
90
- excerpt: deriveExcerpt(body, { description: asString(frontmatter.description) }),
104
+ updated: asDate(raw.updated),
105
+ tags: asTags(raw.tags),
106
+ excerpt: deriveExcerpt(body, { description: asString(raw.description) }),
91
107
  wordCount: wordCount(body),
92
- draft: frontmatter.draft === true,
93
- frontmatter: frontmatter as F,
108
+ draft,
109
+ frontmatter: (result.ok ? result.data : raw) as F,
94
110
  body,
95
111
  };
96
112
  });
@@ -131,5 +147,6 @@ export function createContentIndex<F = Record<string, unknown>>(
131
147
  older: i < list.length - 1 ? summarize(list[i + 1]) : undefined,
132
148
  };
133
149
  },
150
+ problems: () => problems,
134
151
  };
135
152
  }
@@ -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
+ }
@@ -30,33 +30,32 @@ function normalizePath(path: string): string {
30
30
  return path.length > 1 ? path.replace(/\/+$/, '') : path;
31
31
  }
32
32
 
33
- /** Validate every entry (drafts included) against its concept, aggregating failures. */
34
- function validateAll(concepts: ConceptIndex[]): void {
33
+ /** Collect non-draft validation failures across concepts from each index's recorded verdicts. */
34
+ function siteProblems(concepts: ConceptIndex[]): string[] {
35
35
  const problems: string[] = [];
36
36
  for (const { descriptor, index } of concepts) {
37
- for (const summary of index.all({ includeDrafts: true })) {
38
- const entry = index.byId(summary.id);
39
- if (!entry) continue;
40
- const result = descriptor.validate(entry.frontmatter, entry.body);
41
- if (!result.ok) {
42
- for (const [field, message] of Object.entries(result.errors)) {
43
- problems.push(`${descriptor.dir}/${summary.id}: ${field}: ${message}`);
44
- }
37
+ for (const problem of index.problems()) {
38
+ if (problem.draft) continue; // a half-finished draft never ships, so it does not fail the build
39
+ for (const [field, message] of Object.entries(problem.errors)) {
40
+ problems.push(`${descriptor.dir}/${problem.id}: ${field}: ${message}`);
45
41
  }
46
42
  }
47
43
  }
48
- if (problems.length > 0) {
49
- throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
50
- }
44
+ return problems;
51
45
  }
52
46
 
53
47
  /**
54
48
  * Union per-concept indexes into a site-level resolver. Throws on a duplicate permalink and,
55
- * unless `validate` is `false`, on any entry whose frontmatter fails its concept's validator,
56
- * so malformed content fails the build instead of shipping.
49
+ * unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
50
+ * validator, so malformed content fails the build instead of shipping.
57
51
  */
58
52
  export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteIndex {
59
- if (opts.validate !== false) validateAll(concepts);
53
+ if (opts.validate !== false) {
54
+ const problems = siteProblems(concepts);
55
+ if (problems.length > 0) {
56
+ throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
57
+ }
58
+ }
60
59
  const byPath = new Map<string, { index: ContentIndex; id: string }>();
61
60
  const byId = new Map<string, ContentIndex>();
62
61
  for (const { descriptor, index } of concepts) {
@@ -0,0 +1,52 @@
1
+ // cairn-cms: the full-auto typed site index (schema-source-of-truth design). It maps over a
2
+ // defineAdapter-typed adapter to give one typed per-concept index, with frontmatter typed as the
3
+ // concept's inferred schema type, plus a site resolver for the catch-all route. It is the typed
4
+ // convenience over createContentIndex and createSiteIndex, not a replacement: both stay the
5
+ // lower-level escape hatch. It imports only pure content and delivery code, so the delivery
6
+ // bundle stays backend-free.
7
+ import type { CairnAdapter, ConceptConfig } from '../content/types.js';
8
+ import type { Infer } from '../content/schema.js';
9
+ import type { SiteConfig } from '../nav/site-config.js';
10
+ import { siteDescriptors } from './site-descriptors.js';
11
+ import { createContentIndex, fromGlob } from './content-index.js';
12
+ import { createSiteIndex } from './site-index.js';
13
+ import type { ContentIndex } from './content-index.js';
14
+ import type { ConceptIndex, SiteIndex } from './site-index.js';
15
+
16
+ /** A per-concept raw glob record (`{ path: raw }`) keyed by concept id, from `import.meta.glob`. */
17
+ export type SiteGlobs<A extends CairnAdapter> = {
18
+ [K in keyof A['content']]?: Record<string, string>;
19
+ };
20
+
21
+ /** The typed per-concept indexes plus the cross-concept `site` resolver. A concept literally named
22
+ * `site` is not supported, since `site` is the reserved resolver key. */
23
+ export type SiteIndexes<A extends CairnAdapter> = {
24
+ [K in keyof A['content']]: ContentIndex<
25
+ NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
26
+ >;
27
+ } & { readonly site: SiteIndex };
28
+
29
+ /**
30
+ * Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
31
+ * globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
32
+ * glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
33
+ * of the build gate, exactly as on `createSiteIndex`.
34
+ */
35
+ export function createSiteIndexes<const A extends CairnAdapter>(
36
+ adapter: A,
37
+ config: SiteConfig,
38
+ globs: SiteGlobs<A>,
39
+ opts: { validate?: boolean } = {},
40
+ ): SiteIndexes<A> {
41
+ const descriptors = siteDescriptors(adapter, config);
42
+ const byConcept: Record<string, ContentIndex> = {};
43
+ const conceptIndexes: ConceptIndex[] = [];
44
+ for (const descriptor of descriptors) {
45
+ const record = (globs as Record<string, Record<string, string> | undefined>)[descriptor.id] ?? {};
46
+ const index = createContentIndex(fromGlob(record), descriptor);
47
+ byConcept[descriptor.id] = index;
48
+ conceptIndexes.push({ descriptor, index });
49
+ }
50
+ const site = createSiteIndex(conceptIndexes, opts);
51
+ return { ...byConcept, site } as SiteIndexes<A>;
52
+ }
package/src/lib/index.ts CHANGED
@@ -37,7 +37,9 @@ export {
37
37
  serializeMarkdown,
38
38
  parseMarkdown,
39
39
  } from './content/frontmatter.js';
40
- export { validateFields } from './content/validate.js';
40
+ export { defineFields } from './content/schema.js';
41
+ export { defineAdapter } from './content/adapter.js';
42
+ export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
41
43
  export {
42
44
  isValidId,
43
45
  idFromFilename,
@@ -72,7 +74,6 @@ export {
72
74
  isElement,
73
75
  strProp,
74
76
  iconSpan,
75
- splitHead,
76
77
  cardShell,
77
78
  markFirstList,
78
79
  } from './render/rehype-dispatch.js';
@@ -119,9 +120,12 @@ export type {
119
120
  ContentSummary,
120
121
  ContentEntry,
121
122
  ContentIndex,
123
+ ContentProblem,
122
124
  } from './delivery/content-index.js';
123
125
  export { createSiteIndex } from './delivery/site-index.js';
124
126
  export type { SiteIndex, ConceptIndex } from './delivery/site-index.js';
127
+ export { createSiteIndexes } from './delivery/site-indexes.js';
128
+ export type { SiteIndexes, SiteGlobs } from './delivery/site-indexes.js';
125
129
  export { deriveExcerpt, wordCount } from './delivery/excerpt.js';
126
130
  export { buildRssFeed, buildJsonFeed } from './delivery/feeds.js';
127
131
  export type { FeedChannel, FeedItem } from './delivery/feeds.js';
@@ -130,5 +134,7 @@ export type { SitemapUrl } from './delivery/sitemap.js';
130
134
  export { buildRobots } from './delivery/robots.js';
131
135
  export { buildSeoMeta } from './delivery/seo.js';
132
136
  export type { SeoInput, SeoMeta } from './delivery/seo.js';
137
+ export { readSeoFields, resolveImageUrl } from './delivery/seo-fields.js';
138
+ export type { SeoFields } from './delivery/seo-fields.js';
133
139
  export { paginate } from './delivery/paginate.js';
134
140
  export type { Page } from './delivery/paginate.js';
@@ -84,14 +84,19 @@ function childrenToText(children: RootContent[]): string {
84
84
  return String(toMd.stringify(root)).trim();
85
85
  }
86
86
 
87
- /** Parse a serialized component directive back into guided-form values, the inverse of
88
- * {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
89
- * saved directive through the form. */
90
- export async function parseComponent(markdown: string, def: ComponentDef): Promise<ComponentValues> {
87
+ // Parse the markdown and find the component's opening container directive. The single seam both
88
+ // parseComponent and parseRawAttributeKeys (and the combined validator helper) build on, so one
89
+ // parse derives both the form values and the raw attribute keys.
90
+ function findComponentRoot(markdown: string, def: ComponentDef): (RootContent & DirectiveNode) | undefined {
91
91
  const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown) as Root;
92
- const root = tree.children.find(
92
+ return tree.children.find(
93
93
  (c): c is RootContent & DirectiveNode => isContainer(c) && (c as DirectiveNode).name === def.name,
94
94
  );
95
+ }
96
+
97
+ // Build guided-form values from an already-found component root. Returns the empty base when the
98
+ // root is absent.
99
+ function valuesFromRoot(root: (RootContent & DirectiveNode) | undefined, def: ComponentDef): ComponentValues {
95
100
  const values = emptyComponentValues(def);
96
101
  if (!root) return values;
97
102
 
@@ -126,14 +131,33 @@ export async function parseComponent(markdown: string, def: ComponentDef): Promi
126
131
  return values;
127
132
  }
128
133
 
134
+ // The raw attribute keys on an already-found component root.
135
+ function rawKeysFromRoot(root: (RootContent & DirectiveNode) | undefined): string[] {
136
+ return Object.keys(root?.attributes ?? {});
137
+ }
138
+
139
+ /** Parse a serialized component directive back into guided-form values, the inverse of
140
+ * {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
141
+ * saved directive through the form. */
142
+ export async function parseComponent(markdown: string, def: ComponentDef): Promise<ComponentValues> {
143
+ return valuesFromRoot(findComponentRoot(markdown, def), def);
144
+ }
145
+
129
146
  /** The raw attribute keys present on the component's opening directive, read from the parsed tree
130
147
  * (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
131
148
  export function parseRawAttributeKeys(markdown: string, def: ComponentDef): string[] {
132
- const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown) as Root;
133
- const root = tree.children.find(
134
- (c): c is RootContent & DirectiveNode => isContainer(c) && (c as DirectiveNode).name === def.name,
135
- );
136
- return Object.keys(root?.attributes ?? {});
149
+ return rawKeysFromRoot(findComponentRoot(markdown, def));
150
+ }
151
+
152
+ /** Parse the component once and derive both the guided-form values and the raw attribute keys.
153
+ * Validation needs both, so this seam spares it the double parse that calling
154
+ * {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
155
+ export async function parseComponentWithRawKeys(
156
+ markdown: string,
157
+ def: ComponentDef,
158
+ ): Promise<{ values: ComponentValues; rawKeys: string[] }> {
159
+ const root = findComponentRoot(markdown, def);
160
+ return { values: valuesFromRoot(root, def), rawKeys: rawKeysFromRoot(root) };
137
161
  }
138
162
 
139
163
  // A bare parse base: empty strings, false, and empty lists, with no attribute defaults applied. The
@@ -1,11 +1,11 @@
1
- import { parseComponent, parseRawAttributeKeys } from './component-grammar.js';
1
+ import { parseComponentWithRawKeys } from './component-grammar.js';
2
2
  import type { ComponentDef } from './registry.js';
3
3
 
4
4
  /** A validation verdict: ok, or field-keyed error messages. */
5
5
  export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
6
6
 
7
7
  export async function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation> {
8
- const values = await parseComponent(markdown, def);
8
+ const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
9
9
  const errors: Record<string, string> = {};
10
10
  const declared = new Set((def.attributes ?? []).map((f) => f.key));
11
11
 
@@ -21,7 +21,7 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
21
21
  }
22
22
  }
23
23
 
24
- for (const key of parseRawAttributeKeys(markdown, def)) {
24
+ for (const key of rawKeys) {
25
25
  if (!declared.has(key)) errors[key] = `Unknown attribute "${key}".`;
26
26
  }
27
27
 
@@ -4,11 +4,15 @@ import type { Element } from 'hast';
4
4
  /** A glyph name to SVG path-data map (the site owns the icon set). */
5
5
  export type IconSet = Record<string, string>;
6
6
 
7
- /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
7
+ /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill.
8
+ * An unknown icon name yields the bare svg shell with no path child, so it never serializes
9
+ * a stray empty (or undefined) path. Callers always wrap the returned element, so the shell
10
+ * keeps them safe. */
8
11
  export function glyph(name: string, icons: IconSet): Element {
12
+ const d = icons[name];
9
13
  return s(
10
14
  'svg',
11
15
  { className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' },
12
- [s('path', { d: icons[name] })],
16
+ d == null ? [] : [s('path', { d })],
13
17
  );
14
18
  }
@@ -3,7 +3,7 @@
3
3
  // (Plan 04) and the future component palette both derive from this single source, so the
4
4
  // parser, the render dispatch, and the editor never drift apart. The adapter references
5
5
  // `ComponentRegistry` from here.
6
- import type { Element } from 'hast';
6
+ import type { Element, ElementContent } from 'hast';
7
7
 
8
8
  /** The input types a component attribute or repeatable item field can take. */
9
9
  export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
@@ -38,6 +38,21 @@ export interface SlotDef {
38
38
  itemFields?: AttributeField[];
39
39
  }
40
40
 
41
+ /** The structured input a component's `build` receives. The engine stamps the component's
42
+ * attributes and partitions its slots from the rendered hast, so `build` arranges hast and
43
+ * never walks the tree. `slot(name)` returns a slot's rendered children (title, body, or any
44
+ * named slot); `items(name)` returns a repeatable slot's items, one child list per item. */
45
+ export interface ComponentContext {
46
+ /** Declared attribute values, keyed by attribute key. Booleans are real booleans. */
47
+ attributes: Record<string, string | boolean>;
48
+ /** A named slot's rendered children. Returns `[]` for an absent or empty slot. */
49
+ slot(name: string): ElementContent[];
50
+ /** A repeatable slot's items, each item its own list of rendered children. `[]` when absent. */
51
+ items(name: string): ElementContent[][];
52
+ /** The stamped component element, for an escape hatch. Most builds never need it. */
53
+ node: Element;
54
+ }
55
+
41
56
  /** A site component: how it inserts (editor) and how it renders (rehype). */
42
57
  export interface ComponentDef {
43
58
  /** Directive name, e.g. 'card' (matches `:::card`). */
@@ -48,10 +63,10 @@ export interface ComponentDef {
48
63
  description: string;
49
64
  /** Markdown scaffold inserted at the cursor by the editor palette. */
50
65
  insertTemplate?: string;
51
- /** Build the final hast element from the stamped directive element. The engine
52
- * stamps the entrance-stagger ordinal (`data-rise`) on the top-level result, so a
53
- * build fn stays free of any motion concern. */
54
- build: (node: Element) => Element;
66
+ /** Build the final hast element from the component context (attributes plus partitioned
67
+ * slots). The engine stamps the entrance-stagger ordinal (`data-rise`) on the top-level
68
+ * result, so a build fn stays free of any motion concern. */
69
+ build: (ctx: ComponentContext) => Element;
55
70
  /** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
56
71
  defaultIconByRole?: Record<string, string>;
57
72
  /** One line on when to reach for this component; feeds the picker and the reference file. */
@@ -69,6 +84,13 @@ export interface ComponentRegistry {
69
84
  defaultIcon(name: string, role?: string): string | undefined;
70
85
  }
71
86
 
87
+ /** The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
88
+ * becomes `dataAttrTone`. The directive stamp writes it and the rehype dispatch reads it, so both
89
+ * sides derive the name from this one helper rather than spelling the capitalize twice. */
90
+ export function dataAttrProp(key: string): string {
91
+ return `dataAttr${key.charAt(0).toUpperCase()}${key.slice(1)}`;
92
+ }
93
+
72
94
  /**
73
95
  * Build a registry from a site's component definitions. The single source the render
74
96
  * pipeline (directive stamp plus rehype dispatch) and the editor palette both read.