@contentrain/query 7.3.7 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +51 -8
  2. package/dist/astro/index.cjs +30 -2
  3. package/dist/astro/index.d.cts +9 -0
  4. package/dist/astro/index.d.cts.map +1 -1
  5. package/dist/astro/index.d.mts +10 -1
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +30 -2
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/cdn/index.d.mts +1 -1
  10. package/dist/cli.cjs +1 -1
  11. package/dist/cli.mjs +1 -1
  12. package/dist/{frontmatter-BUaCvV-s.mjs → frontmatter-D9yVqC5y.mjs} +2 -2
  13. package/dist/{frontmatter-BUaCvV-s.mjs.map → frontmatter-D9yVqC5y.mjs.map} +1 -1
  14. package/dist/{generate-CdLXEj13.mjs → generate-BOi6o7Z5.mjs} +27 -14
  15. package/dist/generate-BOi6o7Z5.mjs.map +1 -0
  16. package/dist/{generate-oSHTaAuy.cjs → generate-D4NDHZ_K.cjs} +26 -13
  17. package/dist/generator/generate.cjs +1 -1
  18. package/dist/generator/generate.d.cts +10 -5
  19. package/dist/generator/generate.d.cts.map +1 -1
  20. package/dist/generator/generate.d.mts +11 -6
  21. package/dist/generator/generate.d.mts.map +1 -1
  22. package/dist/generator/generate.mjs +1 -1
  23. package/dist/{index-DWpmPtUW.d.mts → index-ClftI9li.d.mts} +1 -1
  24. package/dist/{index-DWpmPtUW.d.mts.map → index-ClftI9li.d.mts.map} +1 -1
  25. package/dist/index.cjs +3 -0
  26. package/dist/index.d.cts +18 -1
  27. package/dist/index.d.cts.map +1 -1
  28. package/dist/index.d.mts +19 -2
  29. package/dist/index.d.mts.map +1 -1
  30. package/dist/index.mjs +2 -1
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/media-HEEDdrFS.mjs +27 -0
  33. package/dist/media-HEEDdrFS.mjs.map +1 -0
  34. package/dist/media-o6KezVYy.cjs +36 -0
  35. package/dist/{publication-Ba8bLPAO.d.mts → publication-CMrWLPi7.d.mts} +1 -1
  36. package/dist/publication-CMrWLPi7.d.mts.map +1 -0
  37. package/package.json +2 -2
  38. package/dist/generate-CdLXEj13.mjs.map +0 -1
  39. package/dist/publication-Ba8bLPAO.d.mts.map +0 -1
  40. package/dist/{frontmatter-DERIsjeG.cjs → frontmatter-DSpE3so9.cjs} +1 -1
package/README.md CHANGED
@@ -159,18 +159,47 @@ Supported methods:
159
159
  - `first()`
160
160
  - `all()`
161
161
 
162
- ### `media(value)`
162
+ ### `media(value, baseOverride?)` / `mediaBody(markdown, baseOverride?)`
163
163
 
164
- Resolves a stored `media/...` path to its absolute delivery URL. Emitted **only** when a CDN base is configured — set `cdn.url` in `.contentrain/config.json` or run `contentrain generate --cdnBaseUrl <base>`:
164
+ Resolve a stored `media/...` path — `media()` for a single field value, `mediaBody()` for every `](media/...)` link and image embed inside a markdown string — to an absolute delivery URL. Emitted **only** when a media base is configured at generate time — set `cdn.url` in `.contentrain/config.json` or run `contentrain generate --mediaBaseUrl <base>` (`--cdnBaseUrl` still works as a deprecated alias). With no base configured, these are not emitted at all — not a pass-through, an absent export — so code that imports them from `#contentrain` without generating with a base will fail to resolve at build time:
165
165
 
166
166
  ```ts
167
- import { media } from '#contentrain'
167
+ import { media, mediaBody } from '#contentrain'
168
168
 
169
- media('media/original/hero.webp') // → '{cdn.url}/media/original/hero.webp'
169
+ media('media/original/hero.webp') // → '{mediaBaseUrl}/media/original/hero.webp'
170
170
  media('https://images.unsplash.com/x.jpg') // → unchanged (external pass-through)
171
+
172
+ mediaBody('See ![cover](media/hero.webp) for more.')
173
+ // → 'See ![cover](https://.../media/hero.webp) for more.'
174
+ ```
175
+
176
+ Both are idempotent — external URLs (`http(s)://`, `//`, `data:`) and already-absolute delivery URLs pass through untouched — and take an optional second argument that overrides the base baked in at generate time for that one call. That is how a Nuxt app stays on one build across environments: read the base from its own runtime config, not from what `contentrain generate` happened to bake in.
177
+
178
+ ```ts
179
+ // Nuxt: nuxt.config.ts
180
+ export default defineNuxtConfig({
181
+ runtimeConfig: {
182
+ public: { contentrainMediaBaseUrl: '' }, // filled from NUXT_PUBLIC_CONTENTRAIN_MEDIA_BASE_URL
183
+ },
184
+ })
185
+
186
+ // Nuxt: a composable or component
187
+ const { public: { contentrainMediaBaseUrl } } = useRuntimeConfig()
188
+ const cover = media(post.cover, contentrainMediaBaseUrl)
189
+ ```
190
+
191
+ This is the local-mode counterpart of CDN mode's `MediaAccessor.url()`. For Studio-CDN content, media fields already carry absolute URLs, so no resolution is needed.
192
+
193
+ For a Nuxt app (or anything else) that never bakes a base into the generated client at all — reading it purely from its own runtime config or env every time, so `media()`/`mediaBody()` are never emitted — import the underlying pure resolvers directly instead of going through the generated client:
194
+
195
+ ```ts
196
+ import { resolveMediaUrl, resolveMediaRefsInBody } from '@contentrain/query'
197
+
198
+ const cover = resolveMediaUrl(post.cover, useRuntimeConfig().public.contentrainMediaBaseUrl)
199
+ const body = resolveMediaRefsInBody(post.body, useRuntimeConfig().public.contentrainMediaBaseUrl)
171
200
  ```
172
201
 
173
- Idempotent — external URLs (`http(s)://`, `//`, `data:`) and already-absolute delivery URLs pass through untouched. It is the local-mode counterpart of CDN mode's `MediaAccessor.url()`. For Studio-CDN content, media fields already carry absolute URLs, so no resolution is needed.
202
+ Both are opt-in: called with no base (`undefined`, `null`, or `''`), they return the value unchanged, so a project that never configures one keeps relative `media/...` paths exactly as stored.
174
203
 
175
204
  ## 🔗 Relations
176
205
 
@@ -211,6 +240,7 @@ Public root exports:
211
240
  - `ConversationClient` — Conversation API client
212
241
  - `ContentrainError` — HTTP error class for CDN mode
213
242
  - `applyWhere` — shared where filter helper
243
+ - `resolveMediaUrl`, `resolveMediaRefsInBody` — the media resolver behind `media()`/`mediaBody()`/`contentrainLoader`'s `mediaBaseUrl`, usable standalone with a base sourced from anywhere (a framework's own runtime config, an env var)
214
244
 
215
245
  ## CDN Transport
216
246
 
@@ -476,6 +506,18 @@ loads with ids prefixed (`en/my-post`) — Astro ids are unique per collection,
476
506
  without the prefix one language would overwrite the other. `root` points at the
477
507
  project holding `.contentrain` (default: `process.cwd()`).
478
508
 
509
+ This loader reads `.contentrain` directly, so nothing upstream has rewritten a
510
+ stored `media/...` reference into an absolute URL yet — pass `mediaBaseUrl` to
511
+ resolve it here, the same opt-in resolver `media()`/`mediaBody()` use:
512
+
513
+ ```ts
514
+ contentrainLoader({ model: 'blog-post', locale: 'en', mediaBaseUrl: 'https://cdn.example.com' })
515
+ ```
516
+
517
+ Applied automatically to `image`/`video`/`file` fields, to `](media/...)`
518
+ references inside `markdown`/`richtext` fields, and to a document's markdown
519
+ body. Omit it to keep values exactly as stored.
520
+
479
521
  Astro is not a dependency, not even a peer: the loader is structurally what
480
522
  `defineCollection` accepts, so any Astro 5 version works and non-Astro consumers
481
523
  of this package carry nothing extra.
@@ -496,9 +538,10 @@ const hero = client.singleton('hero').get()
496
538
  **Via the `contentrain` CLI (recommended for most users):**
497
539
 
498
540
  ```bash
499
- contentrain generate # Generate once
500
- contentrain generate --watch # Regenerate on model/content changes
501
- contentrain generate --json # Machine-readable JSON for CI
541
+ contentrain generate # Generate once
542
+ contentrain generate --watch # Regenerate on model/content changes
543
+ contentrain generate --json # Machine-readable JSON for CI
544
+ contentrain generate --mediaBaseUrl https://cdn.example.com # Bake media()/mediaBody() into the client
502
545
  ```
503
546
 
504
547
  **Via `contentrain-query` (programmatic / build tool flows):**
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_frontmatter = require("../frontmatter-DERIsjeG.cjs");
2
+ const require_media = require("../media-o6KezVYy.cjs");
3
+ const require_frontmatter = require("../frontmatter-DSpE3so9.cjs");
4
+ let _contentrain_types = require("@contentrain/types");
3
5
  let node_path = require("node:path");
4
6
  let node_url = require("node:url");
5
7
  //#region src/astro/loader.ts
