@micacms/astro 0.5.0 → 0.6.0-rc.2

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/src/index.ts ADDED
@@ -0,0 +1,495 @@
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
+ import { fileURLToPath } from "node:url";
22
+
23
+ import {
24
+ collectionEntryPath,
25
+ createReferenceCheck,
26
+ entryLayout,
27
+ fieldsSchema,
28
+ INDEX_FILE,
29
+ singletonPath,
30
+ isFieldKind,
31
+ MANIFEST_PATH,
32
+ readCollection,
33
+ readSingleton,
34
+ type Collection,
35
+ type ContentSource,
36
+ type FieldMap,
37
+ type InferCollection,
38
+ type InferSingleton,
39
+ type Singleton,
40
+ type Site,
41
+ } from "@micacms/core";
42
+ import { fileSystemSource } from "@micacms/core/node";
43
+ import type { ImageMetadata } from "astro";
44
+ import { z } from "zod";
45
+
46
+ import { imagePathsFromEntry } from "./images.js";
47
+ import { manifestFor } from "./manifest.js";
48
+ import { DEFAULT_CONTENT_ROOT, resolveContentRoot } from "./root.js";
49
+
50
+ export interface MicaCollectionsOptions {
51
+ /**
52
+ * Overrides `defineSite({ root })`, which is where the content root belongs.
53
+ *
54
+ * Relative paths resolve against the Astro project root, like Astro's own
55
+ * `glob({ base })`. An absolute path is used as given — which is what a
56
+ * project whose content is a separate repository, checked out by CI
57
+ * somewhere else, wants.
58
+ */
59
+ root?: string;
60
+ /** Overrides `root` entirely. Used by tests, and by a non-filesystem source. */
61
+ source?: ContentSource;
62
+ /**
63
+ * Turns off publishing the model to `<root>/.mica/schema.json`.
64
+ *
65
+ * Only for a project that is not edited through Mica at all: without that
66
+ * file in the repository, Mica has no model and refuses to start.
67
+ */
68
+ publishModel?: false;
69
+ /**
70
+ * How long to wait after a content change before reloading, in dev.
71
+ *
72
+ * Saving one entry touches several files — `index.json` and every `.mdoc`
73
+ * beside it — and a Git checkout touches all of them at once. One reload
74
+ * after the burst is both faster and less confusing than one per file.
75
+ */
76
+ debounceMs?: number;
77
+ /**
78
+ * Fails the build on a relation to a missing entry or a missing media file.
79
+ * Without it, the build logs a warning for each and goes on; a missing image
80
+ * file fails the build either way, because Astro cannot import it.
81
+ */
82
+ strict?: boolean;
83
+ }
84
+
85
+ /**
86
+ * Entry metadata attached to every collection document.
87
+ *
88
+ * No timestamps: in Git they are commit metadata, which a static build cannot
89
+ * read cheaply. A date an editor cares about belongs in the model as a `date()` field.
90
+ *
91
+ * Namespaced because these keys share a namespace with the customer's own
92
+ * fields, and `position` is a plausible field name.
93
+ */
94
+ export interface MicaMeta {
95
+ /** The entry slug, which is its identity in the repository. */
96
+ id: string;
97
+ slug: string;
98
+ collection: string;
99
+ /** Index in the collection's order, manual when `_order.json` says so. */
100
+ position: number;
101
+ }
102
+
103
+ const metaSchema = z.object({
104
+ id: z.string(),
105
+ slug: z.string(),
106
+ collection: z.string(),
107
+ position: z.number(),
108
+ });
109
+
110
+ /** What a document looks like inside Astro: the payload plus `_mica`. */
111
+ export type AstroDocument<T> = T & { _mica: MicaMeta };
112
+
113
+ /**
114
+ * The shape Astro's config loader expects.
115
+ *
116
+ * Deliberately not built with `defineCollection` from `astro:content`: that is a
117
+ * virtual module, resolvable only inside an Astro app's module graph, and this
118
+ * package is pre-compiled. `defineCollection` does nothing more than stamp this
119
+ * `type`, so producing the object directly keeps the package importable
120
+ * anywhere — including from a plain unit test.
121
+ */
122
+ export interface AstroCollectionConfig<T = unknown> {
123
+ type: "content_layer";
124
+ loader: ContentLoader;
125
+ /**
126
+ * A function, so that Astro hands in its `image()`. Its result is typed as
127
+ * `ZodType<T>` because Astro derives `CollectionEntry<"projects">["data"]`
128
+ * from it: left generic, every field in every template would degrade to
129
+ * `any`, and the point of the shared model is that renaming a field breaks
130
+ * the build.
131
+ */
132
+ schema: (context: { image: () => z.ZodType }) => z.ZodType<T>;
133
+ }
134
+
135
+ /** The subset of Astro's `Loader` contract this package uses. */
136
+ export interface ContentLoader {
137
+ name: string;
138
+ load(context: LoaderContext): Promise<void>;
139
+ }
140
+
141
+ export interface LoaderContext {
142
+ store: {
143
+ clear(): void;
144
+ set(entry: { id: string; data: Record<string, unknown>; digest?: string; filePath?: string }): boolean;
145
+ keys(): string[];
146
+ };
147
+ logger: {
148
+ info(message: string): void;
149
+ warn(message: string): void;
150
+ error(message: string): void;
151
+ debug(message: string): void;
152
+ };
153
+ meta: { get(key: string): string | undefined; set(key: string, value: string): void };
154
+ /**
155
+ * Astro's resolved config. Optional because this interface is a hand-written
156
+ * subset and the tests build a context by hand; `config.root` is what turns a
157
+ * relative content root into a path.
158
+ */
159
+ config?: { root: URL };
160
+ /**
161
+ * Present only while `astro dev` runs. Astro's watcher follows the module
162
+ * graph, and content files are not in it — no page imports
163
+ * `projects/haus-a/index.json` — so each loader watches its own directory
164
+ * here, exactly as Astro's own `glob` loader does.
165
+ */
166
+ watcher?: {
167
+ add(path: string): void;
168
+ on(event: "add" | "change" | "unlink", handler: (path: string) => void): void;
169
+ };
170
+ parseData(input: {
171
+ id: string;
172
+ data: Record<string, unknown>;
173
+ /** The entry's file, from the project root: Astro's `image()` resolves paths against it. */
174
+ filePath?: string;
175
+ }): Promise<Record<string, unknown>>;
176
+ generateDigest(data: Record<string, unknown> | string): string;
177
+ }
178
+
179
+ export type MicaCollections<S extends Site> = {
180
+ [K in keyof S["collections"]]: AstroCollectionConfig<CollectionDocument<S, K>>;
181
+ } & {
182
+ [K in keyof S["singletons"]]: AstroCollectionConfig<SingletonDocument<S, K>>;
183
+ };
184
+
185
+ export function micaCollections<S extends Site>(
186
+ site: S,
187
+ options: MicaCollectionsOptions = {},
188
+ ): MicaCollections<S> {
189
+ assertKnownKinds(site);
190
+
191
+ /**
192
+ * Everything below is resolved on first load rather than here, because the
193
+ * Astro project root is something only a loader is told — and it is what
194
+ * turns `./content` into a directory. Memoised: one root and one source,
195
+ * however many collections and however many reloads.
196
+ */
197
+ let root: string | undefined;
198
+ let source: ContentSource | undefined;
199
+
200
+ const rootFor = (context: LoaderContext): string =>
201
+ (root ??= resolveContentRoot(options.root ?? site.root, context.config?.root));
202
+
203
+ const sourceFor = (context: LoaderContext): ContentSource =>
204
+ (source ??= options.source ?? fileSystemSource(rootFor(context)));
205
+
206
+ /** Published once per process; a model change restarts the dev server. */
207
+ let publishing: Promise<void> | undefined;
208
+
209
+ const publish = (context: LoaderContext): Promise<void> =>
210
+ (publishing ??=
211
+ options.publishModel === false || options.source
212
+ ? Promise.resolve()
213
+ : publishModel(site, rootFor(context), context.logger));
214
+
215
+ /** An entry's data file, from the content root and, outside tests, from the project root. */
216
+ const entryFile = (context: LoaderContext, fields: FieldMap, name: string, slug?: string) => {
217
+ const base = slug === undefined ? singletonPath(fields, name) : collectionEntryPath(fields, name, slug);
218
+ const inContent = entryLayout(fields) === "directory" ? `${base}/${INDEX_FILE}` : base;
219
+ const project = context.config?.root;
220
+ const inProject =
221
+ project && !options.source
222
+ ? relative(fileURLToPath(project), join(rootFor(context), inContent)).split(sep).join("/")
223
+ : undefined;
224
+ return { inContent, inProject };
225
+ };
226
+
227
+ /** Warns about broken relations and missing files, or fails the build with `strict`. */
228
+ const checkReferences = async (
229
+ context: LoaderContext,
230
+ source: ContentSource,
231
+ documents: Array<{ label: string; fields: FieldMap; data: Record<string, unknown> }>,
232
+ ): Promise<void> => {
233
+ const check = createReferenceCheck(site, source);
234
+ const problems: string[] = [];
235
+ for (const { label, fields, data } of documents) {
236
+ for (const issue of await check.issues(fields, data)) problems.push(`${label}: ${issue.path}: ${issue.message}`);
237
+ }
238
+ if (problems.length === 0) return;
239
+ if (options.strict) throw new Error(`Broken references:\n ${problems.join("\n ")}`);
240
+ for (const problem of problems) context.logger.warn(problem);
241
+ };
242
+
243
+ /** Collections whose watcher is already registered, so a reload adds none. */
244
+ const watching = new Set<string>();
245
+
246
+ const watch = (context: LoaderContext, name: string, reload: () => Promise<void>): void => {
247
+ const watcher = context.watcher;
248
+ if (!watcher || options.source || watching.has(name)) return;
249
+ watching.add(name);
250
+
251
+ const base = rootFor(context);
252
+ watcher.add(base);
253
+
254
+ // A collection lives in `<root>/<name>/`; a singleton is either
255
+ // `<root>/<name>.json` or `<root>/<name>/…`. Both are covered by this
256
+ // prefix, and nothing outside it can concern this loader — which is what
257
+ // keeps one collection's save from re-reading every other one.
258
+ const prefix = join(base, name);
259
+ const mine = (path: string): boolean =>
260
+ path === `${prefix}.json` || path.startsWith(prefix + sep);
261
+
262
+ let timer: ReturnType<typeof setTimeout> | undefined;
263
+
264
+ const onChange = (path: string): void => {
265
+ // The manifest is this package's own output; reloading content because
266
+ // the model was written is a loop waiting to happen.
267
+ if (path.endsWith(MANIFEST_PATH) || !mine(path)) return;
268
+
269
+ if (timer) clearTimeout(timer);
270
+ timer = setTimeout(() => {
271
+ // Content that fails its own schema lands here. The dev server has to
272
+ // survive it: the next save is very likely the fix.
273
+ void reload().catch((error: unknown) => {
274
+ context.logger.error(`Could not reload content: ${describe(error)}`);
275
+ });
276
+ }, options.debounceMs ?? 100);
277
+ // A pending reload is never a reason to keep the process alive.
278
+ timer.unref?.();
279
+ };
280
+
281
+ watcher.on("change", onChange);
282
+ watcher.on("add", onChange);
283
+ watcher.on("unlink", onChange);
284
+ };
285
+
286
+ const collections = {} as Record<string, AstroCollectionConfig<unknown>>;
287
+
288
+ for (const [name, def] of Object.entries(site.collections) as [string, Collection][]) {
289
+ collections[name] = {
290
+ type: "content_layer",
291
+ schema: ({ image }) => fieldsSchema(def.fields, { image }).extend({ _mica: metaSchema }),
292
+ loader: {
293
+ name: `mica:${name}`,
294
+ load: async (context) => {
295
+ const read = async (): Promise<void> => {
296
+ const source = sourceFor(context);
297
+ const entries = await guard(context, source, () => readCollection(source, def, name));
298
+
299
+ // A full replace, not a merge: an entry deleted from the repository
300
+ // has to disappear from the build.
301
+ context.store.clear();
302
+
303
+ for (const entry of entries) {
304
+ const meta: MicaMeta = {
305
+ id: entry.slug,
306
+ slug: entry.slug,
307
+ collection: name,
308
+ position: entry.position,
309
+ };
310
+
311
+ const file = entryFile(context, def.fields, name, entry.slug);
312
+ const data = await context.parseData({
313
+ id: entry.slug,
314
+ data: { ...imagePathsFromEntry(def.fields, entry.data, file.inContent), _mica: meta },
315
+ ...(file.inProject ? { filePath: file.inProject } : {}),
316
+ });
317
+ context.store.set({
318
+ id: entry.slug,
319
+ data,
320
+ digest: context.generateDigest(data),
321
+ ...(file.inProject ? { filePath: file.inProject } : {}),
322
+ });
323
+ }
324
+
325
+ await checkReferences(
326
+ context,
327
+ source,
328
+ entries.map((entry) => ({ label: `${name}/${entry.slug}`, fields: def.fields, data: entry.data })),
329
+ );
330
+ context.logger.info(`Loaded ${entries.length} ${entries.length === 1 ? "entry" : "entries"} of "${def.label}"`);
331
+ };
332
+
333
+ await publish(context);
334
+ await read();
335
+ watch(context, name, read);
336
+ },
337
+ },
338
+ };
339
+ }
340
+
341
+ // A singleton becomes a one-entry collection whose id is its name, so the
342
+ // website reaches it with the same `getEntry` it uses for everything else.
343
+ for (const [name, def] of Object.entries(site.singletons) as [string, Singleton][]) {
344
+ collections[name] = {
345
+ type: "content_layer",
346
+ schema: ({ image }) => fieldsSchema(def.fields, { image }),
347
+ loader: {
348
+ name: `mica:${name}`,
349
+ load: async (context) => {
350
+ const read = async (): Promise<void> => {
351
+ const source = sourceFor(context);
352
+ const data = await guard(context, source, () => readSingleton(source, def, name));
353
+
354
+ context.store.clear();
355
+ if (data === null) {
356
+ // Not fatal. A site is normally deployed before every settings
357
+ // form has been filled in, and the page that reads this singleton
358
+ // reports the absence far better than the loader can.
359
+ context.logger.warn(`No content for "${def.label}" yet.`);
360
+ return;
361
+ }
362
+
363
+ await checkReferences(context, source, [{ label: name, fields: def.fields, data }]);
364
+ const file = entryFile(context, def.fields, name);
365
+ const parsed = await context.parseData({
366
+ id: name,
367
+ data: imagePathsFromEntry(def.fields, data, file.inContent),
368
+ ...(file.inProject ? { filePath: file.inProject } : {}),
369
+ });
370
+ context.store.set({
371
+ id: name,
372
+ data: parsed,
373
+ digest: context.generateDigest(parsed),
374
+ ...(file.inProject ? { filePath: file.inProject } : {}),
375
+ });
376
+ };
377
+
378
+ await publish(context);
379
+ await read();
380
+ watch(context, name, read);
381
+ },
382
+ },
383
+ };
384
+ }
385
+
386
+ return collections as MicaCollections<S>;
387
+ }
388
+
389
+ /**
390
+ * The site's own model must use only kinds this package knows. Mica reads an
391
+ * unknown kind as one from a newer version and makes its collection read-only,
392
+ * so a typo would otherwise pass the build and lock the collection.
393
+ */
394
+ function assertKnownKinds(site: Site): void {
395
+ const check = (fields: FieldMap, at: string) => {
396
+ for (const [key, field] of Object.entries(fields)) {
397
+ if (!isFieldKind(field.kind)) {
398
+ throw new Error(`Field "${at}.${key}" has the unknown kind "${String(field.kind)}". Use the field functions from @micacms/core.`);
399
+ }
400
+ if (field.kind === "object" || field.kind === "repeater") check(field.fields, `${at}.${key}`);
401
+ }
402
+ };
403
+ for (const [name, def] of Object.entries(site.collections)) check(def.fields, `collections.${name}`);
404
+ for (const [name, def] of Object.entries(site.singletons)) check(def.fields, `singletons.${name}`);
405
+ }
406
+
407
+ /**
408
+ * Writes the model into the content root, for Mica to read back.
409
+ *
410
+ * Mica holds no content model: the Astro project is the only place a collection
411
+ * is defined, and this is how it says so. The file goes *inside the content
412
+ * root* so that it travels to Mica the way content does — through Git and the
413
+ * repository connector — with nothing to mount and no second channel to keep in
414
+ * step. Commit it.
415
+ *
416
+ * Done from a loader rather than an integration because this is the moment both
417
+ * facts are in hand: the model, and the project root that turns `./content`
418
+ * into a directory. It also means `astro.config` needs no Mica in it, which is
419
+ * what lets a project define Mica collections and ordinary Astro ones in the
420
+ * same `content.config.ts`.
421
+ */
422
+ async function publishModel(
423
+ site: Site,
424
+ root: string,
425
+ logger: LoaderContext["logger"],
426
+ ): Promise<void> {
427
+ const target = join(root, MANIFEST_PATH);
428
+ const contents = `${JSON.stringify(manifestFor(site), null, 2)}\n`;
429
+
430
+ try {
431
+ // Only when it differs. The content root is watched in dev, and rewriting
432
+ // an identical file on every start would ask for a reload for no reason.
433
+ if ((await readFile(target, "utf8").catch(() => null)) === contents) return;
434
+
435
+ await mkdir(dirname(target), { recursive: true });
436
+ await writeFile(target, contents, "utf8");
437
+ logger.info(`Wrote the content model to ${relative(process.cwd(), target)} — commit it for Mica to read.`);
438
+ } catch (error) {
439
+ // A site that builds correctly should not fail over this, and a read-only
440
+ // checkout is a normal way to build one in CI. Mica reports a missing model
441
+ // itself, and far more usefully.
442
+ logger.warn(`Could not write the content model: ${describe(error)}`);
443
+ }
444
+ }
445
+
446
+ function describe(error: unknown): string {
447
+ return error instanceof Error ? error.message : String(error);
448
+ }
449
+
450
+ /**
451
+ * Reports a read failure against the content root that produced it.
452
+ *
453
+ * A build failing on unreadable content is correct — publishing a half-empty
454
+ * site would be worse — but the message has to name the directory that was
455
+ * read, because the usual cause is a content root pointing at the wrong place.
456
+ */
457
+ async function guard<T>(
458
+ context: LoaderContext,
459
+ source: ContentSource,
460
+ read: () => Promise<T>,
461
+ ): Promise<T> {
462
+ try {
463
+ return await read();
464
+ } catch (error) {
465
+ const detail = error instanceof Error ? error.message : String(error);
466
+ context.logger.error(detail);
467
+
468
+ throw new Error(`Could not read content from ${source.origin}.\n ${detail}`, { cause: error });
469
+ }
470
+ }
471
+
472
+ /* ------------------------------------------------------------------ *
473
+ * Types for the website
474
+ * ------------------------------------------------------------------ */
475
+
476
+ /** The document type for one collection, as it appears in Astro. */
477
+ export type CollectionDocument<
478
+ S extends Site,
479
+ K extends keyof S["collections"],
480
+ > = AstroDocument<InferCollection<S["collections"][K], ImageMetadata>>;
481
+
482
+ /** The document type for one singleton. */
483
+ export type SingletonDocument<
484
+ S extends Site,
485
+ K extends keyof S["singletons"],
486
+ > = InferSingleton<S["singletons"][K], ImageMetadata>;
487
+
488
+ export { manifestFor } from "./manifest.js";
489
+ export { DEFAULT_CONTENT_ROOT, resolveContentRoot };
490
+ export {
491
+ renderRichText,
492
+ transformRichText,
493
+ validateRichText,
494
+ type RichTextOptions,
495
+ } 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
+ }