@micacms/astro 0.5.0-rc.2 → 0.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @micacms/astro
2
2
 
3
- > **Experimental.** Pre-1.0: the API and the content format can still change
4
- > without notice. Use it at your own risk.
3
+ > **Public.** From 1.0, semver covers this package. Pre-1.0: the API and the
4
+ > content format can still change in a minor release.
5
5
 
6
6
  Reads a Mica content repository as Astro Content Collections, through the
7
7
  Content Layer API — and publishes the content model for Mica to read back.
@@ -68,6 +68,11 @@ Astro hands a loader exactly what they require:
68
68
  loader watches its own directory and coalesces the burst of writes that saving
69
69
  one entry produces (`debounceMs`, 100 ms by default).
70
70
 
71
+ The loaders also check references: a relation to a missing entry, or a media
72
+ path to a missing file, is a warning in the build log. `strict: true` makes it
73
+ fail the build. An entry in the other layout always fails the build, with the
74
+ files and `npx mica migrate-layout`.
75
+
71
76
  ## Mixing with your own collections
72
77
 
73
78
  The return value is a plain object:
@@ -97,7 +102,8 @@ collections. They ship with the package, so they match the installed version.
97
102
  ## Install
98
103
 
99
104
  ```bash
100
- npm install @micacms/astro
105
+ npm install @micacms/astro @micacms/core
101
106
  ```
102
107
 
103
- Astro 6 or 7 is a peer dependency. Requires Node 22.12 or newer. Licence: MIT.
108
+ Astro 7, `@micacms/core` and zod 4 are peer dependencies. npm installs zod. ESM only. Requires Node 24 or newer.
109
+ Licence: MIT.
package/dist/index.d.ts CHANGED
@@ -45,6 +45,11 @@ export interface MicaCollectionsOptions {
45
45
  * after the burst is both faster and less confusing than one per file.
46
46
  */
47
47
  debounceMs?: number;
48
+ /**
49
+ * Fails the build on a relation to a missing entry or a missing media file.
50
+ * Without it, the build logs a warning for each and goes on.
51
+ */
52
+ strict?: boolean;
48
53
  }
49
54
  /**
50
55
  * Entry metadata attached to every collection document.
@@ -149,11 +154,4 @@ export type SingletonDocument<S extends Site, K extends keyof S["singletons"]> =
149
154
  export { manifestFor } from "./manifest.js";
150
155
  export { DEFAULT_CONTENT_ROOT, resolveContentRoot };
151
156
  export { renderRichText, transformRichText, validateRichText, type RichTextOptions, } from "./richtext.js";
152
- /**
153
- * Re-exported for convenience, so a project that only depends on this package
154
- * can still build a source or read a collection. They are defined in
155
- * `@micacms/core`, where the Mica server can reach them too.
156
- */
157
- export { ContentReadError, ContentSourceError, ORDER_FILE, memorySource, readCollection, readSingleton, type ContentSource, type DirectoryEntry, } from "@micacms/core";
158
- export { fileSystemSource } from "@micacms/core/node";
159
157
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { mkdir, readFile, writeFile } from "node:fs/promises";
19
19
  import { dirname, join, relative, sep } from "node:path";
20
- import { fieldsSchema, MANIFEST_PATH, readCollection, readSingleton, } from "@micacms/core";
20
+ import { createReferenceCheck, fieldsSchema, isFieldKind, MANIFEST_PATH, readCollection, readSingleton, } from "@micacms/core";
21
21
  import { fileSystemSource } from "@micacms/core/node";
22
22
  import { z } from "zod";
23
23
  import { manifestFor } from "./manifest.js";
@@ -29,6 +29,7 @@ const metaSchema = z.object({
29
29
  position: z.number(),
30
30
  });