@@ -39,7 +41,8 @@ function contentrainLoader(options) {
39
41
  if (options.locale !== void 0 && refs.length === 0 && model.i18n) context.logger?.warn(`contentrainLoader: model "${model.id}" has no content for locale "${options.locale}" — collection is empty.`);
40
42
  const prefixLocale = model.i18n === true && options.locale === void 0;
41
43
  const publication = require_frontmatter.publicationContext(root, manifest.config.locales.default, options);
42
- const entries = (await Promise.all(refs.map((ref) => entriesOf(ref, model, prefixLocale, publication)))).flat();
44
+ const perFile = await Promise.all(refs.map((ref) => entriesOf(ref, model, prefixLocale, publication)));
45
+ const entries = options.mediaBaseUrl ? resolveEntriesMedia(perFile.flat(), model, options.mediaBaseUrl) : perFile.flat();
43
46
  const siteRoot = toPath(context.config?.root);
44
47
  context.store.clear();
45
48
  for (const entry of entries) {
@@ -83,6 +86,31 @@ function toPath(root) {
83
86
  if (typeof root !== "string") return (0, node_url.fileURLToPath)(root);
84
87
  return root.startsWith("file:") ? (0, node_url.fileURLToPath)(root) : root;
85
88
  }
89
+ const MEDIA_BODY_TYPES = new Set(["markdown", "richtext"]);
90
+ function fieldNamesOfType(model, types) {
91
+ if (!model.fields) return [];
92
+ return Object.entries(model.fields).filter(([, f]) => types.has(f.type)).map(([name]) => name);
93
+ }
94
+ /** Resolves `media/...` references to absolute URLs — image/video/file field
95
+ * values (including those nested inside object/array fields, via the shared
96
+ * `@contentrain/types` engine), `](media/...)` links and inline HTML
97
+ * src/href inside markdown/richtext fields, and a document's markdown body —
98
+ * across every loaded entry. A model with no fields and no body is returned
99
+ * unchanged, entry by entry. */
100
+ function resolveEntriesMedia(entries, model, mediaBaseUrl) {
101
+ const bodyFields = fieldNamesOfType(model, MEDIA_BODY_TYPES);
102
+ if (!model.fields && model.kind !== "document") return entries;
103
+ return entries.map((entry) => {
104
+ const data = model.fields ? (0, _contentrain_types.rewriteEntryMedia)(entry.data, model.fields, mediaBaseUrl) : entry.data;
105
+ for (const field of bodyFields) if (field in data) data[field] = require_media.resolveMediaRefsInBody(data[field], mediaBaseUrl);
106
+ const body = entry.body === void 0 ? void 0 : require_media.resolveMediaRefsInBody(entry.body, mediaBaseUrl);
107
+ return {
108
+ ...entry,
109
+ data,
110
+ ...body === void 0 ? {} : { body }
111
+ };
112
+ });
113
+ }
86
114
  async function entriesOf(ref, model, prefixLocale, publication) {
87
115
  const meta = publication ? await require_frontmatter.publicationMeta(ref, model, publication) : void 0;
88
116
  const visible = (id) => !publication || require_frontmatter.isPublishedAt(id === void 0 ? meta : meta?.[id], publication.at);
@@ -56,6 +56,15 @@ interface ContentrainLoaderOptions extends PublicationOptions {
56
56
  * post do not collide — Astro ids are unique per collection.
57
57
  */
58
58
  locale?: string;
59
+ /**
60
+ * Public media delivery base for resolving relative `media/...` references
61
+ * to absolute URLs. This loader reads `.contentrain` directly — nothing
62
+ * upstream has rewritten them yet — so, unlike CDN-bundled content, they
63
+ * need a resolver here. Applied automatically to image/video/file fields,
64
+ * to `](media/...)` references inside markdown/richtext fields, and to a
65
+ * document's markdown body. Omit to keep values exactly as stored.
66
+ */
67
+ mediaBaseUrl?: string;
59
68
  }
60
69
  /**
61
70
  * A loader for one Contentrain model.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/astro/loader.ts"],"mappings":";;;;UAuBiB,wBAAA;EACf,KAAA;IACE,KAAA;IACA,GAAA,GAAM,KAAA;MACJ,EAAA;MACA,IAAA,EAAM,MAAA;MACN,IAAA;MACA,MAAA;MACA,QAAA;QAAa,IAAA;QAAc,QAAA,GAAW,MAAA;MAAA;MACtC,QAAA;IAAA;EAAA;EAGJ,MAAA;IAAW,IAAA,GAAO,GAAA;IAAsB,IAAA,GAAO,GAAA;EAAA;EAPrC;EASV,MAAA;IAAW,IAAA,GAAO,GAAA;EAAA;EAClB,cAAA,IAAkB,IAAA;EAClB,SAAA,IAAa,KAAA;IAAS,EAAA;IAAY,IAAA,EAAM,MAAA;IAAyB,QAAA;EAAA,MAAwB,OAAA,CAAQ,MAAA;EACjG,cAAA,IAAkB,OAAA,aAAoB,OAAA;IAAU,IAAA;IAAc,QAAA,GAAW,MAAA;EAAA;EACzE,OAAA;IAAY,GAAA,GAAM,IAAA;IAAuB,EAAA,GAAK,KAAA,UAAe,EAAA,GAAK,IAAA;EAAA;AAAA;;UAInD,iBAAA;EACf,IAAA;EACA,IAAA,GAAO,OAAA,EAAS,wBAAA,KAA6B,OAAA;AAAA;AAAA,UAG9B,wBAAA,SAAiC,kBAAA;EAXyC;EAazF,KAAA;EAZA;EAcA,IAAA;EAdsC;;;;;EAoBtC,MAAA;AAAA;;;;;;;AAfF;;;;;;;;;;;AAKA;;;iBAwCgB,iBAAA,CAAkB,OAAA,EAAS,wBAAA,GAA2B,iBAAA"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/astro/loader.ts"],"mappings":";;;;UAyBiB,wBAAA;EACf,KAAA;IACE,KAAA;IACA,GAAA,GAAM,KAAA;MACJ,EAAA;MACA,IAAA,EAAM,MAAA;MACN,IAAA;MACA,MAAA;MACA,QAAA;QAAa,IAAA;QAAc,QAAA,GAAW,MAAA;MAAA;MACtC,QAAA;IAAA;EAAA;EAGJ,MAAA;IAAW,IAAA,GAAO,GAAA;IAAsB,IAAA,GAAO,GAAA;EAAA;EAPrC;EASV,MAAA;IAAW,IAAA,GAAO,GAAA;EAAA;EAClB,cAAA,IAAkB,IAAA;EAClB,SAAA,IAAa,KAAA;IAAS,EAAA;IAAY,IAAA,EAAM,MAAA;IAAyB,QAAA;EAAA,MAAwB,OAAA,CAAQ,MAAA;EACjG,cAAA,IAAkB,OAAA,aAAoB,OAAA;IAAU,IAAA;IAAc,QAAA,GAAW,MAAA;EAAA;EACzE,OAAA;IAAY,GAAA,GAAM,IAAA;IAAuB,EAAA,GAAK,KAAA,UAAe,EAAA,GAAK,IAAA;EAAA;AAAA;;UAInD,iBAAA;EACf,IAAA;EACA,IAAA,GAAO,OAAA,EAAS,wBAAA,KAA6B,OAAA;AAAA;AAAA,UAG9B,wBAAA,SAAiC,kBAAA;EAXyC;EAazF,KAAA;EAZA;EAcA,IAAA;EAdsC;;;;;EAoBtC,MAAA;EAnBkB;;;;;;;AAIpB;EAwBE,YAAA;AAAA;;;;;;;;;AAnBF;;;;;;;;;;;AAiDA;iBAAgB,iBAAA,CAAkB,OAAA,EAAS,wBAAA,GAA2B,iBAAA"}
@@ -1,4 +1,4 @@
1
- import { t as PublicationOptions } from "../publication-Ba8bLPAO.mjs";
1
+ import { t as PublicationOptions } from "../publication-CMrWLPi7.mjs";
2
2
 
3
3
  //#region src/astro/loader.d.ts
4
4
  /** The slice of Astro's `LoaderContext` this loader uses. */
@@ -56,6 +56,15 @@ interface ContentrainLoaderOptions extends PublicationOptions {
56
56
  * post do not collide — Astro ids are unique per collection.
57
57
  */
58
58
  locale?: string;
59
+ /**
60
+ * Public media delivery base for resolving relative `media/...` references
61
+ * to absolute URLs. This loader reads `.contentrain` directly — nothing
62
+ * upstream has rewritten them yet — so, unlike CDN-bundled content, they
63
+ * need a resolver here. Applied automatically to image/video/file fields,
64
+ * to `](media/...)` references inside markdown/richtext fields, and to a
65
+ * document's markdown body. Omit to keep values exactly as stored.
66
+ */
67
+ mediaBaseUrl?: string;
59
68
  }
60
69
  /**
61
70
  * A loader for one Contentrain model.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/astro/loader.ts"],"mappings":";;;;UAuBiB,wBAAA;EACf,KAAA;IACE,KAAA;IACA,GAAA,GAAM,KAAA;MACJ,EAAA;MACA,IAAA,EAAM,MAAA;MACN,IAAA;MACA,MAAA;MACA,QAAA;QAAa,IAAA;QAAc,QAAA,GAAW,MAAA;MAAA;MACtC,QAAA;IAAA;EAAA;EAGJ,MAAA;IAAW,IAAA,GAAO,GAAA;IAAsB,IAAA,GAAO,GAAA;EAAA;EAPrC;EASV,MAAA;IAAW,IAAA,GAAO,GAAA;EAAA;EAClB,cAAA,IAAkB,IAAA;EAClB,SAAA,IAAa,KAAA;IAAS,EAAA;IAAY,IAAA,EAAM,MAAA;IAAyB,QAAA;EAAA,MAAwB,OAAA,CAAQ,MAAA;EACjG,cAAA,IAAkB,OAAA,aAAoB,OAAA;IAAU,IAAA;IAAc,QAAA,GAAW,MAAA;EAAA;EACzE,OAAA;IAAY,GAAA,GAAM,IAAA;IAAuB,EAAA,GAAK,KAAA,UAAe,EAAA,GAAK,IAAA;EAAA;AAAA;;UAInD,iBAAA;EACf,IAAA;EACA,IAAA,GAAO,OAAA,EAAS,wBAAA,KAA6B,OAAA;AAAA;AAAA,UAG9B,wBAAA,SAAiC,kBAAA;EAXyC;EAazF,KAAA;EAZA;EAcA,IAAA;EAdsC;;;;;EAoBtC,MAAA;AAAA;;;;;;;AAfF;;;;;;;;;;;AAKA;;;iBAwCgB,iBAAA,CAAkB,OAAA,EAAS,wBAAA,GAA2B,iBAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/astro/loader.ts"],"mappings":";;;;UAyBiB,wBAAA;EACf,KAAA;IACE,KAAA;IACA,GAAA,GAAM,KAAA;MACJ,EAAA;MACA,IAAA,EAAM,MAAA;MACN,IAAA;MACA,MAAA;MACA,QAAA;QAAa,IAAA;QAAc,QAAA,GAAW,MAAA;MAAA;MACtC,QAAA;IAAA;EAAA;EAGJ,MAAA;IAAW,IAAA,GAAO,GAAA;IAAsB,IAAA,GAAO,GAAA;EAAA;EAPrC;EASV,MAAA;IAAW,IAAA,GAAO,GAAA;EAAA;EAClB,cAAA,IAAkB,IAAA;EAClB,SAAA,IAAa,KAAA;IAAS,EAAA;IAAY,IAAA,EAAM,MAAA;IAAyB,QAAA;EAAA,MAAwB,OAAA,CAAQ,MAAA;EACjG,cAAA,IAAkB,OAAA,aAAoB,OAAA;IAAU,IAAA;IAAc,QAAA,GAAW,MAAA;EAAA;EACzE,OAAA;IAAY,GAAA,GAAM,IAAA;IAAuB,EAAA,GAAK,KAAA,UAAe,EAAA,GAAK,IAAA;EAAA;AAAA;;UAInD,iBAAA;EACf,IAAA;EACA,IAAA,GAAO,OAAA,EAAS,wBAAA,KAA6B,OAAA;AAAA;AAAA,UAG9B,wBAAA,SAAiC,kBAAA;EAXyC;EAazF,KAAA;EAZA;EAcA,IAAA;EAdsC;;;;;EAoBtC,MAAA;EAnBkB;;;;;;;AAIpB;EAwBE,YAAA;AAAA;;;;;;;;;AAnBF;;;;;;;;;;;AAiDA;iBAAgB,iBAAA,CAAkB,OAAA,EAAS,wBAAA,GAA2B,iBAAA"}
@@ -1,4 +1,6 @@
1
- import { a as readJson, c as isPublishedAt, l as publicationContext, n as stringLikeFieldKeys, o as readText, r as readProjectManifest, t as parseFrontmatter, u as publicationMeta } from "../frontmatter-BUaCvV-s.mjs";
1
+ import { t as resolveMediaRefsInBody } from "../media-HEEDdrFS.mjs";
2
+ import { a as readJson, c as isPublishedAt, l as publicationContext, n as stringLikeFieldKeys, o as readText, r as readProjectManifest, t as parseFrontmatter, u as publicationMeta } from "../frontmatter-D9yVqC5y.mjs";
3
+ import { rewriteEntryMedia } from "@contentrain/types";
2
4
  import { isAbsolute, join, relative } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  //#region src/astro/loader.ts
@@ -38,7 +40,8 @@ function contentrainLoader(options) {
38
40
  if (options.locale !== void 0 && refs.length === 0 && model.i18n) context.logger?.warn(`contentrainLoader: model "${model.id}" has no content for locale "${options.locale}" — collection is empty.`);
39
41
  const prefixLocale = model.i18n === true && options.locale === void 0;
40
42
  const publication = publicationContext(root, manifest.config.locales.default, options);
41
- const entries = (await Promise.all(refs.map((ref) => entriesOf(ref, model, prefixLocale, publication)))).flat();
43
+ const perFile = await Promise.all(refs.map((ref) => entriesOf(ref, model, prefixLocale, publication)));
44
+ const entries = options.mediaBaseUrl ? resolveEntriesMedia(perFile.flat(), model, options.mediaBaseUrl) : perFile.flat();
42
45
  const siteRoot = toPath(context.config?.root);
43
46
  context.store.clear();
44
47
  for (const entry of entries) {
@@ -82,6 +85,31 @@ function toPath(root) {
82
85
  if (typeof root !== "string") return fileURLToPath(root);
83
86
  return root.startsWith("file:") ? fileURLToPath(root) : root;
84
87
  }
88
+ const MEDIA_BODY_TYPES = new Set(["markdown", "richtext"]);
89
+ function fieldNamesOfType(model, types) {
90
+ if (!model.fields) return [];
91
+ return Object.entries(model.fields).filter(([, f]) => types.has(f.type)).map(([name]) => name);
92
+ }
93
+ /** Resolves `media/...` references to absolute URLs — image/video/file field
94
+ * values (including those nested inside object/array fields, via the shared
95
+ * `@contentrain/types` engine), `](media/...)` links and inline HTML
96
+ * src/href inside markdown/richtext fields, and a document's markdown body —
97
+ * across every loaded entry. A model with no fields and no body is returned
98
+ * unchanged, entry by entry. */
99
+ function resolveEntriesMedia(entries, model, mediaBaseUrl) {
100
+ const bodyFields = fieldNamesOfType(model, MEDIA_BODY_TYPES);
101
+ if (!model.fields && model.kind !== "document") return entries;
102
+ return entries.map((entry) => {
103
+ const data = model.fields ? rewriteEntryMedia(entry.data, model.fields, mediaBaseUrl) : entry.data;
104
+ for (const field of bodyFields) if (field in data) data[field] = resolveMediaRefsInBody(data[field], mediaBaseUrl);
105
+ const body = entry.body === void 0 ? void 0 : resolveMediaRefsInBody(entry.body, mediaBaseUrl);
106
+ return {
107
+ ...entry,
108
+ data,
109
+ ...body === void 0 ? {} : { body }
110
+ };
111
+ });
112
+ }
85
113
  async function entriesOf(ref, model, prefixLocale, publication) {
86
114
  const meta = publication ? await publicationMeta(ref, model, publication) : void 0;
87
115
  const visible = (id) => !publication || isPublishedAt(id === void 0 ? meta : meta?.[id], publication.at);
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/astro/loader.ts"],"sourcesContent":["// Astro content-layer loader for a `.contentrain` project.\n//\n// `contentrain generate` produces a typed client for application code; Astro\n// wants its content in the content layer instead, so pages can query it with\n// `getCollection()` and render documents with `<Content />`. This loader reads\n// the same `.contentrain` files the generator reads — through the same manifest\n// — so a project never has two answers about what its content is.\n//\n// Astro is NOT a dependency here, not even a peer: the returned object is\n// structurally what `defineCollection({ loader })` accepts, and the context is\n// typed by the small surface this loader actually uses. That keeps\n// @contentrain/query dependency-free and usable from any Astro 5 version.\n\nimport { publicationContext, publicationMeta, isPublishedAt, type PublicationOptions, type PublicationContext } from '../generator/publication.js'\nimport type { ModelDefinition } from '@contentrain/types'\nimport { isAbsolute, join, relative } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { ContentFileRef } from '../generator/config-reader.js'\nimport { readProjectManifest } from '../generator/config-reader.js'\nimport { readJson, readText } from '../generator/utils.js'\nimport { parseFrontmatter, stringLikeFieldKeys } from '../shared/frontmatter.js'\n\n/** The slice of Astro's `LoaderContext` this loader uses. */\nexport interface ContentrainLoaderContext {\n store: {\n clear: () => void\n set: (entry: {\n id: string\n data: Record<string, unknown>\n body?: string\n digest?: string\n rendered?: { html: string, metadata?: Record<string, unknown> }\n filePath?: string\n }) => void\n }\n logger?: { info: (msg: string) => void, warn: (msg: string) => void }\n /** Astro's resolved config; only `root` is read, to make file paths relative. */\n config?: { root?: URL | string }\n generateDigest?: (data: unknown) => string\n parseData?: (entry: { id: string, data: Record<string, unknown>, filePath?: string }) => Promise<Record<string, unknown>>\n renderMarkdown?: (content: string) => Promise<{ html: string, metadata?: Record<string, unknown> }>\n watcher?: { add: (path: string) => void, on: (event: string, cb: (path: string) => void) => void }\n}\n\n/** Structurally Astro's `Loader`. */\nexport interface ContentrainLoader {\n name: string\n load: (context: ContentrainLoaderContext) => Promise<void>\n}\n\nexport interface ContentrainLoaderOptions extends PublicationOptions {\n /** Model id — the `id` in `.contentrain/models/<id>.json`. */\n model: string\n /** Project root holding `.contentrain`. Default: `process.cwd()`. */\n root?: string\n /**\n * Load only this locale. Omitted on an i18n model, every supported locale is\n * loaded and entry ids are prefixed (`en/my-post`) so the two languages of one\n * post do not collide — Astro ids are unique per collection.\n */\n locale?: string\n}\n\ninterface Entry {\n id: string\n data: Record<string, unknown>\n body?: string\n filePath: string\n}\n\n/**\n * A loader for one Contentrain model.\n *\n * ```ts\n * // src/content.config.ts\n * import { defineCollection } from 'astro:content'\n * import { contentrainLoader } from '@contentrain/query/astro'\n *\n * export const collections = {\n * posts: defineCollection({ loader: contentrainLoader({ model: 'blog-post', locale: 'en' }) }),\n * articles: defineCollection({ loader: contentrainLoader({ model: 'blog-article' }) }),\n * }\n * ```\n *\n * Each model kind maps to the shape Astro can actually use:\n * collection → one entry per record · document → one entry per file, markdown\n * in `body` (and rendered, so `<Content />` works) · dictionary → one entry per\n * key, because a single blob would make `getEntry()` useless · singleton → one\n * entry, id `<model>` or the locale.\n */\nexport function contentrainLoader(options: ContentrainLoaderOptions): ContentrainLoader {\n const root = options.root ?? process.cwd()\n let watching = false\n\n return {\n name: `contentrain:${options.model}`,\n async load(context) {\n const manifest = await readProjectManifest(root)\n const model = manifest.models.find(m => m.id === options.model)\n if (!model) {\n const known = manifest.models.map(m => m.id).join(', ') || 'none'\n throw new Error(\n `contentrainLoader: model \"${options.model}\" not found in ${join(root, '.contentrain', 'models')}. Models here: ${known}.`,\n )\n }\n\n const refs = manifest.contentFiles.filter(ref =>\n ref.modelId === model.id && (options.locale === undefined || ref.locale === options.locale),\n )\n if (options.locale !== undefined && refs.length === 0 && model.i18n) {\n context.logger?.warn(\n `contentrainLoader: model \"${model.id}\" has no content for locale \"${options.locale}\" — collection is empty.`,\n )\n }\n // Prefix ids only when one collection really holds several languages;\n // a single-locale load keeps the natural id so routes stay clean.\n const prefixLocale = model.i18n === true && options.locale === undefined\n\n const publication = publicationContext(root, manifest.config.locales.default, options)\n const perFile = await Promise.all(refs.map(ref => entriesOf(ref, model, prefixLocale, publication)))\n const entries = perFile.flat()\n // Astro rejects an absolute filePath (\"must be relative to the site\n // root\"), and a `.contentrain` outside the Astro root has no relative\n // form it accepts — there we send none rather than a path it refuses.\n const siteRoot = toPath(context.config?.root)\n\n context.store.clear()\n for (const entry of entries) {\n const data = context.parseData\n ? await context.parseData({ id: entry.id, data: entry.data, filePath: entry.filePath })\n : entry.data\n const filePath = siteRoot === undefined ? undefined : relative(siteRoot, entry.filePath)\n context.store.set({\n id: entry.id,\n data,\n ...(filePath === undefined || filePath.startsWith('..') || isAbsolute(filePath) ? {} : { filePath }),\n ...(entry.body === undefined ? {} : { body: entry.body }),\n ...(context.generateDigest ? { digest: context.generateDigest(entry.data) } : {}),\n ...(entry.body !== undefined && context.renderMarkdown\n ? { rendered: await context.renderMarkdown(entry.body) }\n : {}),\n })\n }\n context.logger?.info(`Loaded ${entries.length} ${model.kind} ${entries.length === 1 ? 'entry' : 'entries'} from ${model.id}`)\n\n // Dev server: reload when the files behind this collection change. The\n // listener is registered once — `load` runs again on every change, and a\n // listener per run would multiply the work on every save.\n if (context.watcher && !watching) {\n watching = true\n const paths = new Set(refs.map(ref => ref.filePath))\n for (const path of paths) context.watcher.add(path)\n const metaDir = join(root, '.contentrain', 'meta', model.id)\n context.watcher.add(metaDir)\n context.watcher.add(join(root, '.contentrain', 'models', `${model.id}.json`))\n const reload = (path: string) => {\n if (!paths.has(path) && !path.startsWith(metaDir + '/') && !path.endsWith(`${model.id}.json`)) return\n void this.load(context)\n }\n context.watcher.on('change', reload)\n context.watcher.on('add', reload)\n context.watcher.on('unlink', reload)\n }\n },\n }\n}\n\n/** Astro hands `config.root` as a file URL; tests and other hosts may use a path. */\nfunction toPath(root: URL | string | undefined): string | undefined {\n if (root === undefined) return undefined\n if (typeof root !== 'string') return fileURLToPath(root)\n return root.startsWith('file:') ? fileURLToPath(root) : root\n}\n\nasync function entriesOf(ref: ContentFileRef, model: ModelDefinition, prefixLocale: boolean, publication?: PublicationContext): Promise<Entry[]> {\n const meta = publication ? await publicationMeta(ref, model, publication) : undefined\n const visible = (id?: string) => !publication || isPublishedAt(id === undefined ? meta : meta?.[id], publication.at)\n if ((model.kind === 'singleton' || model.kind === 'document') && !visible()) return []\n const prefix = prefixLocale && ref.locale ? `${ref.locale}/` : ''\n const withLocale = (data: Record<string, unknown>): Record<string, unknown> =>\n ref.locale === null ? data : { ...data, locale: ref.locale }\n\n switch (ref.kind) {\n case 'collection': {\n const raw = await readJson<Record<string, Record<string, unknown>>>(ref.filePath)\n if (!raw) return []\n return Object.entries(raw)\n .filter(([id]) => visible(id))\n .toSorted(([a], [b]) => a.localeCompare(b, 'en'))\n .map(([id, fields]) => ({\n id: `${prefix}${id}`,\n data: withLocale({ id, ...fields }),\n filePath: ref.filePath,\n }))\n }\n\n case 'singleton': {\n const raw = await readJson<Record<string, unknown>>(ref.filePath)\n if (!raw) return []\n return [{\n id: prefix ? `${ref.locale}` : model.id,\n data: withLocale(raw),\n filePath: ref.filePath,\n }]\n }\n\n case 'dictionary': {\n // One entry per key: a dictionary key is a semantic address, and a single\n // blob entry would make getEntry() and reference() useless on it.\n const raw = await readJson<Record<string, string>>(ref.filePath)\n if (!raw) return []\n return Object.entries(raw)\n .filter(([id]) => visible(id))\n .toSorted(([a], [b]) => a.localeCompare(b, 'en'))\n .map(([key, value]) => ({\n id: `${prefix}${key}`,\n data: withLocale({ key, value }),\n filePath: ref.filePath,\n }))\n }\n\n case 'document': {\n const text = await readText(ref.filePath)\n if (text === null) return []\n const { frontmatter, body } = parseFrontmatter(text, stringLikeFieldKeys(model))\n const slug = ref.slug ?? model.id\n return [{\n id: `${prefix}${slug}`,\n data: withLocale({ slug, ...frontmatter }),\n body,\n filePath: ref.filePath,\n }]\n }\n\n default:\n return []\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/astro/loader.ts"],"sourcesContent":["// Astro content-layer loader for a `.contentrain` project.\n//\n// `contentrain generate` produces a typed client for application code; Astro\n// wants its content in the content layer instead, so pages can query it with\n// `getCollection()` and render documents with `<Content />`. This loader reads\n// the same `.contentrain` files the generator reads — through the same manifest\n// — so a project never has two answers about what its content is.\n//\n// Astro is NOT a dependency here, not even a peer: the returned object is\n// structurally what `defineCollection({ loader })` accepts, and the context is\n// typed by the small surface this loader actually uses. That keeps\n// @contentrain/query dependency-free and usable from any Astro 5 version.\n\nimport { publicationContext, publicationMeta, isPublishedAt, type PublicationOptions, type PublicationContext } from '../generator/publication.js'\nimport type { ModelDefinition } from '@contentrain/types'\nimport { rewriteEntryMedia } from '@contentrain/types'\nimport { isAbsolute, join, relative } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { ContentFileRef } from '../generator/config-reader.js'\nimport { readProjectManifest } from '../generator/config-reader.js'\nimport { readJson, readText } from '../generator/utils.js'\nimport { parseFrontmatter, stringLikeFieldKeys } from '../shared/frontmatter.js'\nimport { resolveMediaRefsInBody } from '../shared/media.js'\n\n/** The slice of Astro's `LoaderContext` this loader uses. */\nexport interface ContentrainLoaderContext {\n store: {\n clear: () => void\n set: (entry: {\n id: string\n data: Record<string, unknown>\n body?: string\n digest?: string\n rendered?: { html: string, metadata?: Record<string, unknown> }\n filePath?: string\n }) => void\n }\n logger?: { info: (msg: string) => void, warn: (msg: string) => void }\n /** Astro's resolved config; only `root` is read, to make file paths relative. */\n config?: { root?: URL | string }\n generateDigest?: (data: unknown) => string\n parseData?: (entry: { id: string, data: Record<string, unknown>, filePath?: string }) => Promise<Record<string, unknown>>\n renderMarkdown?: (content: string) => Promise<{ html: string, metadata?: Record<string, unknown> }>\n watcher?: { add: (path: string) => void, on: (event: string, cb: (path: string) => void) => void }\n}\n\n/** Structurally Astro's `Loader`. */\nexport interface ContentrainLoader {\n name: string\n load: (context: ContentrainLoaderContext) => Promise<void>\n}\n\nexport interface ContentrainLoaderOptions extends PublicationOptions {\n /** Model id — the `id` in `.contentrain/models/<id>.json`. */\n model: string\n /** Project root holding `.contentrain`. Default: `process.cwd()`. */\n root?: string\n /**\n * Load only this locale. Omitted on an i18n model, every supported locale is\n * loaded and entry ids are prefixed (`en/my-post`) so the two languages of one\n * post do not collide — Astro ids are unique per collection.\n */\n locale?: string\n /**\n * Public media delivery base for resolving relative `media/...` references\n * to absolute URLs. This loader reads `.contentrain` directly — nothing\n * upstream has rewritten them yet — so, unlike CDN-bundled content, they\n * need a resolver here. Applied automatically to image/video/file fields,\n * to `](media/...)` references inside markdown/richtext fields, and to a\n * document's markdown body. Omit to keep values exactly as stored.\n */\n mediaBaseUrl?: string\n}\n\ninterface Entry {\n id: string\n data: Record<string, unknown>\n body?: string\n filePath: string\n}\n\n/**\n * A loader for one Contentrain model.\n *\n * ```ts\n * // src/content.config.ts\n * import { defineCollection } from 'astro:content'\n * import { contentrainLoader } from '@contentrain/query/astro'\n *\n * export const collections = {\n * posts: defineCollection({ loader: contentrainLoader({ model: 'blog-post', locale: 'en' }) }),\n * articles: defineCollection({ loader: contentrainLoader({ model: 'blog-article' }) }),\n * }\n * ```\n *\n * Each model kind maps to the shape Astro can actually use:\n * collection → one entry per record · document → one entry per file, markdown\n * in `body` (and rendered, so `<Content />` works) · dictionary → one entry per\n * key, because a single blob would make `getEntry()` useless · singleton → one\n * entry, id `<model>` or the locale.\n */\nexport function contentrainLoader(options: ContentrainLoaderOptions): ContentrainLoader {\n const root = options.root ?? process.cwd()\n let watching = false\n\n return {\n name: `contentrain:${options.model}`,\n async load(context) {\n const manifest = await readProjectManifest(root)\n const model = manifest.models.find(m => m.id === options.model)\n if (!model) {\n const known = manifest.models.map(m => m.id).join(', ') || 'none'\n throw new Error(\n `contentrainLoader: model \"${options.model}\" not found in ${join(root, '.contentrain', 'models')}. Models here: ${known}.`,\n )\n }\n\n const refs = manifest.contentFiles.filter(ref =>\n ref.modelId === model.id && (options.locale === undefined || ref.locale === options.locale),\n )\n if (options.locale !== undefined && refs.length === 0 && model.i18n) {\n context.logger?.warn(\n `contentrainLoader: model \"${model.id}\" has no content for locale \"${options.locale}\" — collection is empty.`,\n )\n }\n // Prefix ids only when one collection really holds several languages;\n // a single-locale load keeps the natural id so routes stay clean.\n const prefixLocale = model.i18n === true && options.locale === undefined\n\n const publication = publicationContext(root, manifest.config.locales.default, options)\n const perFile = await Promise.all(refs.map(ref => entriesOf(ref, model, prefixLocale, publication)))\n const entries = options.mediaBaseUrl\n ? resolveEntriesMedia(perFile.flat(), model, options.mediaBaseUrl)\n : perFile.flat()\n // Astro rejects an absolute filePath (\"must be relative to the site\n // root\"), and a `.contentrain` outside the Astro root has no relative\n // form it accepts — there we send none rather than a path it refuses.\n const siteRoot = toPath(context.config?.root)\n\n context.store.clear()\n for (const entry of entries) {\n const data = context.parseData\n ? await context.parseData({ id: entry.id, data: entry.data, filePath: entry.filePath })\n : entry.data\n const filePath = siteRoot === undefined ? undefined : relative(siteRoot, entry.filePath)\n context.store.set({\n id: entry.id,\n data,\n ...(filePath === undefined || filePath.startsWith('..') || isAbsolute(filePath) ? {} : { filePath }),\n ...(entry.body === undefined ? {} : { body: entry.body }),\n ...(context.generateDigest ? { digest: context.generateDigest(entry.data) } : {}),\n ...(entry.body !== undefined && context.renderMarkdown\n ? { rendered: await context.renderMarkdown(entry.body) }\n : {}),\n })\n }\n context.logger?.info(`Loaded ${entries.length} ${model.kind} ${entries.length === 1 ? 'entry' : 'entries'} from ${model.id}`)\n\n // Dev server: reload when the files behind this collection change. The\n // listener is registered once — `load` runs again on every change, and a\n // listener per run would multiply the work on every save.\n if (context.watcher && !watching) {\n watching = true\n const paths = new Set(refs.map(ref => ref.filePath))\n for (const path of paths) context.watcher.add(path)\n const metaDir = join(root, '.contentrain', 'meta', model.id)\n context.watcher.add(metaDir)\n context.watcher.add(join(root, '.contentrain', 'models', `${model.id}.json`))\n const reload = (path: string) => {\n if (!paths.has(path) && !path.startsWith(metaDir + '/') && !path.endsWith(`${model.id}.json`)) return\n void this.load(context)\n }\n context.watcher.on('change', reload)\n context.watcher.on('add', reload)\n context.watcher.on('unlink', reload)\n }\n },\n }\n}\n\n/** Astro hands `config.root` as a file URL; tests and other hosts may use a path. */\nfunction toPath(root: URL | string | undefined): string | undefined {\n if (root === undefined) return undefined\n if (typeof root !== 'string') return fileURLToPath(root)\n return root.startsWith('file:') ? fileURLToPath(root) : root\n}\n\nconst MEDIA_BODY_TYPES = new Set(['markdown', 'richtext'])\n\nfunction fieldNamesOfType(model: ModelDefinition, types: Set<string>): string[] {\n if (!model.fields) return []\n return Object.entries(model.fields).filter(([, f]) => types.has(f.type)).map(([name]) => name)\n}\n\n/** Resolves `media/...` references to absolute URLs — image/video/file field\n * values (including those nested inside object/array fields, via the shared\n * `@contentrain/types` engine), `](media/...)` links and inline HTML\n * src/href inside markdown/richtext fields, and a document's markdown body —\n * across every loaded entry. A model with no fields and no body is returned\n * unchanged, entry by entry. */\nfunction resolveEntriesMedia(entries: Entry[], model: ModelDefinition, mediaBaseUrl: string): Entry[] {\n const bodyFields = fieldNamesOfType(model, MEDIA_BODY_TYPES)\n if (!model.fields && model.kind !== 'document') return entries\n\n return entries.map((entry) => {\n const data = model.fields ? rewriteEntryMedia(entry.data, model.fields, mediaBaseUrl) : entry.data\n for (const field of bodyFields) {\n if (field in data) data[field] = resolveMediaRefsInBody(data[field], mediaBaseUrl)\n }\n const body = entry.body === undefined ? undefined : resolveMediaRefsInBody(entry.body, mediaBaseUrl) as string\n return { ...entry, data, ...(body === undefined ? {} : { body }) }\n })\n}\n\nasync function entriesOf(ref: ContentFileRef, model: ModelDefinition, prefixLocale: boolean, publication?: PublicationContext): Promise<Entry[]> {\n const meta = publication ? await publicationMeta(ref, model, publication) : undefined\n const visible = (id?: string) => !publication || isPublishedAt(id === undefined ? meta : meta?.[id], publication.at)\n if ((model.kind === 'singleton' || model.kind === 'document') && !visible()) return []\n const prefix = prefixLocale && ref.locale ? `${ref.locale}/` : ''\n const withLocale = (data: Record<string, unknown>): Record<string, unknown> =>\n ref.locale === null ? data : { ...data, locale: ref.locale }\n\n switch (ref.kind) {\n case 'collection': {\n const raw = await readJson<Record<string, Record<string, unknown>>>(ref.filePath)\n if (!raw) return []\n return Object.entries(raw)\n .filter(([id]) => visible(id))\n .toSorted(([a], [b]) => a.localeCompare(b, 'en'))\n .map(([id, fields]) => ({\n id: `${prefix}${id}`,\n data: withLocale({ id, ...fields }),\n filePath: ref.filePath,\n }))\n }\n\n case 'singleton': {\n const raw = await readJson<Record<string, unknown>>(ref.filePath)\n if (!raw) return []\n return [{\n id: prefix ? `${ref.locale}` : model.id,\n data: withLocale(raw),\n filePath: ref.filePath,\n }]\n }\n\n case 'dictionary': {\n // One entry per key: a dictionary key is a semantic address, and a single\n // blob entry would make getEntry() and reference() useless on it.\n const raw = await readJson<Record<string, string>>(ref.filePath)\n if (!raw) return []\n return Object.entries(raw)\n .filter(([id]) => visible(id))\n .toSorted(([a], [b]) => a.localeCompare(b, 'en'))\n .map(([key, value]) => ({\n id: `${prefix}${key}`,\n data: withLocale({ key, value }),\n filePath: ref.filePath,\n }))\n }\n\n case 'document': {\n const text = await readText(ref.filePath)\n if (text === null) return []\n const { frontmatter, body } = parseFrontmatter(text, stringLikeFieldKeys(model))\n const slug = ref.slug ?? model.id\n return [{\n id: `${prefix}${slug}`,\n data: withLocale({ slug, ...frontmatter }),\n body,\n filePath: ref.filePath,\n }]\n }\n\n default:\n return []\n }\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import { A as FormsClient, C as CommentsClientConfig, D as FormFieldError, E as FormFieldConfig, F as MediaManifest, G as TransportConfig, I as CdnDocumentQuery, J as DocumentDataSource, K as CollectionDataSource, L as CdnDictionaryAccessor, M as MediaAccessor, N as MediaAsset, O as FormSubmitOptions, P as MediaAssetMeta, R as CdnSingletonAccessor, S as CommentsClient, T as FormConfig, W as HttpTransport, X as SingletonDataSource, Y as DocumentIndexEntry, _ as CommentSubmitResult, a as ConversationClient, b as CommentThreadQuery, c as ConversationHistory, d as ConversationSendOptions, f as ConversationToolResult, g as CommentSubmission, h as CommentFieldError, i as ContentrainError, j as FormsClientConfig, k as FormSubmitResult, l as ConversationMessage, m as CommentAuthor, n as ContentrainCDNConfig, o as ConversationClientConfig, p as ConversationUsage, q as DictionaryDataSource, r as createContentrain, s as ConversationContext, t as ContentrainCDNClient, u as ConversationResponse, v as CommentThread, w as PublicComment, x as CommentType, y as CommentThreadConfig, z as CdnCollectionQuery } from "../index-DWpmPtUW.mjs";
1
+ import { A as FormsClient, C as CommentsClientConfig, D as FormFieldError, E as FormFieldConfig, F as MediaManifest, G as TransportConfig, I as CdnDocumentQuery, J as DocumentDataSource, K as CollectionDataSource, L as CdnDictionaryAccessor, M as MediaAccessor, N as MediaAsset, O as FormSubmitOptions, P as MediaAssetMeta, R as CdnSingletonAccessor, S as CommentsClient, T as FormConfig, W as HttpTransport, X as SingletonDataSource, Y as DocumentIndexEntry, _ as CommentSubmitResult, a as ConversationClient, b as CommentThreadQuery, c as ConversationHistory, d as ConversationSendOptions, f as ConversationToolResult, g as CommentSubmission, h as CommentFieldError, i as ContentrainError, j as FormsClientConfig, k as FormSubmitResult, l as ConversationMessage, m as CommentAuthor, n as ContentrainCDNConfig, o as ConversationClientConfig, p as ConversationUsage, q as DictionaryDataSource, r as createContentrain, s as ConversationContext, t as ContentrainCDNClient, u as ConversationResponse, v as CommentThread, w as PublicComment, x as CommentType, y as CommentThreadConfig, z as CdnCollectionQuery } from "../index-ClftI9li.mjs";
2
2
  export { CdnCollectionQuery, CdnDictionaryAccessor, CdnDocumentQuery, CdnSingletonAccessor, CollectionDataSource, CommentAuthor, CommentFieldError, CommentSubmission, CommentSubmitResult, CommentThread, CommentThreadConfig, CommentThreadQuery, CommentType, CommentsClient, CommentsClientConfig, ContentrainCDNClient, ContentrainCDNConfig, ContentrainError, ConversationClient, ConversationClientConfig, ConversationContext, ConversationHistory, ConversationMessage, ConversationResponse, ConversationSendOptions, ConversationToolResult, ConversationUsage, DictionaryDataSource, DocumentDataSource, DocumentIndexEntry, FormConfig, FormFieldConfig, FormFieldError, FormSubmitOptions, FormSubmitResult, FormsClient, FormsClientConfig, HttpTransport, MediaAccessor, MediaAsset, MediaAssetMeta, MediaManifest, PublicComment, SingletonDataSource, TransportConfig, createContentrain };
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_generate = require("./generate-oSHTaAuy.cjs");
2
+ const require_generate = require("./generate-D4NDHZ_K.cjs");
3
3
  let node_path = require("node:path");
4
4
  let node_fs = require("node:fs");
5
5
  //#region src/cli.ts
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as generate } from "./generate-CdLXEj13.mjs";
2
+ import { t as generate } from "./generate-BOi6o7Z5.mjs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { watch } from "node:fs";
5
5
  //#region src/cli.ts
@@ -1,6 +1,6 @@
1
+ import { parseFrontmatterScalar, parseFrontmatterScalarString, splitFrontmatterList } from "@contentrain/types";
1
2
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
- import { parseFrontmatterScalar, parseFrontmatterScalarString, splitFrontmatterList } from "@contentrain/types";
4
4
  //#region src/generator/publication.ts
5
5
  function publicationContext(projectRoot, defaultLocale, options) {
6
6
  if (!options.publishedOnly && options.at === void 0) return void 0;
@@ -292,4 +292,4 @@ function parseValue(raw, forceString = false) {
292
292
  //#endregion
293
293
  export { readJson as a, isPublishedAt as c, readDir as i, publicationContext as l, stringLikeFieldKeys as n, readText as o, readProjectManifest as r, writeText as s, parseFrontmatter as t, publicationMeta as u };
294
294
 
295
- //# sourceMappingURL=frontmatter-BUaCvV-s.mjs.map
295
+ //# sourceMappingURL=frontmatter-D9yVqC5y.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"frontmatter-BUaCvV-s.mjs","names":[],"sources":["../src/generator/publication.ts","../src/generator/utils.ts","../src/generator/config-reader.ts","../src/shared/frontmatter.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport type { ModelDefinition } from '@contentrain/types'\nimport type { ContentFileRef } from './config-reader.js'\n\nexport interface PublicationOptions {\n /** Opt in for public builds; the default keeps the existing editorial client. */\n publishedOnly?: boolean\n /** ISO timestamp for a reproducible public build. Implies publishedOnly. */\n at?: string\n}\n\nexport interface PublicationContext {\n projectRoot: string\n defaultLocale: string\n at: number\n}\n\nexport function publicationContext(projectRoot: string, defaultLocale: string, options: PublicationOptions): PublicationContext | undefined {\n if (!options.publishedOnly && options.at === undefined) return undefined\n if (options.at !== undefined && !/^\\d{4}-\\d{2}-\\d{2}T.*(?:Z|[+-]\\d{2}:\\d{2})$/i.test(options.at))\n throw new Error('Invalid publication --at timestamp: include an ISO date, time and timezone')\n const at = options.at === undefined ? Date.now() : Date.parse(options.at)\n if (!Number.isFinite(at)) throw new Error('Invalid publication --at timestamp')\n return { projectRoot, defaultLocale, at }\n}\n\n/** Canonical meta is independent of custom content paths and locale strategies. */\nexport async function publicationMeta(ref: ContentFileRef, model: ModelDefinition, context: PublicationContext): Promise<Record<string, unknown> | undefined> {\n const locale = model.i18n ? ref.locale ?? context.defaultLocale : context.defaultLocale\n const parts = [context.projectRoot, '.contentrain', 'meta', model.id]\n if (model.kind === 'document') parts.push(ref.slug ?? model.id)\n const path = join(...parts, `${locale}.json`)\n let text: string\n try { text = await readFile(path, 'utf8') }\n catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined\n throw error\n }\n const meta: unknown = JSON.parse(text)\n if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw new Error(`Invalid publication metadata: ${path}`)\n return meta as Record<string, unknown>\n}\n\n/** Legacy content without meta remains visible, matching Studio's CDN contract. */\nexport function isPublishedAt(meta: unknown, at: number): boolean {\n if (meta === undefined) return true\n if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return false\n const value = meta as Record<string, unknown>\n if (value.status && value.status !== 'published') return false\n for (const key of ['publish_at', 'expire_at'] as const) {\n if (value[key] === undefined || value[key] === null) continue\n const time = typeof value[key] === 'string' ? Date.parse(value[key]) : NaN\n if (!Number.isFinite(time)) return false\n if (key === 'publish_at' ? time > at : time <= at) return false\n }\n return true\n}\n","import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises'\nimport { join, dirname } from 'node:path'\n\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n const raw = await readFile(filePath, 'utf-8')\n return JSON.parse(raw) as T\n } catch {\n return null\n }\n}\n\nexport async function readDir(dirPath: string): Promise<string[]> {\n try {\n return await readdir(dirPath)\n } catch {\n return []\n }\n}\n\nexport async function readText(filePath: string): Promise<string | null> {\n try {\n return await readFile(filePath, 'utf-8')\n } catch {\n return null\n }\n}\n\nexport async function writeText(filePath: string, content: string): Promise<void> {\n await mkdir(dirname(filePath), { recursive: true })\n await writeFile(filePath, content, 'utf-8')\n}\n\nexport function contentrainDir(projectRoot: string): string {\n return join(projectRoot, '.contentrain')\n}\n","import type { ContentrainConfig, ModelDefinition, LocaleStrategy } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { readJson, readDir, readText, contentrainDir } from './utils.js'\n\nexport interface ContentFileRef {\n modelId: string\n locale: string | null\n filePath: string\n kind: ModelDefinition['kind']\n /** Extracted slug for document kind (from filename, not frontmatter) */\n slug?: string\n}\n\nexport interface ProjectManifest {\n config: ContentrainConfig\n models: ModelDefinition[]\n contentFiles: ContentFileRef[]\n}\n\nexport async function readProjectManifest(projectRoot: string): Promise<ProjectManifest> {\n const crDir = contentrainDir(projectRoot)\n\n // 1. Read config\n const rawConfig = await readJson<Partial<ContentrainConfig>>(join(crDir, 'config.json'))\n const config: ContentrainConfig = {\n version: rawConfig?.version ?? 1,\n stack: (rawConfig?.stack ?? 'other') as ContentrainConfig['stack'],\n workflow: (rawConfig?.workflow ?? 'auto-merge') as ContentrainConfig['workflow'],\n locales: {\n default: rawConfig?.locales?.default ?? 'en',\n supported: rawConfig?.locales?.supported ?? [rawConfig?.locales?.default ?? 'en'],\n },\n domains: rawConfig?.domains ?? [],\n repository: rawConfig?.repository,\n assets_path: rawConfig?.assets_path,\n cdn: rawConfig?.cdn,\n branchRetention: rawConfig?.branchRetention,\n }\n\n // 2. Read all models (parallel)\n const modelsDir = join(crDir, 'models')\n const modelFiles = (await readDir(modelsDir)).filter(f => f.endsWith('.json'))\n const modelResults = await Promise.all(\n modelFiles.map(file => readJson<ModelDefinition>(join(modelsDir, file))),\n )\n const models = modelResults\n .filter((m): m is ModelDefinition => m != null && Boolean(m.id))\n .toSorted((a, b) => a.id.localeCompare(b.id, 'en'))\n\n // 3. Map content files (parallel per model)\n const allRefs = await Promise.all(\n models.map(model => mapContentFiles(projectRoot, model, config)),\n )\n const contentFiles = allRefs.flat()\n\n return { config, models, contentFiles }\n}\n\nfunction getContentDir(projectRoot: string, model: ModelDefinition): string {\n if (model.content_path) return join(projectRoot, model.content_path)\n return join(contentrainDir(projectRoot), 'content', model.domain, model.id)\n}\n\nfunction getLocaleStrategy(model: ModelDefinition): LocaleStrategy {\n return model.locale_strategy ?? 'file'\n}\n\nfunction jsonFilePath(dir: string, model: ModelDefinition, locale: string): string {\n const strategy = getLocaleStrategy(model)\n switch (strategy) {\n case 'suffix': return join(dir, `${model.id}.${locale}.json`)\n case 'directory': return join(dir, locale, `${model.id}.json`)\n case 'none': return join(dir, `${model.id}.json`)\n case 'file':\n default: return join(dir, `${locale}.json`)\n }\n}\n\nasync function mapContentFiles(\n projectRoot: string,\n model: ModelDefinition,\n config: ContentrainConfig,\n): Promise<ContentFileRef[]> {\n const dir = getContentDir(projectRoot, model)\n\n if (model.kind === 'document') {\n return mapDocumentFiles(dir, model, config)\n }\n\n // JSON-based models (singleton, collection, dictionary)\n if (model.i18n) {\n const results = await Promise.all(\n config.locales.supported.map(async (locale) => {\n const filePath = jsonFilePath(dir, model, locale)\n const content = await readJson(filePath)\n if (content === null) return null\n return { modelId: model.id, locale, filePath, kind: model.kind } as ContentFileRef\n }),\n )\n return results.filter(Boolean) as ContentFileRef[]\n }\n\n // Non-i18n: always data.json\n const filePath = join(dir, 'data.json')\n const content = await readJson(filePath)\n if (content === null) return []\n return [{ modelId: model.id, locale: null, filePath, kind: model.kind }]\n}\n\nasync function mapDocumentFiles(\n dir: string,\n model: ModelDefinition,\n config: ContentrainConfig,\n): Promise<ContentFileRef[]> {\n const strategy = getLocaleStrategy(model)\n\n if (!model.i18n) {\n // Non-i18n documents: {dir}/{slug}.md\n const files = (await readDir(dir)).filter(f => f.endsWith('.md'))\n return files.map(f => ({\n modelId: model.id,\n locale: null,\n filePath: join(dir, f),\n kind: 'document' as const,\n slug: f.replace('.md', ''),\n }))\n }\n\n // i18n documents — resolve per locale in parallel\n const allLocaleRefs = await Promise.all(\n config.locales.supported.map(locale => mapDocumentLocale(dir, model, locale, strategy)),\n )\n return allLocaleRefs.flat()\n}\n\nasync function mapDocumentLocale(\n dir: string,\n model: ModelDefinition,\n locale: string,\n strategy: LocaleStrategy,\n): Promise<ContentFileRef[]> {\n if (strategy === 'file') {\n // Each slug is a directory: {dir}/{slug}/{locale}.md\n const entries = await readDir(dir)\n const results = await Promise.all(\n entries.map(async (entry) => {\n const filePath = join(dir, entry, `${locale}.md`)\n const content = await readText(filePath)\n if (content === null) return null\n return { modelId: model.id, locale, filePath, kind: 'document', slug: entry } as ContentFileRef\n }),\n )\n return results.filter(Boolean) as ContentFileRef[]\n }\n\n if (strategy === 'directory') {\n // {dir}/{locale}/{slug}.md\n const localeDir = join(dir, locale)\n const files = (await readDir(localeDir)).filter(f => f.endsWith('.md'))\n return files.map(f => ({\n modelId: model.id,\n locale,\n filePath: join(localeDir, f),\n kind: 'document' as const,\n slug: f.replace('.md', ''),\n }))\n }\n\n if (strategy === 'suffix') {\n // {dir}/{slug}.{locale}.md\n const suffix = `.${locale}.md`\n const files = (await readDir(dir)).filter(f => f.endsWith(suffix))\n return files.map(f => ({\n modelId: model.id,\n locale,\n filePath: join(dir, f),\n kind: 'document' as const,\n slug: f.slice(0, -suffix.length),\n }))\n }\n\n return []\n}\n","// Frontmatter parsing shared by the client generator and the Astro loader.\n//\n// Both read the same `.contentrain` documents, so the parser lives here rather\n// than being replicated: a divergence would mean the generated client and the\n// Astro collection disagree about the same file's fields.\n//\n// The same argument reaches one level further out. The content engine reads\n// these documents too, through `@contentrain/types`, and for a while this file\n// carried its own copy of the scalar rules — so the two disagreed about what a\n// quoted string with escapes in it says. The *structure* below (nesting,\n// indentation, the model-aware string coercion) is this package's; the scalar\n// grammar is imported, because how a value is spelled on disk is a contract\n// rather than an implementation detail.\n\nimport type { ModelDefinition } from '@contentrain/types'\nimport {\n parseFrontmatterScalar,\n parseFrontmatterScalarString,\n splitFrontmatterList,\n} from '@contentrain/types'\n\n// Field types that map to `string` in the generated types — their frontmatter\n// values must NOT be numerically coerced (e.g. a string SKU \"007\").\nconst STRING_LIKE_TYPES = new Set([\n 'string', 'text', 'email', 'url', 'slug', 'color', 'phone', 'code', 'icon',\n 'markdown', 'richtext', 'date', 'datetime', 'image', 'video', 'file',\n 'select', 'relation',\n])\n\nexport function stringLikeFieldKeys(model: ModelDefinition): Set<string> {\n const keys = new Set<string>()\n if (model.fields) {\n for (const [name, field] of Object.entries(model.fields)) {\n if (STRING_LIKE_TYPES.has(field.type)) keys.add(name)\n }\n }\n return keys\n}\n\n// Minimal frontmatter parser (replicated from MCP pattern)\nexport function parseFrontmatter(text: string, stringKeys: Set<string> = new Set()): { frontmatter: Record<string, unknown>; body: string } {\n const normalized = text.replace(/\\r\\n/g, '\\n')\n const match = normalized.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n if (!match) return { frontmatter: {}, body: normalized }\n\n const fmStr = match[1]!\n const body = match[2]!.trim()\n const frontmatter: Record<string, unknown> = {}\n\n // Stack-based parser that handles nested objects and arrays\n const lines = fmStr.split('\\n')\n const stack: Array<{ obj: Record<string, unknown>; indent: number }> = [{ obj: frontmatter, indent: -1 }]\n\n for (const [lineIndex, line] of lines.entries()) {\n // Skip empty lines\n if (line.trim() === '') continue\n\n // Array item\n const arrayMatch = line.match(/^(\\s*)-\\s+(.*)$/)\n if (arrayMatch) {\n const arrIndent = arrayMatch[1]!.length\n const value = arrayMatch[2]!.trim()\n // Find the parent that owns this array\n while (stack.length > 1 && stack[stack.length - 1]!.indent >= arrIndent) {\n stack.pop()\n }\n const parent = stack[stack.length - 1]!.obj\n const lastKey = Object.keys(parent).pop()\n if (lastKey && Array.isArray(parent[lastKey])) {\n (parent[lastKey] as unknown[]).push(parseValue(value))\n }\n continue\n }\n\n // Key-value pair\n const kvMatch = line.match(/^(\\s*)([\\w][\\w.-]*)\\s*:\\s*(.*)$/)\n if (!kvMatch) continue\n\n const kvIndent = kvMatch[1]!.length\n const key = kvMatch[2]!\n const rawValue = kvMatch[3]!.trim()\n\n // Pop stack to find correct parent based on indentation\n while (stack.length > 1 && stack[stack.length - 1]!.indent >= kvIndent) {\n stack.pop()\n }\n const current = stack[stack.length - 1]!.obj\n\n if (rawValue === '') {\n // A bare `key:` is ambiguous: empty array, empty object, or null. It is\n // resolved by what follows — a list item opens an array, a more-indented\n // key opens an object, and anything else (including end of frontmatter)\n // is an empty array, which is what the canonical reader in\n // `@contentrain/types` returns. Documents written today say `key: []`\n // outright; this path is for the ones written before that.\n //\n // The peek used `lines.indexOf(line)`, which finds the FIRST line with\n // this text rather than this one — two identically spelled lines in one\n // document made the second read the first one's successor.\n let nextLine = ''\n for (let i = lineIndex + 1; i < lines.length; i += 1) {\n if (lines[i]!.trim() !== '') {\n nextLine = lines[i]!\n break\n }\n }\n const nextIndent = nextLine.length - nextLine.trimStart().length\n if (nextLine.trim().startsWith('-')) {\n current[key] = []\n } else if (nextLine !== '' && nextIndent > kvIndent) {\n const nested: Record<string, unknown> = {}\n current[key] = nested\n stack.push({ obj: nested, indent: kvIndent })\n } else {\n current[key] = []\n }\n continue\n }\n\n if (rawValue.startsWith('[') && rawValue.endsWith(']')) {\n const inner = rawValue.slice(1, -1).trim()\n current[key] = inner === ''\n ? []\n : splitFrontmatterList(inner).map(item => parseFrontmatterScalarString(item.trim()))\n continue\n }\n\n // Only top-level keys are matched against the model's declared field types.\n const forceString = current === frontmatter && stringKeys.has(key)\n current[key] = parseValue(rawValue, forceString)\n }\n\n return { frontmatter, body }\n}\n\nfunction parseValue(raw: string, forceString = false): unknown {\n return forceString ? parseFrontmatterScalarString(raw) : parseFrontmatterScalar(raw)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"frontmatter-D9yVqC5y.mjs","names":[],"sources":["../src/generator/publication.ts","../src/generator/utils.ts","../src/generator/config-reader.ts","../src/shared/frontmatter.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport type { ModelDefinition } from '@contentrain/types'\nimport type { ContentFileRef } from './config-reader.js'\n\nexport interface PublicationOptions {\n /** Opt in for public builds; the default keeps the existing editorial client. */\n publishedOnly?: boolean\n /** ISO timestamp for a reproducible public build. Implies publishedOnly. */\n at?: string\n}\n\nexport interface PublicationContext {\n projectRoot: string\n defaultLocale: string\n at: number\n}\n\nexport function publicationContext(projectRoot: string, defaultLocale: string, options: PublicationOptions): PublicationContext | undefined {\n if (!options.publishedOnly && options.at === undefined) return undefined\n if (options.at !== undefined && !/^\\d{4}-\\d{2}-\\d{2}T.*(?:Z|[+-]\\d{2}:\\d{2})$/i.test(options.at))\n throw new Error('Invalid publication --at timestamp: include an ISO date, time and timezone')\n const at = options.at === undefined ? Date.now() : Date.parse(options.at)\n if (!Number.isFinite(at)) throw new Error('Invalid publication --at timestamp')\n return { projectRoot, defaultLocale, at }\n}\n\n/** Canonical meta is independent of custom content paths and locale strategies. */\nexport async function publicationMeta(ref: ContentFileRef, model: ModelDefinition, context: PublicationContext): Promise<Record<string, unknown> | undefined> {\n const locale = model.i18n ? ref.locale ?? context.defaultLocale : context.defaultLocale\n const parts = [context.projectRoot, '.contentrain', 'meta', model.id]\n if (model.kind === 'document') parts.push(ref.slug ?? model.id)\n const path = join(...parts, `${locale}.json`)\n let text: string\n try { text = await readFile(path, 'utf8') }\n catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined\n throw error\n }\n const meta: unknown = JSON.parse(text)\n if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw new Error(`Invalid publication metadata: ${path}`)\n return meta as Record<string, unknown>\n}\n\n/** Legacy content without meta remains visible, matching Studio's CDN contract. */\nexport function isPublishedAt(meta: unknown, at: number): boolean {\n if (meta === undefined) return true\n if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return false\n const value = meta as Record<string, unknown>\n if (value.status && value.status !== 'published') return false\n for (const key of ['publish_at', 'expire_at'] as const) {\n if (value[key] === undefined || value[key] === null) continue\n const time = typeof value[key] === 'string' ? Date.parse(value[key]) : NaN\n if (!Number.isFinite(time)) return false\n if (key === 'publish_at' ? time > at : time <= at) return false\n }\n return true\n}\n","import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises'\nimport { join, dirname } from 'node:path'\n\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n const raw = await readFile(filePath, 'utf-8')\n return JSON.parse(raw) as T\n } catch {\n return null\n }\n}\n\nexport async function readDir(dirPath: string): Promise<string[]> {\n try {\n return await readdir(dirPath)\n } catch {\n return []\n }\n}\n\nexport async function readText(filePath: string): Promise<string | null> {\n try {\n return await readFile(filePath, 'utf-8')\n } catch {\n return null\n }\n}\n\nexport async function writeText(filePath: string, content: string): Promise<void> {\n await mkdir(dirname(filePath), { recursive: true })\n await writeFile(filePath, content, 'utf-8')\n}\n\nexport function contentrainDir(projectRoot: string): string {\n return join(projectRoot, '.contentrain')\n}\n","import type { ContentrainConfig, ModelDefinition, LocaleStrategy } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { readJson, readDir, readText, contentrainDir } from './utils.js'\n\nexport interface ContentFileRef {\n modelId: string\n locale: string | null\n filePath: string\n kind: ModelDefinition['kind']\n /** Extracted slug for document kind (from filename, not frontmatter) */\n slug?: string\n}\n\nexport interface ProjectManifest {\n config: ContentrainConfig\n models: ModelDefinition[]\n contentFiles: ContentFileRef[]\n}\n\nexport async function readProjectManifest(projectRoot: string): Promise<ProjectManifest> {\n const crDir = contentrainDir(projectRoot)\n\n // 1. Read config\n const rawConfig = await readJson<Partial<ContentrainConfig>>(join(crDir, 'config.json'))\n const config: ContentrainConfig = {\n version: rawConfig?.version ?? 1,\n stack: (rawConfig?.stack ?? 'other') as ContentrainConfig['stack'],\n workflow: (rawConfig?.workflow ?? 'auto-merge') as ContentrainConfig['workflow'],\n locales: {\n default: rawConfig?.locales?.default ?? 'en',\n supported: rawConfig?.locales?.supported ?? [rawConfig?.locales?.default ?? 'en'],\n },\n domains: rawConfig?.domains ?? [],\n repository: rawConfig?.repository,\n assets_path: rawConfig?.assets_path,\n cdn: rawConfig?.cdn,\n branchRetention: rawConfig?.branchRetention,\n }\n\n // 2. Read all models (parallel)\n const modelsDir = join(crDir, 'models')\n const modelFiles = (await readDir(modelsDir)).filter(f => f.endsWith('.json'))\n const modelResults = await Promise.all(\n modelFiles.map(file => readJson<ModelDefinition>(join(modelsDir, file))),\n )\n const models = modelResults\n .filter((m): m is ModelDefinition => m != null && Boolean(m.id))\n .toSorted((a, b) => a.id.localeCompare(b.id, 'en'))\n\n // 3. Map content files (parallel per model)\n const allRefs = await Promise.all(\n models.map(model => mapContentFiles(projectRoot, model, config)),\n )\n const contentFiles = allRefs.flat()\n\n return { config, models, contentFiles }\n}\n\nfunction getContentDir(projectRoot: string, model: ModelDefinition): string {\n if (model.content_path) return join(projectRoot, model.content_path)\n return join(contentrainDir(projectRoot), 'content', model.domain, model.id)\n}\n\nfunction getLocaleStrategy(model: ModelDefinition): LocaleStrategy {\n return model.locale_strategy ?? 'file'\n}\n\nfunction jsonFilePath(dir: string, model: ModelDefinition, locale: string): string {\n const strategy = getLocaleStrategy(model)\n switch (strategy) {\n case 'suffix': return join(dir, `${model.id}.${locale}.json`)\n case 'directory': return join(dir, locale, `${model.id}.json`)\n case 'none': return join(dir, `${model.id}.json`)\n case 'file':\n default: return join(dir, `${locale}.json`)\n }\n}\n\nasync function mapContentFiles(\n projectRoot: string,\n model: ModelDefinition,\n config: ContentrainConfig,\n): Promise<ContentFileRef[]> {\n const dir = getContentDir(projectRoot, model)\n\n if (model.kind === 'document') {\n return mapDocumentFiles(dir, model, config)\n }\n\n // JSON-based models (singleton, collection, dictionary)\n if (model.i18n) {\n const results = await Promise.all(\n config.locales.supported.map(async (locale) => {\n const filePath = jsonFilePath(dir, model, locale)\n const content = await readJson(filePath)\n if (content === null) return null\n return { modelId: model.id, locale, filePath, kind: model.kind } as ContentFileRef\n }),\n )\n return results.filter(Boolean) as ContentFileRef[]\n }\n\n // Non-i18n: always data.json\n const filePath = join(dir, 'data.json')\n const content = await readJson(filePath)\n if (content === null) return []\n return [{ modelId: model.id, locale: null, filePath, kind: model.kind }]\n}\n\nasync function mapDocumentFiles(\n dir: string,\n model: ModelDefinition,\n config: ContentrainConfig,\n): Promise<ContentFileRef[]> {\n const strategy = getLocaleStrategy(model)\n\n if (!model.i18n) {\n // Non-i18n documents: {dir}/{slug}.md\n const files = (await readDir(dir)).filter(f => f.endsWith('.md'))\n return files.map(f => ({\n modelId: model.id,\n locale: null,\n filePath: join(dir, f),\n kind: 'document' as const,\n slug: f.replace('.md', ''),\n }))\n }\n\n // i18n documents — resolve per locale in parallel\n const allLocaleRefs = await Promise.all(\n config.locales.supported.map(locale => mapDocumentLocale(dir, model, locale, strategy)),\n )\n return allLocaleRefs.flat()\n}\n\nasync function mapDocumentLocale(\n dir: string,\n model: ModelDefinition,\n locale: string,\n strategy: LocaleStrategy,\n): Promise<ContentFileRef[]> {\n if (strategy === 'file') {\n // Each slug is a directory: {dir}/{slug}/{locale}.md\n const entries = await readDir(dir)\n const results = await Promise.all(\n entries.map(async (entry) => {\n const filePath = join(dir, entry, `${locale}.md`)\n const content = await readText(filePath)\n if (content === null) return null\n return { modelId: model.id, locale, filePath, kind: 'document', slug: entry } as ContentFileRef\n }),\n )\n return results.filter(Boolean) as ContentFileRef[]\n }\n\n if (strategy === 'directory') {\n // {dir}/{locale}/{slug}.md\n const localeDir = join(dir, locale)\n const files = (await readDir(localeDir)).filter(f => f.endsWith('.md'))\n return files.map(f => ({\n modelId: model.id,\n locale,\n filePath: join(localeDir, f),\n kind: 'document' as const,\n slug: f.replace('.md', ''),\n }))\n }\n\n if (strategy === 'suffix') {\n // {dir}/{slug}.{locale}.md\n const suffix = `.${locale}.md`\n const files = (await readDir(dir)).filter(f => f.endsWith(suffix))\n return files.map(f => ({\n modelId: model.id,\n locale,\n filePath: join(dir, f),\n kind: 'document' as const,\n slug: f.slice(0, -suffix.length),\n }))\n }\n\n return []\n}\n","// Frontmatter parsing shared by the client generator and the Astro loader.\n//\n// Both read the same `.contentrain` documents, so the parser lives here rather\n// than being replicated: a divergence would mean the generated client and the\n// Astro collection disagree about the same file's fields.\n//\n// The same argument reaches one level further out. The content engine reads\n// these documents too, through `@contentrain/types`, and for a while this file\n// carried its own copy of the scalar rules — so the two disagreed about what a\n// quoted string with escapes in it says. The *structure* below (nesting,\n// indentation, the model-aware string coercion) is this package's; the scalar\n// grammar is imported, because how a value is spelled on disk is a contract\n// rather than an implementation detail.\n\nimport type { ModelDefinition } from '@contentrain/types'\nimport {\n parseFrontmatterScalar,\n parseFrontmatterScalarString,\n splitFrontmatterList,\n} from '@contentrain/types'\n\n// Field types that map to `string` in the generated types — their frontmatter\n// values must NOT be numerically coerced (e.g. a string SKU \"007\").\nconst STRING_LIKE_TYPES = new Set([\n 'string', 'text', 'email', 'url', 'slug', 'color', 'phone', 'code', 'icon',\n 'markdown', 'richtext', 'date', 'datetime', 'image', 'video', 'file',\n 'select', 'relation',\n])\n\nexport function stringLikeFieldKeys(model: ModelDefinition): Set<string> {\n const keys = new Set<string>()\n if (model.fields) {\n for (const [name, field] of Object.entries(model.fields)) {\n if (STRING_LIKE_TYPES.has(field.type)) keys.add(name)\n }\n }\n return keys\n}\n\n// Minimal frontmatter parser (replicated from MCP pattern)\nexport function parseFrontmatter(text: string, stringKeys: Set<string> = new Set()): { frontmatter: Record<string, unknown>; body: string } {\n const normalized = text.replace(/\\r\\n/g, '\\n')\n const match = normalized.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n if (!match) return { frontmatter: {}, body: normalized }\n\n const fmStr = match[1]!\n const body = match[2]!.trim()\n const frontmatter: Record<string, unknown> = {}\n\n // Stack-based parser that handles nested objects and arrays\n const lines = fmStr.split('\\n')\n const stack: Array<{ obj: Record<string, unknown>; indent: number }> = [{ obj: frontmatter, indent: -1 }]\n\n for (const [lineIndex, line] of lines.entries()) {\n // Skip empty lines\n if (line.trim() === '') continue\n\n // Array item\n const arrayMatch = line.match(/^(\\s*)-\\s+(.*)$/)\n if (arrayMatch) {\n const arrIndent = arrayMatch[1]!.length\n const value = arrayMatch[2]!.trim()\n // Find the parent that owns this array\n while (stack.length > 1 && stack[stack.length - 1]!.indent >= arrIndent) {\n stack.pop()\n }\n const parent = stack[stack.length - 1]!.obj\n const lastKey = Object.keys(parent).pop()\n if (lastKey && Array.isArray(parent[lastKey])) {\n (parent[lastKey] as unknown[]).push(parseValue(value))\n }\n continue\n }\n\n // Key-value pair\n const kvMatch = line.match(/^(\\s*)([\\w][\\w.-]*)\\s*:\\s*(.*)$/)\n if (!kvMatch) continue\n\n const kvIndent = kvMatch[1]!.length\n const key = kvMatch[2]!\n const rawValue = kvMatch[3]!.trim()\n\n // Pop stack to find correct parent based on indentation\n while (stack.length > 1 && stack[stack.length - 1]!.indent >= kvIndent) {\n stack.pop()\n }\n const current = stack[stack.length - 1]!.obj\n\n if (rawValue === '') {\n // A bare `key:` is ambiguous: empty array, empty object, or null. It is\n // resolved by what follows — a list item opens an array, a more-indented\n // key opens an object, and anything else (including end of frontmatter)\n // is an empty array, which is what the canonical reader in\n // `@contentrain/types` returns. Documents written today say `key: []`\n // outright; this path is for the ones written before that.\n //\n // The peek used `lines.indexOf(line)`, which finds the FIRST line with\n // this text rather than this one — two identically spelled lines in one\n // document made the second read the first one's successor.\n let nextLine = ''\n for (let i = lineIndex + 1; i < lines.length; i += 1) {\n if (lines[i]!.trim() !== '') {\n nextLine = lines[i]!\n break\n }\n }\n const nextIndent = nextLine.length - nextLine.trimStart().length\n if (nextLine.trim().startsWith('-')) {\n current[key] = []\n } else if (nextLine !== '' && nextIndent > kvIndent) {\n const nested: Record<string, unknown> = {}\n current[key] = nested\n stack.push({ obj: nested, indent: kvIndent })\n } else {\n current[key] = []\n }\n continue\n }\n\n if (rawValue.startsWith('[') && rawValue.endsWith(']')) {\n const inner = rawValue.slice(1, -1).trim()\n current[key] = inner === ''\n ? []\n : splitFrontmatterList(inner).map(item => parseFrontmatterScalarString(item.trim()))\n continue\n }\n\n // Only top-level keys are matched against the model's declared field types.\n const forceString = current === frontmatter && stringKeys.has(key)\n current[key] = parseValue(rawValue, forceString)\n }\n\n return { frontmatter, body }\n}\n\nfunction parseValue(raw: string, forceString = false): unknown {\n return forceString ? parseFrontmatterScalarString(raw) : parseFrontmatterScalar(raw)\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { a as readJson, c as isPublishedAt, i as readDir, l as publicationContext, n as stringLikeFieldKeys, o as readText, r as readProjectManifest, s as writeText, t as parseFrontmatter, u as publicationMeta } from "./frontmatter-BUaCvV-s.mjs";
1
+ import { a as readJson, c as isPublishedAt, i as readDir, l as publicationContext, n as stringLikeFieldKeys, o as readText, r as readProjectManifest, s as writeText, t as parseFrontmatter, u as publicationMeta } from "./frontmatter-D9yVqC5y.mjs";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  //#region src/generator/type-emitter.ts
@@ -93,8 +93,10 @@ function emitTypes(models, hasMedia = false) {
93
93
  lines.push("export declare function document(model: string): DocumentQuery<Record<string, unknown>>");
94
94
  lines.push("");
95
95
  if (hasMedia) {
96
- lines.push("/** Resolve a stored `media/...` path to its absolute delivery URL. External URLs and already-absolute values pass through unchanged. */");
97
- lines.push("export declare function media(value: string): string");
96
+ lines.push("/** Resolve a stored `media/...` path to its absolute delivery URL. External URLs and already-absolute values pass through unchanged. An optional second argument overrides the configured base for this call — e.g. a value read from framework runtime config. */");
97
+ lines.push("export declare function media(value: string, baseOverride?: string): string");
98
+ lines.push("/** Resolve every `](media/...)` reference inside a markdown string to an absolute delivery URL. Same override rules as `media()`. */");
99
+ lines.push("export declare function mediaBody(markdown: string, baseOverride?: string): string");
98
100
  lines.push("");
99
101
  }
100
102
  lines.push("export interface ContentrainClient {");
@@ -106,6 +108,10 @@ function emitTypes(models, hasMedia = false) {
106
108
  lines.push(" dictionary(model: string): DictionaryAccessor");
107
109
  for (const m of documents) lines.push(` document(model: '${m.id}'): DocumentQuery<${kebabToPascal(m.id)}>`);
108
110
  lines.push(" document(model: string): DocumentQuery<Record<string, unknown>>");
111
+ if (hasMedia) {
112
+ lines.push(" media(value: string, baseOverride?: string): string");
113
+ lines.push(" mediaBody(markdown: string, baseOverride?: string): string");
114
+ }
109
115
  lines.push("}");
110
116
  lines.push("");
111
117
  lines.push("export declare function createContentrainClient(): ContentrainClient");
@@ -263,7 +269,7 @@ async function emitSingleModule(ref, models, publication) {
263
269
  }
264
270
  //#endregion
265
271
  //#region src/generator/runtime-emitter.ts
266
- function emitRuntimeModule(models, dataModules, defaultLocale, cdnBaseUrl) {
272
+ function emitRuntimeModule(models, dataModules, defaultLocale, mediaBaseUrl) {
267
273
  const lines = [
268
274
  "/* eslint-disable */",
269
275
  "/* oxlint-disable */",
@@ -274,8 +280,8 @@ function emitRuntimeModule(models, dataModules, defaultLocale, cdnBaseUrl) {
274
280
  lines.push(`const _defaultLocale = '${defaultLocale}'`);
275
281
  lines.push("");
276
282
  }
277
- if (cdnBaseUrl) {
278
- lines.push(`const _mediaBase = ${JSON.stringify(cdnBaseUrl.replace(/\/+$/, ""))}`);
283
+ if (mediaBaseUrl) {
284
+ lines.push(`const _mediaBase = ${JSON.stringify(mediaBaseUrl.replace(/\/+$/, ""))}`);
279
285
  lines.push("");
280
286
  }
281
287
  for (const dm of dataModules) {
@@ -425,12 +431,19 @@ function emitRuntimeModule(models, dataModules, defaultLocale, cdnBaseUrl) {
425
431
  lines.push("}");
426
432
  lines.push("");
427
433
  }
428
- if (cdnBaseUrl) {
434
+ if (mediaBaseUrl) {
429
435
  lines.push("// ─── Media ───");
430
436
  lines.push("");
431
- lines.push("export function media(value) {");
437
+ lines.push("export function media(value, baseOverride) {");
432
438
  lines.push(" if (typeof value !== 'string' || !value.startsWith('media/')) return value");
433
- lines.push(" return _mediaBase + '/' + value");
439
+ lines.push(" var base = baseOverride ? String(baseOverride).replace(/\\/+$/, '') : _mediaBase");
440
+ lines.push(" return base + '/' + value");
441
+ lines.push("}");
442
+ lines.push("");
443
+ lines.push("export function mediaBody(markdown, baseOverride) {");
444
+ lines.push(" if (typeof markdown !== 'string') return markdown");
445
+ lines.push(" var base = baseOverride ? String(baseOverride).replace(/\\/+$/, '') : _mediaBase");
446
+ lines.push(" return markdown.replace(/\\]\\((media\\/[^)\\s]+)\\)/g, function (_m, p) { return '](' + base + '/' + p + ')' })");
434
447
  lines.push("}");
435
448
  lines.push("");
436
449
  }
@@ -442,7 +455,7 @@ function emitCjsWrapper(models, hasMedia = false) {
442
455
  if (models.some((m) => m.kind === "singleton")) exports.push("singleton");
443
456
  if (models.some((m) => m.kind === "dictionary")) exports.push("dictionary");
444
457
  if (models.some((m) => m.kind === "document")) exports.push("document");
445
- if (hasMedia) exports.push("media");
458
+ if (hasMedia) exports.push("media", "mediaBody");
446
459
  return `/* eslint-disable */
447
460
  /* oxlint-disable */
448
461
  // Auto-generated CJS proxy — delegates to ESM via dynamic import()
@@ -674,11 +687,11 @@ async function generate(options) {
674
687
  const clientDir = join(projectRoot, ".contentrain", "client");
675
688
  const dataDir = join(clientDir, "data");
676
689
  const manifest = await readProjectManifest(projectRoot);
677
- const cdnBaseUrl = options.cdnBaseUrl ?? manifest.config.cdn?.url;
678
- const hasMedia = Boolean(cdnBaseUrl);
690
+ const mediaBaseUrl = options.mediaBaseUrl ?? options.cdnBaseUrl ?? manifest.config.cdn?.url;
691
+ const hasMedia = Boolean(mediaBaseUrl);
679
692
  const dataModules = await emitDataModules(manifest.models, manifest.contentFiles, publicationContext(projectRoot, manifest.config.locales.default, options));
680
693
  const typesContent = emitTypes(manifest.models, hasMedia);
681
- const runtimeContent = emitRuntimeModule(manifest.models, dataModules, manifest.config.locales.default, cdnBaseUrl);
694
+ const runtimeContent = emitRuntimeModule(manifest.models, dataModules, manifest.config.locales.default, mediaBaseUrl);
682
695
  const cjsContent = emitCjsWrapper(manifest.models, hasMedia);
683
696
  const newFileNames = new Set(dataModules.map((dm) => dm.fileName));
684
697
  try {
@@ -708,4 +721,4 @@ async function generate(options) {
708
721
  //#endregion
709
722
  export { generate as t };
710
723
 
711
- //# sourceMappingURL=generate-CdLXEj13.mjs.map
724
+ //# sourceMappingURL=generate-BOi6o7Z5.mjs.map