@micacms/astro 0.5.0 → 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 +10 -4
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -13
- package/dist/index.js.map +1 -1
- package/package.json +20 -7
- package/setup/content-collections.md +5 -4
- package/setup/finish.md +30 -12
- package/setup/new-project.md +3 -2
- package/setup/reference.md +13 -11
- package/src/index.ts +457 -0
- package/src/manifest.ts +18 -0
- package/src/richtext.ts +62 -0
- package/src/root.ts +37 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @micacms/astro
|
|
2
2
|
|
|
3
|
-
> **
|
|
4
|
-
>
|
|
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
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,
|
|
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, isFieldKind, 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";
|
|
@@ -46,6 +46,21 @@ export function micaCollections(site, options = {}) {
|
|
|
46
46
|
options.publishModel === false || options.source
|
|
47
47
|
? Promise.resolve()
|
|
48
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
|
+
};
|
|
49
64
|
/** Collections whose watcher is already registered, so a reload adds none. */
|
|
50
65
|
const watching = new Set();
|
|
51
66
|
const watch = (context, name, reload) => {
|
|
@@ -87,10 +102,7 @@ export function micaCollections(site, options = {}) {
|
|
|
87
102
|
for (const [name, def] of Object.entries(site.collections)) {
|
|
88
103
|
collections[name] = {
|
|
89
104
|
type: "content_layer",
|
|
90
|
-
|
|
91
|
-
// repository at rest and a path into the repository once loaded. There
|
|
92
|
-
// is no expansion step left to tell the two apart.
|
|
93
|
-
schema: fieldsSchema(def.fields, "storage").extend({ _mica: metaSchema }),
|
|
105
|
+
schema: fieldsSchema(def.fields).extend({ _mica: metaSchema }),
|
|
94
106
|
loader: {
|
|
95
107
|
name: `mica:${name}`,
|
|
96
108
|
load: async (context) => {
|
|
@@ -113,6 +125,7 @@ export function micaCollections(site, options = {}) {
|
|
|
113
125
|
});
|
|
114
126
|
context.store.set({ id: entry.slug, data, digest: context.generateDigest(data) });
|
|
115
127
|
}
|
|
128
|
+
await checkReferences(context, source, entries.map((entry) => ({ label: `${name}/${entry.slug}`, fields: def.fields, data: entry.data })));
|
|
116
129
|
context.logger.info(`Loaded ${entries.length} ${entries.length === 1 ? "entry" : "entries"} of "${def.label}"`);
|
|
117
130
|
};
|
|
118
131
|
await publish(context);
|
|
@@ -127,7 +140,7 @@ export function micaCollections(site, options = {}) {
|
|
|
127
140
|
for (const [name, def] of Object.entries(site.singletons)) {
|
|
128
141
|
collections[name] = {
|
|
129
142
|
type: "content_layer",
|
|
130
|
-
schema: fieldsSchema(def.fields
|
|
143
|
+
schema: fieldsSchema(def.fields),
|
|
131
144
|
loader: {
|
|
132
145
|
name: `mica:${name}`,
|
|
133
146
|
load: async (context) => {
|
|
@@ -142,6 +155,7 @@ export function micaCollections(site, options = {}) {
|
|
|
142
155
|
context.logger.warn(`No content for "${def.label}" yet.`);
|
|
143
156
|
return;
|
|
144
157
|
}
|
|
158
|
+
await checkReferences(context, source, [{ label: name, fields: def.fields, data }]);
|
|
145
159
|
const parsed = await context.parseData({ id: name, data });
|
|
146
160
|
context.store.set({ id: name, data: parsed, digest: context.generateDigest(parsed) });
|
|
147
161
|
};
|
|
@@ -231,11 +245,4 @@ async function guard(context, source, read) {
|
|
|
231
245
|
export { manifestFor } from "./manifest.js";
|
|
232
246
|
export { DEFAULT_CONTENT_ROOT, resolveContentRoot };
|
|
233
247
|
export { renderRichText, transformRichText, validateRichText, } from "./richtext.js";
|
|
234
|
-
/**
|
|
235
|
-
* Re-exported for convenience, so a project that only depends on this package
|
|
236
|
-
* can still build a source or read a collection. They are defined in
|
|
237
|
-
* `@micacms/core`, where the Mica server can reach them too.
|
|
238
|
-
*/
|
|
239
|
-
export { ContentReadError, ContentSourceError, ORDER_FILE, memorySource, readCollection, readSingleton, } from "@micacms/core";
|
|
240
|
-
export { fileSystemSource } from "@micacms/core/node";
|
|
241
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 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\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 /** 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 * 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\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"]}
|
package/package.json
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micacms/astro",
|
|
3
|
-
"version": "0.
|
|
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"
|
|
42
|
+
"@markdoc/markdoc": "^0.5.9"
|
|
34
43
|
},
|
|
35
44
|
"peerDependencies": {
|
|
36
|
-
"astro": "^
|
|
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
|
|
165
|
-
`src/lib/media.ts` from [reference.md](./reference.md#images),
|
|
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.
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
##
|
|
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 `
|
|
31
|
-
- After a change to `src/mica.ts`, run `npx astro sync` and `
|
|
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/`, `
|
|
52
|
+
- Before a commit that changes `content/`, `npm run check` must pass.
|
|
36
53
|
```
|
|
37
54
|
|
|
38
|
-
##
|
|
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: `
|
|
46
|
-
files in `content/`; Publish commits them.
|
|
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/.
|
package/setup/new-project.md
CHANGED
|
@@ -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.
|
|
21
|
-
|
|
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
|
package/setup/reference.md
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
234
|
+
const cover = article.data.cover; // media({ alt: "required" }): { path, alt }
|
|
235
|
+
const image = mediaImage(cover?.path);
|
|
233
236
|
---
|
|
234
|
-
{
|
|
237
|
+
{image && <Image src={image} alt={cover.alt} />}
|
|
235
238
|
```
|
|
236
239
|
|
|
237
|
-
A `media({ multiple: true })` field is an array
|
|
238
|
-
`project.data.gallery.map(mediaImage)`.
|
|
239
|
-
|
|
240
|
-
|
|
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";
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|
package/src/richtext.ts
ADDED
|
@@ -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
|
+
}
|