31
31
  export function micaCollections(site, options = {}) {
32
+ assertKnownKinds(site);
32
33
  /**
33
34
  * Everything below is resolved on first load rather than here, because the
34
35
  * Astro project root is something only a loader is told — and it is what
@@ -45,6 +46,21 @@ export function micaCollections(site, options = {}) {
45
46
  options.publishModel === false || options.source
46
47
  ? Promise.resolve()
47
48
  : publishModel(site, rootFor(context), context.logger));
49
+ /** Warns about broken relations and missing files, or fails the build with `strict`. */
50
+ const checkReferences = async (context, source, documents) => {
51
+ const check = createReferenceCheck(site, source);
52
+ const problems = [];
53
+ for (const { label, fields, data } of documents) {
54
+ for (const issue of await check.issues(fields, data))
55
+ problems.push(`${label}: ${issue.path}: ${issue.message}`);
56
+ }
57
+ if (problems.length === 0)
58
+ return;
59
+ if (options.strict)
60
+ throw new Error(`Broken references:\n ${problems.join("\n ")}`);
61
+ for (const problem of problems)
62
+ context.logger.warn(problem);
63
+ };
48
64
  /** Collections whose watcher is already registered, so a reload adds none. */
49
65
  const watching = new Set();
50
66
  const watch = (context, name, reload) => {
@@ -86,10 +102,7 @@ export function micaCollections(site, options = {}) {
86
102
  for (const [name, def] of Object.entries(site.collections)) {
87
103
  collections[name] = {
88
104
  type: "content_layer",
89
- // `storage` rather than `output`: a media reference is a path into the
90
- // repository at rest and a path into the repository once loaded. There
91
- // is no expansion step left to tell the two apart.
92
- schema: fieldsSchema(def.fields, "storage").extend({ _mica: metaSchema }),
105
+ schema: fieldsSchema(def.fields).extend({ _mica: metaSchema }),
93
106
  loader: {
94
107
  name: `mica:${name}`,
95
108
  load: async (context) => {
@@ -112,6 +125,7 @@ export function micaCollections(site, options = {}) {
112
125
  });
113
126
  context.store.set({ id: entry.slug, data, digest: context.generateDigest(data) });
114
127
  }
128
+ await checkReferences(context, source, entries.map((entry) => ({ label: `${name}/${entry.slug}`, fields: def.fields, data: entry.data })));
115
129
  context.logger.info(`Loaded ${entries.length} ${entries.length === 1 ? "entry" : "entries"} of "${def.label}"`);
116
130
  };
117
131
  await publish(context);
@@ -126,7 +140,7 @@ export function micaCollections(site, options = {}) {
126
140
  for (const [name, def] of Object.entries(site.singletons)) {
127
141
  collections[name] = {
128
142
  type: "content_layer",
129
- schema: fieldsSchema(def.fields, "storage"),
143
+ schema: fieldsSchema(def.fields),
130
144
  loader: {
131
145
  name: `mica:${name}`,
132
146
  load: async (context) => {
@@ -141,6 +155,7 @@ export function micaCollections(site, options = {}) {
141
155
  context.logger.warn(`No content for "${def.label}" yet.`);
142
156
  return;
143
157
  }
158
+ await checkReferences(context, source, [{ label: name, fields: def.fields, data }]);
144
159
  const parsed = await context.parseData({ id: name, data });
145
160
  context.store.set({ id: name, data: parsed, digest: context.generateDigest(parsed) });
146
161
  };
@@ -153,6 +168,26 @@ export function micaCollections(site, options = {}) {
153
168
  }
154
169
  return collections;
155
170
  }
171
+ /**
172
+ * The site's own model must use only kinds this package knows. Mica reads an
173
+ * unknown kind as one from a newer version and makes its collection read-only,
174
+ * so a typo would otherwise pass the build and lock the collection.
175
+ */
176
+ function assertKnownKinds(site) {
177
+ const check = (fields, at) => {
178
+ for (const [key, field] of Object.entries(fields)) {
179
+ if (!isFieldKind(field.kind)) {
180
+ throw new Error(`Field "${at}.${key}" has the unknown kind "${String(field.kind)}". Use the field functions from @micacms/core.`);
181
+ }
182
+ if (field.kind === "object" || field.kind === "repeater")
183
+ check(field.fields, `${at}.${key}`);
184
+ }
185
+ };
186
+ for (const [name, def] of Object.entries(site.collections))
187
+ check(def.fields, `collections.${name}`);
188
+ for (const [name, def] of Object.entries(site.singletons))
189
+ check(def.fields, `singletons.${name}`);
190
+ }
156
191
  /**
157
192
  * Writes the model into the content root, for Mica to read back.
158
193
  *
@@ -210,11 +245,4 @@ async function guard(context, source, read) {
210
245
  export { manifestFor } from "./manifest.js";
211
246
  export { DEFAULT_CONTENT_ROOT, resolveContentRoot };
212
247
  export { renderRichText, transformRichText, validateRichText, } from "./richtext.js";
213
- /**
214
- * Re-exported for convenience, so a project that only depends on this package
215
- * can still build a source or read a collection. They are defined in
216
- * `@micacms/core`, where the Mica server can reach them too.
217
- */
218
- export { ContentReadError, ContentSourceError, ORDER_FILE, memorySource, readCollection, readSingleton, } from "@micacms/core";
219
- export { fileSystemSource } from "@micacms/core/node";
220
248
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @micacms/astro — a Git repository as an Astro content source.\n *\n * // src/content.config.ts — the model is in src/mica.ts\n * import { micaCollections } from \"@micacms/astro\";\n * import { site } from \"./mica\";\n *\n * export const collections = micaCollections(site);\n *\n * From there the website uses ordinary Astro APIs — `getCollection(\"projects\")`\n * — and knows nothing about how the content arrived.\n *\n * There is no CMS in this path. The loaders read files that are already in the\n * working tree, so a build needs a checkout and nothing else: no running\n * server, no token, no network. That is the point of the Git-native design.\n * Publishing is a commit, and CI builds the commit.\n */\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join, relative, sep } from \"node:path\";\n\nimport {\n fieldsSchema,\n MANIFEST_PATH,\n readCollection,\n readSingleton,\n type Collection,\n type ContentSource,\n type InferCollection,\n type InferSingleton,\n type Singleton,\n type Site,\n} from \"@micacms/core\";\nimport { fileSystemSource } from \"@micacms/core/node\";\nimport { z } from \"zod\";\n\nimport { manifestFor } from \"./manifest.js\";\nimport { DEFAULT_CONTENT_ROOT, resolveContentRoot } from \"./root.js\";\n\nexport interface MicaCollectionsOptions {\n /**\n * Overrides `defineSite({ root })`, which is where the content root belongs.\n *\n * Relative paths resolve against the Astro project root, like Astro's own\n * `glob({ base })`. An absolute path is used as given — which is what a\n * project whose content is a separate repository, checked out by CI\n * somewhere else, wants.\n */\n root?: string;\n /** Overrides `root` entirely. Used by tests, and by a non-filesystem source. */\n source?: ContentSource;\n /**\n * Turns off publishing the model to `<root>/.mica/schema.json`.\n *\n * Only for a project that is not edited through Mica at all: without that\n * file in the repository, Mica has no model and refuses to start.\n */\n publishModel?: false;\n /**\n * How long to wait after a content change before reloading, in dev.\n *\n * Saving one entry touches several files — `index.json` and every `.mdoc`\n * beside it — and a Git checkout touches all of them at once. One reload\n * after the burst is both faster and less confusing than one per file.\n */\n debounceMs?: number;\n}\n\n/**\n * Entry metadata attached to every collection document.\n *\n * No timestamps: in Git they are commit metadata, which a static build cannot\n * read cheaply. A date an editor cares about belongs in the model as a `date()` field.\n *\n * Namespaced because these keys share a namespace with the customer's own\n * fields, and `position` is a plausible field name.\n */\nexport interface MicaMeta {\n /** The entry slug, which is its identity in the repository. */\n id: string;\n slug: string;\n collection: string;\n /** Index in the collection's order, manual when `_order.json` says so. */\n position: number;\n}\n\nconst metaSchema = z.object({\n id: z.string(),\n slug: z.string(),\n collection: z.string(),\n position: z.number(),\n});\n\n/** What a document looks like inside Astro: the payload plus `_mica`. */\nexport type AstroDocument<T> = T & { _mica: MicaMeta };\n\n/**\n * The shape Astro's config loader expects.\n *\n * Deliberately not built with `defineCollection` from `astro:content`: that is a\n * virtual module, resolvable only inside an Astro app's module graph, and this\n * package is pre-compiled. `defineCollection` does nothing more than stamp this\n * `type`, so producing the object directly keeps the package importable\n * anywhere — including from a plain unit test.\n */\nexport interface AstroCollectionConfig<T = unknown> {\n type: \"content_layer\";\n loader: ContentLoader;\n /**\n * Typed as `ZodType<T>` rather than a bare `ZodObject`, because Astro derives\n * `CollectionEntry<\"projects\">[\"data\"]` from `z.infer` of whatever this is.\n * Left generic, every field in every template would degrade to `any` — and\n * the point of the shared model is that renaming a field breaks the build.\n */\n schema: z.ZodType<T>;\n}\n\n/** The subset of Astro's `Loader` contract this package uses. */\nexport interface ContentLoader {\n name: string;\n load(context: LoaderContext): Promise<void>;\n}\n\nexport interface LoaderContext {\n store: {\n clear(): void;\n set(entry: { id: string; data: Record<string, unknown>; digest?: string }): boolean;\n keys(): string[];\n };\n logger: {\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n debug(message: string): void;\n };\n meta: { get(key: string): string | undefined; set(key: string, value: string): void };\n /**\n * Astro's resolved config. Optional because this interface is a hand-written\n * subset and the tests build a context by hand; `config.root` is what turns a\n * relative content root into a path.\n */\n config?: { root: URL };\n /**\n * Present only while `astro dev` runs. Astro's watcher follows the module\n * graph, and content files are not in it — no page imports\n * `projects/haus-a/index.json` — so each loader watches its own directory\n * here, exactly as Astro's own `glob` loader does.\n */\n watcher?: {\n add(path: string): void;\n on(event: \"add\" | \"change\" | \"unlink\", handler: (path: string) => void): void;\n };\n parseData(input: {\n id: string;\n data: Record<string, unknown>;\n }): Promise<Record<string, unknown>>;\n generateDigest(data: Record<string, unknown> | string): string;\n}\n\nexport type MicaCollections<S extends Site> = {\n [K in keyof S[\"collections\"]]: AstroCollectionConfig<\n AstroDocument<InferCollection<S[\"collections\"][K]>>\n >;\n} & {\n [K in keyof S[\"singletons\"]]: AstroCollectionConfig<InferSingleton<S[\"singletons\"][K]>>;\n};\n\nexport function micaCollections<S extends Site>(\n site: S,\n options: MicaCollectionsOptions = {},\n): MicaCollections<S> {\n /**\n * Everything below is resolved on first load rather than here, because the\n * Astro project root is something only a loader is told — and it is what\n * turns `./content` into a directory. Memoised: one root and one source,\n * however many collections and however many reloads.\n */\n let root: string | undefined;\n let source: ContentSource | undefined;\n\n const rootFor = (context: LoaderContext): string =>\n (root ??= resolveContentRoot(options.root ?? site.root, context.config?.root));\n\n const sourceFor = (context: LoaderContext): ContentSource =>\n (source ??= options.source ?? fileSystemSource(rootFor(context)));\n\n /** Published once per process; a model change restarts the dev server. */\n let publishing: Promise<void> | undefined;\n\n const publish = (context: LoaderContext): Promise<void> =>\n (publishing ??=\n options.publishModel === false || options.source\n ? Promise.resolve()\n : publishModel(site, rootFor(context), context.logger));\n\n /** Collections whose watcher is already registered, so a reload adds none. */\n const watching = new Set<string>();\n\n const watch = (context: LoaderContext, name: string, reload: () => Promise<void>): void => {\n const watcher = context.watcher;\n if (!watcher || options.source || watching.has(name)) return;\n watching.add(name);\n\n const base = rootFor(context);\n watcher.add(base);\n\n // A collection lives in `<root>/<name>/`; a singleton is either\n // `<root>/<name>.json` or `<root>/<name>/…`. Both are covered by this\n // prefix, and nothing outside it can concern this loader — which is what\n // keeps one collection's save from re-reading every other one.\n const prefix = join(base, name);\n const mine = (path: string): boolean =>\n path === `${prefix}.json` || path.startsWith(prefix + sep);\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const onChange = (path: string): void => {\n // The manifest is this package's own output; reloading content because\n // the model was written is a loop waiting to happen.\n if (path.endsWith(MANIFEST_PATH) || !mine(path)) return;\n\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => {\n // Content that fails its own schema lands here. The dev server has to\n // survive it: the next save is very likely the fix.\n void reload().catch((error: unknown) => {\n context.logger.error(`Could not reload content: ${describe(error)}`);\n });\n }, options.debounceMs ?? 100);\n // A pending reload is never a reason to keep the process alive.\n timer.unref?.();\n };\n\n watcher.on(\"change\", onChange);\n watcher.on(\"add\", onChange);\n watcher.on(\"unlink\", onChange);\n };\n\n const collections = {} as Record<string, AstroCollectionConfig<unknown>>;\n\n for (const [name, def] of Object.entries(site.collections) as [string, Collection][]) {\n collections[name] = {\n type: \"content_layer\",\n // `storage` rather than `output`: a media reference is a path into the\n // repository at rest and a path into the repository once loaded. There\n // is no expansion step left to tell the two apart.\n schema: fieldsSchema(def.fields, \"storage\").extend({ _mica: metaSchema }),\n loader: {\n name: `mica:${name}`,\n load: async (context) => {\n const read = async (): Promise<void> => {\n const source = sourceFor(context);\n const entries = await guard(context, source, () => readCollection(source, def, name));\n\n // A full replace, not a merge: an entry deleted from the repository\n // has to disappear from the build.\n context.store.clear();\n\n for (const entry of entries) {\n const meta: MicaMeta = {\n id: entry.slug,\n slug: entry.slug,\n collection: name,\n position: entry.position,\n };\n\n const data = await context.parseData({\n id: entry.slug,\n data: { ...entry.data, _mica: meta },\n });\n context.store.set({ id: entry.slug, data, digest: context.generateDigest(data) });\n }\n\n context.logger.info(`Loaded ${entries.length} ${entries.length === 1 ? \"entry\" : \"entries\"} of \"${def.label}\"`);\n };\n\n await publish(context);\n await read();\n watch(context, name, read);\n },\n },\n };\n }\n\n // A singleton becomes a one-entry collection whose id is its name, so the\n // website reaches it with the same `getEntry` it uses for everything else.\n for (const [name, def] of Object.entries(site.singletons) as [string, Singleton][]) {\n collections[name] = {\n type: \"content_layer\",\n schema: fieldsSchema(def.fields, \"storage\"),\n loader: {\n name: `mica:${name}`,\n load: async (context) => {\n const read = async (): Promise<void> => {\n const source = sourceFor(context);\n const data = await guard(context, source, () => readSingleton(source, def, name));\n\n context.store.clear();\n if (data === null) {\n // Not fatal. A site is normally deployed before every settings\n // form has been filled in, and the page that reads this singleton\n // reports the absence far better than the loader can.\n context.logger.warn(`No content for \"${def.label}\" yet.`);\n return;\n }\n\n const parsed = await context.parseData({ id: name, data });\n context.store.set({ id: name, data: parsed, digest: context.generateDigest(parsed) });\n };\n\n await publish(context);\n await read();\n watch(context, name, read);\n },\n },\n };\n }\n\n return collections as MicaCollections<S>;\n}\n\n/**\n * Writes the model into the content root, for Mica to read back.\n *\n * Mica holds no content model: the Astro project is the only place a collection\n * is defined, and this is how it says so. The file goes *inside the content\n * root* so that it travels to Mica the way content does — through Git and the\n * repository connector — with nothing to mount and no second channel to keep in\n * step. Commit it.\n *\n * Done from a loader rather than an integration because this is the moment both\n * facts are in hand: the model, and the project root that turns `./content`\n * into a directory. It also means `astro.config` needs no Mica in it, which is\n * what lets a project define Mica collections and ordinary Astro ones in the\n * same `content.config.ts`.\n */\nasync function publishModel(\n site: Site,\n root: string,\n logger: LoaderContext[\"logger\"],\n): Promise<void> {\n const target = join(root, MANIFEST_PATH);\n const contents = `${JSON.stringify(manifestFor(site), null, 2)}\\n`;\n\n try {\n // Only when it differs. The content root is watched in dev, and rewriting\n // an identical file on every start would ask for a reload for no reason.\n if ((await readFile(target, \"utf8\").catch(() => null)) === contents) return;\n\n await mkdir(dirname(target), { recursive: true });\n await writeFile(target, contents, \"utf8\");\n logger.info(`Wrote the content model to ${relative(process.cwd(), target)} — commit it for Mica to read.`);\n } catch (error) {\n // A site that builds correctly should not fail over this, and a read-only\n // checkout is a normal way to build one in CI. Mica reports a missing model\n // itself, and far more usefully.\n logger.warn(`Could not write the content model: ${describe(error)}`);\n }\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Reports a read failure against the content root that produced it.\n *\n * A build failing on unreadable content is correct — publishing a half-empty\n * site would be worse — but the message has to name the directory that was\n * read, because the usual cause is a content root pointing at the wrong place.\n */\nasync function guard<T>(\n context: LoaderContext,\n source: ContentSource,\n read: () => Promise<T>,\n): Promise<T> {\n try {\n return await read();\n } catch (error) {\n const detail = error instanceof Error ? error.message : String(error);\n context.logger.error(detail);\n\n throw new Error(`Could not read content from ${source.origin}.\\n ${detail}`, { cause: error });\n }\n}\n\n/* ------------------------------------------------------------------ *\n * Types for the website\n * ------------------------------------------------------------------ */\n\n/** The document type for one collection, as it appears in Astro. */\nexport type CollectionDocument<\n S extends Site,\n K extends keyof S[\"collections\"],\n> = AstroDocument<InferCollection<S[\"collections\"][K]>>;\n\n/** The document type for one singleton. */\nexport type SingletonDocument<\n S extends Site,\n K extends keyof S[\"singletons\"],\n> = InferSingleton<S[\"singletons\"][K]>;\n\nexport { manifestFor } from \"./manifest.js\";\nexport { DEFAULT_CONTENT_ROOT, resolveContentRoot };\nexport {\n renderRichText,\n transformRichText,\n validateRichText,\n type RichTextOptions,\n} from \"./richtext.js\";\n\n/**\n * Re-exported for convenience, so a project that only depends on this package\n * can still build a source or read a collection. They are defined in\n * `@micacms/core`, where the Mica server can reach them too.\n */\nexport {\n ContentReadError,\n ContentSourceError,\n ORDER_FILE,\n memorySource,\n readCollection,\n readSingleton,\n type ContentSource,\n type DirectoryEntry,\n} from \"@micacms/core\";\nexport { fileSystemSource } from \"@micacms/core/node\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @micacms/astro — a Git repository as an Astro content source.\n *\n * // src/content.config.ts — the model is in src/mica.ts\n * import { micaCollections } from \"@micacms/astro\";\n * import { site } from \"./mica\";\n *\n * export const collections = micaCollections(site);\n *\n * From there the website uses ordinary Astro APIs — `getCollection(\"projects\")`\n * — and knows nothing about how the content arrived.\n *\n * There is no CMS in this path. The loaders read files that are already in the\n * working tree, so a build needs a checkout and nothing else: no running\n * server, no token, no network. That is the point of the Git-native design.\n * Publishing is a commit, and CI builds the commit.\n */\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join, relative, sep } from \"node:path\";\n\nimport {\n createReferenceCheck,\n fieldsSchema,\n isFieldKind,\n MANIFEST_PATH,\n readCollection,\n readSingleton,\n type Collection,\n type ContentSource,\n type FieldMap,\n type InferCollection,\n type InferSingleton,\n type Singleton,\n type Site,\n} from \"@micacms/core\";\nimport { fileSystemSource } from \"@micacms/core/node\";\nimport { z } from \"zod\";\n\nimport { manifestFor } from \"./manifest.js\";\nimport { DEFAULT_CONTENT_ROOT, resolveContentRoot } from \"./root.js\";\n\nexport interface MicaCollectionsOptions {\n /**\n * Overrides `defineSite({ root })`, which is where the content root belongs.\n *\n * Relative paths resolve against the Astro project root, like Astro's own\n * `glob({ base })`. An absolute path is used as given — which is what a\n * project whose content is a separate repository, checked out by CI\n * somewhere else, wants.\n */\n root?: string;\n /** Overrides `root` entirely. Used by tests, and by a non-filesystem source. */\n source?: ContentSource;\n /**\n * Turns off publishing the model to `<root>/.mica/schema.json`.\n *\n * Only for a project that is not edited through Mica at all: without that\n * file in the repository, Mica has no model and refuses to start.\n */\n publishModel?: false;\n /**\n * How long to wait after a content change before reloading, in dev.\n *\n * Saving one entry touches several files — `index.json` and every `.mdoc`\n * beside it — and a Git checkout touches all of them at once. One reload\n * after the burst is both faster and less confusing than one per file.\n */\n debounceMs?: number;\n /**\n * Fails the build on a relation to a missing entry or a missing media file.\n * Without it, the build logs a warning for each and goes on.\n */\n strict?: boolean;\n}\n\n/**\n * Entry metadata attached to every collection document.\n *\n * No timestamps: in Git they are commit metadata, which a static build cannot\n * read cheaply. A date an editor cares about belongs in the model as a `date()` field.\n *\n * Namespaced because these keys share a namespace with the customer's own\n * fields, and `position` is a plausible field name.\n */\nexport interface MicaMeta {\n /** The entry slug, which is its identity in the repository. */\n id: string;\n slug: string;\n collection: string;\n /** Index in the collection's order, manual when `_order.json` says so. */\n position: number;\n}\n\nconst metaSchema = z.object({\n id: z.string(),\n slug: z.string(),\n collection: z.string(),\n position: z.number(),\n});\n\n/** What a document looks like inside Astro: the payload plus `_mica`. */\nexport type AstroDocument<T> = T & { _mica: MicaMeta };\n\n/**\n * The shape Astro's config loader expects.\n *\n * Deliberately not built with `defineCollection` from `astro:content`: that is a\n * virtual module, resolvable only inside an Astro app's module graph, and this\n * package is pre-compiled. `defineCollection` does nothing more than stamp this\n * `type`, so producing the object directly keeps the package importable\n * anywhere — including from a plain unit test.\n */\nexport interface AstroCollectionConfig<T = unknown> {\n type: \"content_layer\";\n loader: ContentLoader;\n /**\n * Typed as `ZodType<T>` rather than a bare `ZodObject`, because Astro derives\n * `CollectionEntry<\"projects\">[\"data\"]` from `z.infer` of whatever this is.\n * Left generic, every field in every template would degrade to `any` — and\n * the point of the shared model is that renaming a field breaks the build.\n */\n schema: z.ZodType<T>;\n}\n\n/** The subset of Astro's `Loader` contract this package uses. */\nexport interface ContentLoader {\n name: string;\n load(context: LoaderContext): Promise<void>;\n}\n\nexport interface LoaderContext {\n store: {\n clear(): void;\n set(entry: { id: string; data: Record<string, unknown>; digest?: string }): boolean;\n keys(): string[];\n };\n logger: {\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n debug(message: string): void;\n };\n meta: { get(key: string): string | undefined; set(key: string, value: string): void };\n /**\n * Astro's resolved config. Optional because this interface is a hand-written\n * subset and the tests build a context by hand; `config.root` is what turns a\n * relative content root into a path.\n */\n config?: { root: URL };\n /**\n * Present only while `astro dev` runs. Astro's watcher follows the module\n * graph, and content files are not in it — no page imports\n * `projects/haus-a/index.json` — so each loader watches its own directory\n * here, exactly as Astro's own `glob` loader does.\n */\n watcher?: {\n add(path: string): void;\n on(event: \"add\" | \"change\" | \"unlink\", handler: (path: string) => void): void;\n };\n parseData(input: {\n id: string;\n data: Record<string, unknown>;\n }): Promise<Record<string, unknown>>;\n generateDigest(data: Record<string, unknown> | string): string;\n}\n\nexport type MicaCollections<S extends Site> = {\n [K in keyof S[\"collections\"]]: AstroCollectionConfig<\n AstroDocument<InferCollection<S[\"collections\"][K]>>\n >;\n} & {\n [K in keyof S[\"singletons\"]]: AstroCollectionConfig<InferSingleton<S[\"singletons\"][K]>>;\n};\n\nexport function micaCollections<S extends Site>(\n site: S,\n options: MicaCollectionsOptions = {},\n): MicaCollections<S> {\n assertKnownKinds(site);\n\n /**\n * Everything below is resolved on first load rather than here, because the\n * Astro project root is something only a loader is told — and it is what\n * turns `./content` into a directory. Memoised: one root and one source,\n * however many collections and however many reloads.\n */\n let root: string | undefined;\n let source: ContentSource | undefined;\n\n const rootFor = (context: LoaderContext): string =>\n (root ??= resolveContentRoot(options.root ?? site.root, context.config?.root));\n\n const sourceFor = (context: LoaderContext): ContentSource =>\n (source ??= options.source ?? fileSystemSource(rootFor(context)));\n\n /** Published once per process; a model change restarts the dev server. */\n let publishing: Promise<void> | undefined;\n\n const publish = (context: LoaderContext): Promise<void> =>\n (publishing ??=\n options.publishModel === false || options.source\n ? Promise.resolve()\n : publishModel(site, rootFor(context), context.logger));\n\n /** Warns about broken relations and missing files, or fails the build with `strict`. */\n const checkReferences = async (\n context: LoaderContext,\n source: ContentSource,\n documents: Array<{ label: string; fields: FieldMap; data: Record<string, unknown> }>,\n ): Promise<void> => {\n const check = createReferenceCheck(site, source);\n const problems: string[] = [];\n for (const { label, fields, data } of documents) {\n for (const issue of await check.issues(fields, data)) problems.push(`${label}: ${issue.path}: ${issue.message}`);\n }\n if (problems.length === 0) return;\n if (options.strict) throw new Error(`Broken references:\\n ${problems.join(\"\\n \")}`);\n for (const problem of problems) context.logger.warn(problem);\n };\n\n /** Collections whose watcher is already registered, so a reload adds none. */\n const watching = new Set<string>();\n\n const watch = (context: LoaderContext, name: string, reload: () => Promise<void>): void => {\n const watcher = context.watcher;\n if (!watcher || options.source || watching.has(name)) return;\n watching.add(name);\n\n const base = rootFor(context);\n watcher.add(base);\n\n // A collection lives in `<root>/<name>/`; a singleton is either\n // `<root>/<name>.json` or `<root>/<name>/…`. Both are covered by this\n // prefix, and nothing outside it can concern this loader — which is what\n // keeps one collection's save from re-reading every other one.\n const prefix = join(base, name);\n const mine = (path: string): boolean =>\n path === `${prefix}.json` || path.startsWith(prefix + sep);\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const onChange = (path: string): void => {\n // The manifest is this package's own output; reloading content because\n // the model was written is a loop waiting to happen.\n if (path.endsWith(MANIFEST_PATH) || !mine(path)) return;\n\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => {\n // Content that fails its own schema lands here. The dev server has to\n // survive it: the next save is very likely the fix.\n void reload().catch((error: unknown) => {\n context.logger.error(`Could not reload content: ${describe(error)}`);\n });\n }, options.debounceMs ?? 100);\n // A pending reload is never a reason to keep the process alive.\n timer.unref?.();\n };\n\n watcher.on(\"change\", onChange);\n watcher.on(\"add\", onChange);\n watcher.on(\"unlink\", onChange);\n };\n\n const collections = {} as Record<string, AstroCollectionConfig<unknown>>;\n\n for (const [name, def] of Object.entries(site.collections) as [string, Collection][]) {\n collections[name] = {\n type: \"content_layer\",\n schema: fieldsSchema(def.fields).extend({ _mica: metaSchema }),\n loader: {\n name: `mica:${name}`,\n load: async (context) => {\n const read = async (): Promise<void> => {\n const source = sourceFor(context);\n const entries = await guard(context, source, () => readCollection(source, def, name));\n\n // A full replace, not a merge: an entry deleted from the repository\n // has to disappear from the build.\n context.store.clear();\n\n for (const entry of entries) {\n const meta: MicaMeta = {\n id: entry.slug,\n slug: entry.slug,\n collection: name,\n position: entry.position,\n };\n\n const data = await context.parseData({\n id: entry.slug,\n data: { ...entry.data, _mica: meta },\n });\n context.store.set({ id: entry.slug, data, digest: context.generateDigest(data) });\n }\n\n await checkReferences(\n context,\n source,\n entries.map((entry) => ({ label: `${name}/${entry.slug}`, fields: def.fields, data: entry.data })),\n );\n context.logger.info(`Loaded ${entries.length} ${entries.length === 1 ? \"entry\" : \"entries\"} of \"${def.label}\"`);\n };\n\n await publish(context);\n await read();\n watch(context, name, read);\n },\n },\n };\n }\n\n // A singleton becomes a one-entry collection whose id is its name, so the\n // website reaches it with the same `getEntry` it uses for everything else.\n for (const [name, def] of Object.entries(site.singletons) as [string, Singleton][]) {\n collections[name] = {\n type: \"content_layer\",\n schema: fieldsSchema(def.fields),\n loader: {\n name: `mica:${name}`,\n load: async (context) => {\n const read = async (): Promise<void> => {\n const source = sourceFor(context);\n const data = await guard(context, source, () => readSingleton(source, def, name));\n\n context.store.clear();\n if (data === null) {\n // Not fatal. A site is normally deployed before every settings\n // form has been filled in, and the page that reads this singleton\n // reports the absence far better than the loader can.\n context.logger.warn(`No content for \"${def.label}\" yet.`);\n return;\n }\n\n await checkReferences(context, source, [{ label: name, fields: def.fields, data }]);\n const parsed = await context.parseData({ id: name, data });\n context.store.set({ id: name, data: parsed, digest: context.generateDigest(parsed) });\n };\n\n await publish(context);\n await read();\n watch(context, name, read);\n },\n },\n };\n }\n\n return collections as MicaCollections<S>;\n}\n\n/**\n * The site's own model must use only kinds this package knows. Mica reads an\n * unknown kind as one from a newer version and makes its collection read-only,\n * so a typo would otherwise pass the build and lock the collection.\n */\nfunction assertKnownKinds(site: Site): void {\n const check = (fields: FieldMap, at: string) => {\n for (const [key, field] of Object.entries(fields)) {\n if (!isFieldKind(field.kind)) {\n throw new Error(`Field \"${at}.${key}\" has the unknown kind \"${String(field.kind)}\". Use the field functions from @micacms/core.`);\n }\n if (field.kind === \"object\" || field.kind === \"repeater\") check(field.fields, `${at}.${key}`);\n }\n };\n for (const [name, def] of Object.entries(site.collections)) check(def.fields, `collections.${name}`);\n for (const [name, def] of Object.entries(site.singletons)) check(def.fields, `singletons.${name}`);\n}\n\n/**\n * Writes the model into the content root, for Mica to read back.\n *\n * Mica holds no content model: the Astro project is the only place a collection\n * is defined, and this is how it says so. The file goes *inside the content\n * root* so that it travels to Mica the way content does — through Git and the\n * repository connector — with nothing to mount and no second channel to keep in\n * step. Commit it.\n *\n * Done from a loader rather than an integration because this is the moment both\n * facts are in hand: the model, and the project root that turns `./content`\n * into a directory. It also means `astro.config` needs no Mica in it, which is\n * what lets a project define Mica collections and ordinary Astro ones in the\n * same `content.config.ts`.\n */\nasync function publishModel(\n site: Site,\n root: string,\n logger: LoaderContext[\"logger\"],\n): Promise<void> {\n const target = join(root, MANIFEST_PATH);\n const contents = `${JSON.stringify(manifestFor(site), null, 2)}\\n`;\n\n try {\n // Only when it differs. The content root is watched in dev, and rewriting\n // an identical file on every start would ask for a reload for no reason.\n if ((await readFile(target, \"utf8\").catch(() => null)) === contents) return;\n\n await mkdir(dirname(target), { recursive: true });\n await writeFile(target, contents, \"utf8\");\n logger.info(`Wrote the content model to ${relative(process.cwd(), target)} — commit it for Mica to read.`);\n } catch (error) {\n // A site that builds correctly should not fail over this, and a read-only\n // checkout is a normal way to build one in CI. Mica reports a missing model\n // itself, and far more usefully.\n logger.warn(`Could not write the content model: ${describe(error)}`);\n }\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Reports a read failure against the content root that produced it.\n *\n * A build failing on unreadable content is correct — publishing a half-empty\n * site would be worse — but the message has to name the directory that was\n * read, because the usual cause is a content root pointing at the wrong place.\n */\nasync function guard<T>(\n context: LoaderContext,\n source: ContentSource,\n read: () => Promise<T>,\n): Promise<T> {\n try {\n return await read();\n } catch (error) {\n const detail = error instanceof Error ? error.message : String(error);\n context.logger.error(detail);\n\n throw new Error(`Could not read content from ${source.origin}.\\n ${detail}`, { cause: error });\n }\n}\n\n/* ------------------------------------------------------------------ *\n * Types for the website\n * ------------------------------------------------------------------ */\n\n/** The document type for one collection, as it appears in Astro. */\nexport type CollectionDocument<\n S extends Site,\n K extends keyof S[\"collections\"],\n> = AstroDocument<InferCollection<S[\"collections\"][K]>>;\n\n/** The document type for one singleton. */\nexport type SingletonDocument<\n S extends Site,\n K extends keyof S[\"singletons\"],\n> = InferSingleton<S[\"singletons\"][K]>;\n\nexport { manifestFor } from \"./manifest.js\";\nexport { DEFAULT_CONTENT_ROOT, resolveContentRoot };\nexport {\n renderRichText,\n transformRichText,\n validateRichText,\n type RichTextOptions,\n} from \"./richtext.js\";\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAkB,KAAK,gBAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,eAAe,CAAC;AAKjF;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,gBAAgB,CAExD"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAkB,KAAK,gBAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,eAAe,CAAC;AAEjF;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,gBAAgB,CAExD"}
package/dist/manifest.js CHANGED
@@ -7,13 +7,11 @@
7
7
  * import each other.
8
8
  */
9
9
  import { createManifest } from "@micacms/core";
10
- /** Replaced at publish time; the fallback keeps a workspace build honest. */
11
- const PACKAGE_VERSION = "0.0.1";
12
10
  /**
13
11
  * Mica holds no model of its own. The Astro project is the only place a
14
12
  * collection is defined, and this is how it says so.
15
13
  */
16
14
  export function manifestFor(site) {
17
- return createManifest(site, `@micacms/astro@${PACKAGE_VERSION}`);
15
+ return createManifest(site, "@micacms/astro");
18
16
  }
19
17
  //# sourceMappingURL=manifest.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,cAAc,EAAoC,MAAM,eAAe,CAAC;AAEjF,6EAA6E;AAC7E,MAAM,eAAe,GAAG,OAAO,CAAC;AAEhC;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,IAAU;IACpC,OAAO,cAAc,CAAC,IAAI,EAAE,kBAAkB,eAAe,EAAE,CAAC,CAAC;AACnE,CAAC","sourcesContent":["/**\n * The protocol manifest this project publishes.\n *\n * Its own module rather than a function in `index.ts`, so that the integration\n * can emit a manifest without importing the loaders, and the loaders can be\n * imported without pulling in the integration. The two files would otherwise\n * import each other.\n */\n\nimport { createManifest, type ProtocolManifest, type Site } from \"@micacms/core\";\n\n/** Replaced at publish time; the fallback keeps a workspace build honest. */\nconst PACKAGE_VERSION = \"0.0.1\";\n\n/**\n * Mica holds no model of its own. The Astro project is the only place a\n * collection is defined, and this is how it says so.\n */\nexport function manifestFor(site: Site): ProtocolManifest {\n return createManifest(site, `@micacms/astro@${PACKAGE_VERSION}`);\n}\n"]}
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,cAAc,EAAoC,MAAM,eAAe,CAAC;AAEjF;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,IAAU;IACpC,OAAO,cAAc,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;AAChD,CAAC","sourcesContent":["/**\n * The protocol manifest this project publishes.\n *\n * Its own module rather than a function in `index.ts`, so that the integration\n * can emit a manifest without importing the loaders, and the loaders can be\n * imported without pulling in the integration. The two files would otherwise\n * import each other.\n */\n\nimport { createManifest, type ProtocolManifest, type Site } from \"@micacms/core\";\n\n/**\n * Mica holds no model of its own. The Astro project is the only place a\n * collection is defined, and this is how it says so.\n */\nexport function manifestFor(site: Site): ProtocolManifest {\n return createManifest(site, \"@micacms/astro\");\n}\n"]}
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "@micacms/astro",
3
- "version": "0.5.0-rc.2",
3
+ "version": "0.6.0-rc.1",
4
4
  "type": "module",
5
5
  "description": "Reads a Mica content repository as Astro Content Collections via the Content Layer API.",
6
6
  "license": "MIT",
7
+ "homepage": "https://micacms.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/micacms/mica.git",
11
+ "directory": "packages/astro"
12
+ },
13
+ "bugs": "https://github.com/micacms/mica/issues",
14
+ "engines": {
15
+ "node": ">=24"
16
+ },
7
17
  "author": "Alexander Müdespacher",
8
18
  "keywords": [
9
19
  "mica",
@@ -25,20 +35,23 @@
25
35
  },
26
36
  "files": [
27
37
  "dist",
28
- "setup"
38
+ "setup",
39
+ "src"
29
40
  ],
30
41
  "dependencies": {
31
- "@markdoc/markdoc": "^0.5.9",
32
- "zod": "^4.3.6",
33
- "@micacms/core": "0.5.0-rc.2"
42
+ "@markdoc/markdoc": "^0.5.9"
34
43
  },
35
44
  "peerDependencies": {
36
- "astro": "^6.0.0 || ^7.0.0"
45
+ "astro": "^7.0.0",
46
+ "zod": "^4.3.6",
47
+ "@micacms/core": "^0.6.0-rc.1"
37
48
  },
38
49
  "devDependencies": {
39
50
  "@types/node": "^22.20.1",
40
51
  "astro": "^7.2.2",
41
- "typescript": "^5.7.3"
52
+ "typescript": "^5.7.3",
53
+ "zod": "^4.3.6",
54
+ "@micacms/core": "0.6.0-rc.1"
42
55
  },
43
56
  "scripts": {
44
57
  "build": "tsc -p tsconfig.json",
@@ -79,7 +79,7 @@ Wait for the answers.
79
79
  | `z.date()`, `z.coerce.date()` | `date()` (stored as `"YYYY-MM-DD"`) |
80
80
  | `z.enum([...])` | `select({ options })` |
81
81
  | `z.array(z.enum([...]))` | `select({ multiple: true, options })` |
82
- | `image()` | `media({ accept: "image" })` |
82
+ | `image()` | `media({ accept: "image", alt: "required" })`, or `media({ accept: "image" })` for a decorative image |
83
83
  | `reference("authors")` | `relation({ collection: "authors" })` |
84
84
  | `z.object({...})` | `object({ fields })` |
85
85
  | `z.array(z.object({...}))` | `repeater({ fields })` |
@@ -161,9 +161,10 @@ endpoints such as `rss.xml.js`. See
161
161
  strings gives `NaN`, and the list is then not sorted, with no error.
162
162
  - Where an API needs a `Date`, such as the RSS `pubDate`, pass
163
163
  `new Date(entry.data.pubDate)`.
164
- - An image was `ImageMetadata` and is now a path from the content root. Create
165
- `src/lib/media.ts` from [reference.md](./reference.md#images), and use
166
- `mediaImage()`.
164
+ - An image was `ImageMetadata` and is now `{ path, alt }` (or a path, without
165
+ `alt`). Create `src/lib/media.ts` from [reference.md](./reference.md#images),
166
+ and use `mediaImage(value.path)`. Move alt text from the old frontmatter, such
167
+ as `coverAlt`, into `alt`.
167
168
  - A reference was `{ collection, id }` and is now a slug:
168
169
  `getEntry("authors", entry.data.author)`.
169
170
  - `entry.id` is the slug. If ids had folders, update the links, and add
package/setup/finish.md CHANGED
@@ -1,22 +1,39 @@
1
1
  # Finish the setup
2
2
 
3
- ## 1. Check
3
+ ## 1. Add scripts
4
+
5
+ Add two scripts to `package.json`, so that the user has short commands:
6
+
7
+ ```json
8
+ "mica": "mica",
9
+ "check": "mica check && astro check"
10
+ ```
11
+
12
+ - If the project has no `@astrojs/check`, use `"check": "mica check"`. Do not
13
+ install it only for this: without it, `astro check` asks to install it, and a
14
+ script cannot answer. Tell the user that nothing checks the types in
15
+ `.astro` files.
16
+ - If the project already has a `mica` script, keep it. If it has a `check`
17
+ script without `mica check`, ask the user whether to add `mica check &&` in
18
+ front of it. If the user says no, use `npx mica check` in place of
19
+ `npm run check` in the steps below and in the notes.
20
+
21
+ ## 2. Check
4
22
 
5
23
  All three must pass:
6
24
 
7
25
  ```bash
8
26
  npx astro sync
9
- npx mica check # 0 errors
27
+ npm run check # mica check: 0 errors; then astro check, if it is there
10
28
  npx astro build
11
29
  ```
12
30
 
13
- If the project has `@astrojs/check`, run `npx astro check` too. Do not install
14
- it only for this. Without it, nothing checks the types in `.astro` files: tell
15
- the user.
31
+ If `astro check` reports errors that were there before you started, tell the
32
+ user. Do not fix them unless the user asks.
16
33
 
17
34
  Explain each warning of `mica check` to the user, or fix it.
18
35
 
19
- ## 2. Add notes for the next agent
36
+ ## 3. Add notes for the next agent
20
37
 
21
38
  Add this section to the project's agent instructions: `AGENTS.md`,
22
39
  `CLAUDE.md` or the file the project already uses. If `CLAUDE.md` is a link to
@@ -27,23 +44,24 @@ paths if the project uses others.
27
44
  ## Content (Mica)
28
45
 
29
46
  - The content model is `src/mica.ts`. The content is JSON and Markdoc in
30
- `content/`; editors change it with `npx mica`.
31
- - After a change to `src/mica.ts`, run `npx astro sync` and `npx mica check`,
47
+ `content/`; editors change it with `npm run mica`.
48
+ - After a change to `src/mica.ts`, run `npx astro sync` and `npm run check`,
32
49
  and commit `content/.mica/schema.json` with the change.
33
50
  - Renaming or removing a field or a collection does not move the stored
34
51
  content. Change the content files in the same commit, or ask first.
35
- - Before a commit that changes `content/`, `npx mica check` must report 0 errors.
52
+ - Before a commit that changes `content/`, `npm run check` must pass.
36
53
  ```
37
54
 
38
- ## 3. Tell the user
55
+ ## 4. Tell the user
39
56
 
40
57
  - What you added and changed, file by file.
41
58
  - The decisions they made, and what was not converted, if anything.
42
59
  - What to commit: every file that `git status` shows as new or changed,
43
60
  including `content/.mica/schema.json`, `package.json` and the lock file. Do
44
61
  not commit for them unless they asked.
45
- - How to edit: `npx mica` opens the editor on this computer. A save writes
46
- files in `content/`; Publish commits them. `@micacms/editor` is a dev
62
+ - How to edit: `npm run mica` opens the editor on this computer. A save writes
63
+ files in `content/`; Publish commits them. `npm run check` checks the content
64
+ (and the types, if `@astrojs/check` is there). `@micacms/editor` is a dev
47
65
  dependency, so on another clone, run `npm install` first.
48
66
  - That a self-hosted editor for other people is described at
49
67
  https://micacms.com/docs/self-hosting/.
@@ -17,8 +17,9 @@ Ask these questions, and wait for the answers:
17
17
  3. Do entries refer to other entries? For example, an article has an author
18
18
  from the team.
19
19
  4. Should editors put a collection in a manual order?
20
- 5. Do images need alt text that editors write? A media field has none, so
21
- each image that carries meaning needs a `text()` field beside it.
20
+ 5. Which images carry meaning, and which are decorative? An image that
21
+ carries meaning gets `media({ alt: "required" })`, so that editors write
22
+ its alt text. A decorative image gets `media()`.
22
23
  6. Do they want example content, so that the pages show something at once?
23
24
 
24
25
  Suggest a model from the answers, as a short list of collections, singletons
@@ -9,6 +9,7 @@ The guides link here. Read the section that a step names.
9
9
  | `npx mica init` | Writes a starter `src/mica.ts` and `src/content.config.ts`. Never overwrites a file |
10
10
  | `npx astro sync` | Runs the Mica loaders, which write the model to `content/.mica/schema.json` |
11
11
  | `npx mica check` | Checks every entry against `content/.mica/schema.json`. Exit code 1 on an error |
12
+ | `npx mica migrate-layout` | Moves entries into the layout of the model, after the first or the last rich-text or media field of a collection was added or removed |
12
13
  | `npx mica` | Opens the editor in the browser. Only for the user; do not run it yourself |
13
14
 
14
15
  `mica check` reads the model from `content/.mica/schema.json`. After every
@@ -61,7 +62,7 @@ in the entry list), `readonly`, `hidden`, `group`, `width` (`"full"`, `"half"`,
61
62
 
62
63
  | Field | Stored as | Other options |
63
64
  | --- | --- | --- |
64
- | `text()` | string | `minLength`, `maxLength`, `pattern`, `placeholder`, `default` |
65
+ | `text()` | string | `minLength`, `maxLength`, `pattern` (must match the whole value), `placeholder`, `default` |
65
66
  | `textarea()` | string | `minLength`, `maxLength`, `rows`, `placeholder`, `default` |
66
67
  | `richText()` | Markdoc in its own `.mdoc` file | `toolbar`, `maxLength`, `default` |
67
68
  | `number()` | number | `min`, `max`, `step`, `integer`, `unit`, `default` |
@@ -74,7 +75,7 @@ in the entry list), `readonly`, `hidden`, `group`, `width` (`"full"`, `"half"`,
74
75
  | `object()` | object | `fields` (required), `collapsible`, `collapsed` |
75
76
  | `repeater()` | array of objects | `fields` (required), `min`, `max`, `itemLabel`, `addLabel` |
76
77
  | `relation()` | the slug of an entry | `collection` (required), `multiple` |
77
- | `media()` | a file path | `accept: "image" \| "document" \| "any"`, `multiple`, `max` |
78
+ | `media()` | a file path, or `{ path, alt }` with `alt` | `accept: "image" \| "document" \| "any"`, `multiple`, `max`, `alt: "required" \| "optional"` |
78
79
  | `color()` | `"#rrggbb"` | `presets`, `default` |
79
80
  | `coordinates()` | `{ "lat": 47.37, "lng": 8.54 }` | `defaultCenter`, `defaultZoom` |
80
81
 
@@ -152,12 +153,13 @@ export async function writeOrder(collection, slugs) {
152
153
  await writeFile(join(ROOT, collection, "_order.json"), `${JSON.stringify(slugs, null, 2)}\n`);
153
154
  }
154
155
 
155
- // Example: `data` has every field, rich text as a Markdoc string, media as `media/<file>`.
156
+ // Example: `data` has every field, rich text as a Markdoc string, and media as
157
+ // `media/<file>`, or `{ path, alt }` for a `media({ alt })` field.
156
158
  // await writeEntry("news", "hello-world", {
157
159
  // title: "Hello, world",
158
160
  // slug: "hello-world",
159
161
  // publishedOn: "2026-01-15",
160
- // cover: "media/cover.jpg",
162
+ // cover: { path: "media/cover.jpg", alt: "The team at the opening" },
161
163
  // body: "The first article.",
162
164
  // });
163
165
  ```
@@ -229,16 +231,16 @@ export function mediaImage(path: string | null | undefined): ImageMetadata | und
229
231
  ---
230
232
  import { Image } from "astro:assets";
231
233
  import { mediaImage } from "../../lib/media";
232
- const cover = mediaImage(article.data.cover);
234
+ const cover = article.data.cover; // media({ alt: "required" }): { path, alt }
235
+ const image = mediaImage(cover?.path);
233
236
  ---
234
- {cover && <Image src={cover} alt={article.data.title} />}
237
+ {image && <Image src={image} alt={cover.alt} />}
235
238
  ```
236
239
 
237
- A `media({ multiple: true })` field is an array of paths:
238
- `project.data.gallery.map(mediaImage)`.
239
-
240
- A media field has no alt text. Add a `text()` field for it where the image
241
- carries meaning. For documents such as PDF files, use
240
+ A `media({ multiple: true })` field is an array:
241
+ `project.data.gallery.map((item) => mediaImage(item.path))`. A `media()`
242
+ without `alt` is a path only: use it for a decorative image, with `alt=""`. For
243
+ documents such as PDF files, use
242
244
  `import.meta.glob("../../content/**/*.pdf", { query: "?url", import: "default", eager: true })`
243
245
  and link to the URL.
244
246
 
package/src/index.ts ADDED
@@ -0,0 +1,457 @@
1
+ /**
2
+ * @micacms/astro — a Git repository as an Astro content source.
3
+ *
4
+ * // src/content.config.ts — the model is in src/mica.ts
5
+ * import { micaCollections } from "@micacms/astro";
6
+ * import { site } from "./mica";
7
+ *
8
+ * export const collections = micaCollections(site);
9
+ *
10
+ * From there the website uses ordinary Astro APIs — `getCollection("projects")`
11
+ * — and knows nothing about how the content arrived.
12
+ *
13
+ * There is no CMS in this path. The loaders read files that are already in the
14
+ * working tree, so a build needs a checkout and nothing else: no running
15
+ * server, no token, no network. That is the point of the Git-native design.
16
+ * Publishing is a commit, and CI builds the commit.
17
+ */
18
+
19
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
20
+ import { dirname, join, relative, sep } from "node:path";
21
+
22
+ import {
23
+ createReferenceCheck,
24
+ fieldsSchema,
25
+ isFieldKind,
26
+ MANIFEST_PATH,
27
+ readCollection,
28
+ readSingleton,
29
+ type Collection,
30
+ type ContentSource,
31
+ type FieldMap,
32
+ type InferCollection,
33
+ type InferSingleton,
34
+ type Singleton,
35
+ type Site,
36
+ } from "@micacms/core";
37
+ import { fileSystemSource } from "@micacms/core/node";
38
+ import { z } from "zod";
39
+
40
+ import { manifestFor } from "./manifest.js";
41
+ import { DEFAULT_CONTENT_ROOT, resolveContentRoot } from "./root.js";
42
+
43
+ export interface MicaCollectionsOptions {
44
+ /**
45
+ * Overrides `defineSite({ root })`, which is where the content root belongs.
46
+ *
47
+ * Relative paths resolve against the Astro project root, like Astro's own
48
+ * `glob({ base })`. An absolute path is used as given — which is what a
49
+ * project whose content is a separate repository, checked out by CI
50
+ * somewhere else, wants.
51
+ */
52
+ root?: string;
53
+ /** Overrides `root` entirely. Used by tests, and by a non-filesystem source. */
54
+ source?: ContentSource;
55
+ /**
56
+ * Turns off publishing the model to `<root>/.mica/schema.json`.
57
+ *
58
+ * Only for a project that is not edited through Mica at all: without that
59
+ * file in the repository, Mica has no model and refuses to start.
60
+ */
61
+ publishModel?: false;
62
+ /**
63
+ * How long to wait after a content change before reloading, in dev.
64
+ *
65
+ * Saving one entry touches several files — `index.json` and every `.mdoc`
66
+ * beside it — and a Git checkout touches all of them at once. One reload
67
+ * after the burst is both faster and less confusing than one per file.
68
+ */
69
+ debounceMs?: number;
70
+ /**
71
+ * Fails the build on a relation to a missing entry or a missing media file.
72
+ * Without it, the build logs a warning for each and goes on.
73
+ */
74
+ strict?: boolean;
75
+ }
76
+
77
+ /**
78
+ * Entry metadata attached to every collection document.
79
+ *
80
+ * No timestamps: in Git they are commit metadata, which a static build cannot
81
+ * read cheaply. A date an editor cares about belongs in the model as a `date()` field.
82
+ *
83
+ * Namespaced because these keys share a namespace with the customer's own
84
+ * fields, and `position` is a plausible field name.
85
+ */
86
+ export interface MicaMeta {
87
+ /** The entry slug, which is its identity in the repository. */
88
+ id: string;
89
+ slug: string;
90
+ collection: string;
91
+ /** Index in the collection's order, manual when `_order.json` says so. */
92
+ position: number;
93
+ }
94
+
95
+ const metaSchema = z.object({
96
+ id: z.string(),
97
+ slug: z.string(),
98
+ collection: z.string(),
99
+ position: z.number(),
100
+ });
101
+
102
+ /** What a document looks like inside Astro: the payload plus `_mica`. */
103
+ export type AstroDocument<T> = T & { _mica: MicaMeta };
104
+
105
+ /**
106
+ * The shape Astro's config loader expects.
107
+ *
108
+ * Deliberately not built with `defineCollection` from `astro:content`: that is a
109
+ * virtual module, resolvable only inside an Astro app's module graph, and this
110
+ * package is pre-compiled. `defineCollection` does nothing more than stamp this
111
+ * `type`, so producing the object directly keeps the package importable
112
+ * anywhere — including from a plain unit test.
113
+ */
114
+ export interface AstroCollectionConfig<T = unknown> {
115
+ type: "content_layer";
116
+ loader: ContentLoader;
117
+ /**
118
+ * Typed as `ZodType<T>` rather than a bare `ZodObject`, because Astro derives
119
+ * `CollectionEntry<"projects">["data"]` from `z.infer` of whatever this is.
120
+ * Left generic, every field in every template would degrade to `any` — and
121
+ * the point of the shared model is that renaming a field breaks the build.
122
+ */
123
+ schema: z.ZodType<T>;
124
+ }
125
+
126
+ /** The subset of Astro's `Loader` contract this package uses. */
127
+ export interface ContentLoader {
128
+ name: string;
129
+ load(context: LoaderContext): Promise<void>;
130
+ }
131
+
132
+ export interface LoaderContext {
133
+ store: {
134
+ clear(): void;
135
+ set(entry: { id: string; data: Record<string, unknown>; digest?: string }): boolean;
136
+ keys(): string[];
137
+ };
138
+ logger: {
139
+ info(message: string): void;
140
+ warn(message: string): void;
141
+ error(message: string): void;
142
+ debug(message: string): void;
143
+ };
144
+ meta: { get(key: string): string | undefined; set(key: string, value: string): void };
145
+ /**
146
+ * Astro's resolved config. Optional because this interface is a hand-written
147
+ * subset and the tests build a context by hand; `config.root` is what turns a
148
+ * relative content root into a path.
149
+ */
150
+ config?: { root: URL };
151
+ /**
152
+ * Present only while `astro dev` runs. Astro's watcher follows the module
153
+ * graph, and content files are not in it — no page imports
154
+ * `projects/haus-a/index.json` — so each loader watches its own directory
155
+ * here, exactly as Astro's own `glob` loader does.
156
+ */
157
+ watcher?: {
158
+ add(path: string): void;
159
+ on(event: "add" | "change" | "unlink", handler: (path: string) => void): void;
160
+ };
161
+ parseData(input: {
162
+ id: string;
163
+ data: Record<string, unknown>;
164
+ }): Promise<Record<string, unknown>>;
165
+ generateDigest(data: Record<string, unknown> | string): string;
166
+ }
167
+
168
+ export type MicaCollections<S extends Site> = {
169
+ [K in keyof S["collections"]]: AstroCollectionConfig<
170
+ AstroDocument<InferCollection<S["collections"][K]>>
171
+ >;
172
+ } & {
173
+ [K in keyof S["singletons"]]: AstroCollectionConfig<InferSingleton<S["singletons"][K]>>;
174
+ };
175
+
176
+ export function micaCollections<S extends Site>(
177
+ site: S,
178
+ options: MicaCollectionsOptions = {},
179
+ ): MicaCollections<S> {
180
+ assertKnownKinds(site);
181
+
182
+ /**
183
+ * Everything below is resolved on first load rather than here, because the
184
+ * Astro project root is something only a loader is told — and it is what
185
+ * turns `./content` into a directory. Memoised: one root and one source,
186
+ * however many collections and however many reloads.
187
+ */
188
+ let root: string | undefined;
189
+ let source: ContentSource | undefined;
190
+
191
+ const rootFor = (context: LoaderContext): string =>
192
+ (root ??= resolveContentRoot(options.root ?? site.root, context.config?.root));
193
+
194
+ const sourceFor = (context: LoaderContext): ContentSource =>
195
+ (source ??= options.source ?? fileSystemSource(rootFor(context)));
196
+
197
+ /** Published once per process; a model change restarts the dev server. */
198
+ let publishing: Promise<void> | undefined;
199
+
200
+ const publish = (context: LoaderContext): Promise<void> =>
201
+ (publishing ??=
202
+ options.publishModel === false || options.source
203
+ ? Promise.resolve()
204
+ : publishModel(site, rootFor(context), context.logger));
205
+
206
+ /** Warns about broken relations and missing files, or fails the build with `strict`. */
207
+ const checkReferences = async (
208
+ context: LoaderContext,
209
+ source: ContentSource,
210
+ documents: Array<{ label: string; fields: FieldMap; data: Record<string, unknown> }>,
211
+ ): Promise<void> => {
212
+ const check = createReferenceCheck(site, source);
213
+ const problems: string[] = [];
214
+ for (const { label, fields, data } of documents) {
215
+ for (const issue of await check.issues(fields, data)) problems.push(`${label}: ${issue.path}: ${issue.message}`);
216
+ }
217
+ if (problems.length === 0) return;
218
+ if (options.strict) throw new Error(`Broken references:\n ${problems.join("\n ")}`);
219
+ for (const problem of problems) context.logger.warn(problem);
220
+ };
221
+
222
+ /** Collections whose watcher is already registered, so a reload adds none. */
223
+ const watching = new Set<string>();
224
+
225
+ const watch = (context: LoaderContext, name: string, reload: () => Promise<void>): void => {
226
+ const watcher = context.watcher;
227
+ if (!watcher || options.source || watching.has(name)) return;
228
+ watching.add(name);
229
+
230
+ const base = rootFor(context);
231
+ watcher.add(base);
232
+
233
+ // A collection lives in `<root>/<name>/`; a singleton is either
234
+ // `<root>/<name>.json` or `<root>/<name>/…`. Both are covered by this
235
+ // prefix, and nothing outside it can concern this loader — which is what
236
+ // keeps one collection's save from re-reading every other one.
237
+ const prefix = join(base, name);
238
+ const mine = (path: string): boolean =>
239
+ path === `${prefix}.json` || path.startsWith(prefix + sep);
240
+
241
+ let timer: ReturnType<typeof setTimeout> | undefined;
242
+
243
+ const onChange = (path: string): void => {
244
+ // The manifest is this package's own output; reloading content because
245
+ // the model was written is a loop waiting to happen.
246
+ if (path.endsWith(MANIFEST_PATH) || !mine(path)) return;
247
+
248
+ if (timer) clearTimeout(timer);
249
+ timer = setTimeout(() => {
250
+ // Content that fails its own schema lands here. The dev server has to
251
+ // survive it: the next save is very likely the fix.
252
+ void reload().catch((error: unknown) => {
253
+ context.logger.error(`Could not reload content: ${describe(error)}`);
254
+ });
255
+ }, options.debounceMs ?? 100);
256
+ // A pending reload is never a reason to keep the process alive.
257
+ timer.unref?.();
258
+ };
259
+
260
+ watcher.on("change", onChange);
261
+ watcher.on("add", onChange);
262
+ watcher.on("unlink", onChange);
263
+ };
264
+
265
+ const collections = {} as Record<string, AstroCollectionConfig<unknown>>;
266
+
267
+ for (const [name, def] of Object.entries(site.collections) as [string, Collection][]) {
268
+ collections[name] = {
269
+ type: "content_layer",
270
+ schema: fieldsSchema(def.fields).extend({ _mica: metaSchema }),
271
+ loader: {
272
+ name: `mica:${name}`,
273
+ load: async (context) => {
274
+ const read = async (): Promise<void> => {
275
+ const source = sourceFor(context);
276
+ const entries = await guard(context, source, () => readCollection(source, def, name));
277
+
278
+ // A full replace, not a merge: an entry deleted from the repository
279
+ // has to disappear from the build.
280
+ context.store.clear();
281
+
282
+ for (const entry of entries) {
283
+ const meta: MicaMeta = {
284
+ id: entry.slug,
285
+ slug: entry.slug,
286
+ collection: name,
287
+ position: entry.position,
288
+ };
289
+
290
+ const data = await context.parseData({
291
+ id: entry.slug,
292
+ data: { ...entry.data, _mica: meta },
293
+ });
294
+ context.store.set({ id: entry.slug, data, digest: context.generateDigest(data) });
295
+ }
296
+
297
+ await checkReferences(
298
+ context,
299
+ source,
300
+ entries.map((entry) => ({ label: `${name}/${entry.slug}`, fields: def.fields, data: entry.data })),
301
+ );
302
+ context.logger.info(`Loaded ${entries.length} ${entries.length === 1 ? "entry" : "entries"} of "${def.label}"`);
303
+ };
304
+
305
+ await publish(context);
306
+ await read();
307
+ watch(context, name, read);
308
+ },
309
+ },
310
+ };
311
+ }
312
+
313
+ // A singleton becomes a one-entry collection whose id is its name, so the
314
+ // website reaches it with the same `getEntry` it uses for everything else.
315
+ for (const [name, def] of Object.entries(site.singletons) as [string, Singleton][]) {
316
+ collections[name] = {
317
+ type: "content_layer",
318
+ schema: fieldsSchema(def.fields),
319
+ loader: {
320
+ name: `mica:${name}`,
321
+ load: async (context) => {
322
+ const read = async (): Promise<void> => {
323
+ const source = sourceFor(context);
324
+ const data = await guard(context, source, () => readSingleton(source, def, name));
325
+
326
+ context.store.clear();
327
+ if (data === null) {
328
+ // Not fatal. A site is normally deployed before every settings
329
+ // form has been filled in, and the page that reads this singleton
330
+ // reports the absence far better than the loader can.
331
+ context.logger.warn(`No content for "${def.label}" yet.`);
332
+ return;
333
+ }
334
+
335
+ await checkReferences(context, source, [{ label: name, fields: def.fields, data }]);
336
+ const parsed = await context.parseData({ id: name, data });
337
+ context.store.set({ id: name, data: parsed, digest: context.generateDigest(parsed) });
338
+ };
339
+
340
+ await publish(context);
341
+ await read();
342
+ watch(context, name, read);
343
+ },
344
+ },
345
+ };
346
+ }
347
+
348
+ return collections as MicaCollections<S>;
349
+ }
350
+
351
+ /**
352
+ * The site's own model must use only kinds this package knows. Mica reads an
353
+ * unknown kind as one from a newer version and makes its collection read-only,
354
+ * so a typo would otherwise pass the build and lock the collection.
355
+ */
356
+ function assertKnownKinds(site: Site): void {
357
+ const check = (fields: FieldMap, at: string) => {
358
+ for (const [key, field] of Object.entries(fields)) {
359
+ if (!isFieldKind(field.kind)) {
360
+ throw new Error(`Field "${at}.${key}" has the unknown kind "${String(field.kind)}". Use the field functions from @micacms/core.`);
361
+ }
362
+ if (field.kind === "object" || field.kind === "repeater") check(field.fields, `${at}.${key}`);
363
+ }
364
+ };
365
+ for (const [name, def] of Object.entries(site.collections)) check(def.fields, `collections.${name}`);
366
+ for (const [name, def] of Object.entries(site.singletons)) check(def.fields, `singletons.${name}`);
367
+ }
368
+
369
+ /**
370
+ * Writes the model into the content root, for Mica to read back.
371
+ *
372
+ * Mica holds no content model: the Astro project is the only place a collection
373
+ * is defined, and this is how it says so. The file goes *inside the content
374
+ * root* so that it travels to Mica the way content does — through Git and the
375
+ * repository connector — with nothing to mount and no second channel to keep in
376
+ * step. Commit it.
377
+ *
378
+ * Done from a loader rather than an integration because this is the moment both
379
+ * facts are in hand: the model, and the project root that turns `./content`
380
+ * into a directory. It also means `astro.config` needs no Mica in it, which is
381
+ * what lets a project define Mica collections and ordinary Astro ones in the
382
+ * same `content.config.ts`.
383
+ */
384
+ async function publishModel(
385
+ site: Site,
386
+ root: string,
387
+ logger: LoaderContext["logger"],
388
+ ): Promise<void> {
389
+ const target = join(root, MANIFEST_PATH);
390
+ const contents = `${JSON.stringify(manifestFor(site), null, 2)}\n`;
391
+
392
+ try {
393
+ // Only when it differs. The content root is watched in dev, and rewriting
394
+ // an identical file on every start would ask for a reload for no reason.
395
+ if ((await readFile(target, "utf8").catch(() => null)) === contents) return;
396
+
397
+ await mkdir(dirname(target), { recursive: true });
398
+ await writeFile(target, contents, "utf8");
399
+ logger.info(`Wrote the content model to ${relative(process.cwd(), target)} — commit it for Mica to read.`);
400
+ } catch (error) {
401
+ // A site that builds correctly should not fail over this, and a read-only
402
+ // checkout is a normal way to build one in CI. Mica reports a missing model
403
+ // itself, and far more usefully.
404
+ logger.warn(`Could not write the content model: ${describe(error)}`);
405
+ }
406
+ }
407
+
408
+ function describe(error: unknown): string {
409
+ return error instanceof Error ? error.message : String(error);
410
+ }
411
+
412
+ /**
413
+ * Reports a read failure against the content root that produced it.
414
+ *
415
+ * A build failing on unreadable content is correct — publishing a half-empty
416
+ * site would be worse — but the message has to name the directory that was
417
+ * read, because the usual cause is a content root pointing at the wrong place.
418
+ */
419
+ async function guard<T>(
420
+ context: LoaderContext,
421
+ source: ContentSource,
422
+ read: () => Promise<T>,
423
+ ): Promise<T> {
424
+ try {
425
+ return await read();
426
+ } catch (error) {
427
+ const detail = error instanceof Error ? error.message : String(error);
428
+ context.logger.error(detail);
429
+
430
+ throw new Error(`Could not read content from ${source.origin}.\n ${detail}`, { cause: error });
431
+ }
432
+ }
433
+
434
+ /* ------------------------------------------------------------------ *
435
+ * Types for the website
436
+ * ------------------------------------------------------------------ */
437
+
438
+ /** The document type for one collection, as it appears in Astro. */
439
+ export type CollectionDocument<
440
+ S extends Site,
441
+ K extends keyof S["collections"],
442
+ > = AstroDocument<InferCollection<S["collections"][K]>>;
443
+
444
+ /** The document type for one singleton. */
445
+ export type SingletonDocument<
446
+ S extends Site,
447
+ K extends keyof S["singletons"],
448
+ > = InferSingleton<S["singletons"][K]>;
449
+
450
+ export { manifestFor } from "./manifest.js";
451
+ export { DEFAULT_CONTENT_ROOT, resolveContentRoot };
452
+ export {
453
+ renderRichText,
454
+ transformRichText,
455
+ validateRichText,
456
+ type RichTextOptions,
457
+ } from "./richtext.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The protocol manifest this project publishes.
3
+ *
4
+ * Its own module rather than a function in `index.ts`, so that the integration
5
+ * can emit a manifest without importing the loaders, and the loaders can be
6
+ * imported without pulling in the integration. The two files would otherwise
7
+ * import each other.
8
+ */
9
+
10
+ import { createManifest, type ProtocolManifest, type Site } from "@micacms/core";
11
+
12
+ /**
13
+ * Mica holds no model of its own. The Astro project is the only place a
14
+ * collection is defined, and this is how it says so.
15
+ */
16
+ export function manifestFor(site: Site): ProtocolManifest {
17
+ return createManifest(site, "@micacms/astro");
18
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Rendering a rich-text field.
3
+ *
4
+ * A `richText()` field is persisted as Markdoc, and this is what turns it into
5
+ * HTML at build time. It runs during `astro build` only; the deployed page is
6
+ * static, and no visitor's browser ever sees Markdoc.
7
+ *
8
+ * Markdoc rather than Markdown-with-HTML for one reason that matters: a Markdoc
9
+ * document can only produce what the configuration allows. An undeclared
10
+ * `{% tag %}` renders as nothing rather than as itself, and raw HTML is inert
11
+ * unless it is explicitly permitted — which it is not here. So the canonical
12
+ * content format cannot carry markup that escapes the site's own styling, and a
13
+ * compromised or careless editor session cannot inject script into a build.
14
+ */
15
+
16
+ import Markdoc, { type Config, type RenderableTreeNode } from "@markdoc/markdoc";
17
+
18
+ export interface RichTextOptions {
19
+ /**
20
+ * Extra Markdoc configuration: custom tags, nodes or variables.
21
+ *
22
+ * The place a project adds a callout or a gallery block without giving up the
23
+ * closed set that makes the format safe. A tag must be declared here to
24
+ * render at all.
25
+ */
26
+ config?: Config;
27
+ }
28
+
29
+ /**
30
+ * Markdoc source to an HTML string.
31
+ *
32
+ * Returns an empty string for empty input, so a caller can render the result
33
+ * directly without a null check ahead of it.
34
+ */
35
+ export function renderRichText(source: string | null | undefined, options: RichTextOptions = {}): string {
36
+ if (!source || source.trim() === "") return "";
37
+
38
+ const tree = transformRichText(source, options);
39
+ return Markdoc.renderers.html(tree);
40
+ }
41
+
42
+ /**
43
+ * The intermediate renderable tree, for a project that wants to render with
44
+ * its own components rather than to an HTML string.
45
+ */
46
+ export function transformRichText(
47
+ source: string,
48
+ options: RichTextOptions = {},
49
+ ): RenderableTreeNode {
50
+ return Markdoc.transform(Markdoc.parse(source), options.config ?? {});
51
+ }
52
+
53
+ /**
54
+ * Problems Markdoc found in a document, for a build that wants to be strict.
55
+ *
56
+ * Not called by `renderRichText`: a malformed heading should not stop a site
57
+ * from deploying, and Markdoc degrades to plain text rather than to nothing.
58
+ * Exposed so a project can decide otherwise.
59
+ */
60
+ export function validateRichText(source: string, options: RichTextOptions = {}) {
61
+ return Markdoc.validate(Markdoc.parse(source), options.config ?? {});
62
+ }
package/src/root.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where the content root is, said once.
3
+ *
4
+ * The collections, the dev integration and the manifest writer all have to
5
+ * agree on this directory, and a disagreement between them produces an empty
6
+ * build rather than an error — the worst kind of failure. So the project states
7
+ * it once, in `defineSite({ root })`, and everything here resolves that one
8
+ * string the same way.
9
+ *
10
+ * Resolved against the **Astro project root**, not the working directory, which
11
+ * is what Astro's own `glob({ base })` does. A relative root therefore means
12
+ * the same thing whether the build was started from the project, from a
13
+ * workspace root, or by an editor extension. Absolute paths are left alone, for
14
+ * content that lives outside the project.
15
+ */
16
+
17
+ import { isAbsolute, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ export const DEFAULT_CONTENT_ROOT = "./content";
21
+
22
+ export function resolveContentRoot(root: string | undefined, projectRoot?: URL): string {
23
+ const configured = root ?? DEFAULT_CONTENT_ROOT;
24
+
25
+ if (isAbsolute(configured)) return configured;
26
+
27
+ // Only outside a loader or a hook — a plain unit test, or an Astro version
28
+ // that stops passing its config. The working directory is the best guess
29
+ // left, and it is right whenever the build was started from the project.
30
+ if (!projectRoot) return resolve(configured);
31
+
32
+ // A base without a trailing slash would have its last segment replaced
33
+ // rather than appended to. Astro's `config.root` has one; nothing else is
34
+ // guaranteed to.
35
+ const base = projectRoot.href.endsWith("/") ? projectRoot.href : `${projectRoot.href}/`;
36
+ return fileURLToPath(new URL(configured, base));
37
+ }