@jxsuite/parser 1.3.0 → 1.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.
@@ -47,8 +47,40 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
47
47
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
48
48
 
49
49
  // src/content-loader.ts
50
- import { existsSync, readFileSync, readdirSync } from "node:fs";
51
- import { basename, extname, resolve } from "node:path";
50
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
51
+ import { basename, dirname, extname, resolve } from "node:path";
52
+
53
+ // ../../packages/schema/src/asset-paths.ts
54
+ function normalizeDir(value) {
55
+ const forward = value.replaceAll("\\", "/");
56
+ return forward.length > 1 && forward.endsWith("/") ? forward.slice(0, -1) : forward;
57
+ }
58
+ function normalizeAssetPrefix(prefix) {
59
+ const forward = normalizeDir(prefix);
60
+ return forward.startsWith("/") ? forward : `/${forward}`;
61
+ }
62
+ function bySpecificity(mounts, key) {
63
+ return [...mounts].toSorted((a, b) => b[key].length - a[key].length);
64
+ }
65
+ function isUnder(path, dir) {
66
+ return path === dir || path.startsWith(`${dir}/`);
67
+ }
68
+ function assetUrlFor(mounts, absolutePath) {
69
+ const path = normalizeDir(absolutePath);
70
+ for (const mount of bySpecificity(mounts, "dir")) {
71
+ const dir = normalizeDir(mount.dir);
72
+ if (!isUnder(path, dir) || path === dir) {
73
+ continue;
74
+ }
75
+ const rest = path.slice(dir.length + 1);
76
+ const encoded = rest.split("/").map((segment) => encodeURIComponent(segment)).join("/");
77
+ return `${normalizeAssetPrefix(mount.urlPrefix)}/${encoded}`;
78
+ }
79
+ return null;
80
+ }
81
+ var URL_TERMINATOR = String.raw`"'\s<>()\\`;
82
+
83
+ // src/content-loader.ts
52
84
  function loadJSONEntries(filePath) {
53
85
  const raw = JSON.parse(readFileSync(filePath, "utf8"));
54
86
  if (Array.isArray(raw)) {
@@ -77,6 +109,95 @@ function discoverJSONFiles(resolvedSource) {
77
109
  return [];
78
110
  }
79
111
  }
112
+ var SECTION_KEY = "content";
113
+ var SAFE_TYPE_NAME = /^[\w.~-]+$/;
114
+ var ASSET_KEYS = ["src", "poster"];
115
+ function isNonRelativeRef(value) {
116
+ return value === "" || value.startsWith("/") || value.startsWith("#") || value.includes("${") || /^[a-z][\w+.-]*:/i.test(value);
117
+ }
118
+ function contentAssetMounts(section, root) {
119
+ const mounts = [];
120
+ for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
121
+ const source = contentTypeDef?.source;
122
+ if (!source || source.startsWith("http://") || source.startsWith("https://")) {
123
+ continue;
124
+ }
125
+ if (!SAFE_TYPE_NAME.test(name)) {
126
+ console.warn(`Content type "${name}": name is not URL-safe — its source directory is not published ` + `at /${SECTION_KEY}/${name}, so content-relative asset references stay unresolved.`);
127
+ continue;
128
+ }
129
+ const resolvedSource = resolve(root, source).split("\\").join("/");
130
+ if (extname(source) || !existsSync(resolvedSource) || !statSync(resolvedSource).isDirectory()) {
131
+ continue;
132
+ }
133
+ mounts.push({ dir: resolvedSource, urlPrefix: `/${SECTION_KEY}/${name}` });
134
+ }
135
+ return mounts;
136
+ }
137
+ function rewriteRef(value, entryDir, mount, onMissing) {
138
+ if (isNonRelativeRef(value)) {
139
+ return null;
140
+ }
141
+ const bare = value.split("#")[0].split("?")[0];
142
+ let target;
143
+ try {
144
+ target = resolve(entryDir, decodeURIComponent(bare));
145
+ } catch {
146
+ return null;
147
+ }
148
+ const url = assetUrlFor([mount], target);
149
+ if (!url) {
150
+ return null;
151
+ }
152
+ if (!existsSync(target)) {
153
+ onMissing(value);
154
+ return null;
155
+ }
156
+ const suffix = value.slice(bare.length);
157
+ return `${url}${suffix}`;
158
+ }
159
+ function rewriteNodeAssets(node, entryDir, mount, onMissing) {
160
+ if (!node || typeof node !== "object") {
161
+ return;
162
+ }
163
+ const record = node;
164
+ const attributes = record.attributes;
165
+ for (const key of ASSET_KEYS) {
166
+ for (const holder of [record, attributes]) {
167
+ const value = holder?.[key];
168
+ if (typeof value === "string") {
169
+ const rewritten = rewriteRef(value, entryDir, mount, onMissing);
170
+ if (rewritten) {
171
+ holder[key] = rewritten;
172
+ }
173
+ }
174
+ }
175
+ }
176
+ const { children } = record;
177
+ if (Array.isArray(children)) {
178
+ for (const child of children) {
179
+ rewriteNodeAssets(child, entryDir, mount, onMissing);
180
+ }
181
+ }
182
+ }
183
+ function rewriteEntryAssets(entries, filePath, mount, typeName, schema) {
184
+ const entryDir = dirname(filePath);
185
+ const uriFields = Object.entries(schema?.properties ?? {}).filter(([, def]) => def?.format === "uri-reference" || def?.items?.format === "uri-reference").map(([field]) => field);
186
+ for (const entry of entries) {
187
+ const onMissing = (value) => console.warn(`Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`);
188
+ for (const child of entry.$children ?? []) {
189
+ rewriteNodeAssets(child, entryDir, mount, onMissing);
190
+ }
191
+ for (const field of uriFields) {
192
+ const value = entry.data[field];
193
+ if (typeof value === "string") {
194
+ entry.data[field] = rewriteRef(value, entryDir, mount, onMissing) ?? value;
195
+ } else if (Array.isArray(value)) {
196
+ entry.data[field] = value.map((item) => typeof item === "string" ? rewriteRef(item, entryDir, mount, onMissing) ?? item : item);
197
+ }
198
+ }
199
+ }
200
+ }
80
201
  function loadContentConfig(projectRoot, projectConfig) {
81
202
  const contentDir = resolve(projectRoot, "content");
82
203
  const config = { content: projectConfig?.content ?? {} };
@@ -84,8 +205,9 @@ function loadContentConfig(projectRoot, projectConfig) {
84
205
  }
85
206
  async function loadContentSection(section, root, formats) {
86
207
  const contentTypes = new Map;
208
+ const mounts = new Map(contentAssetMounts(section, root).map((mount) => [mount.urlPrefix.split("/").pop(), mount]));
87
209
  for (const [name, contentTypeDef] of Object.entries(section)) {
88
- const entries = await loadContentType(name, contentTypeDef, root, formats);
210
+ const entries = await loadContentType(name, contentTypeDef, root, formats, mounts.get(name));
89
211
  contentTypes.set(name, entries);
90
212
  }
91
213
  return contentTypes;
@@ -95,7 +217,7 @@ function getContentTypeElements(projectRoot, contentTypeName, projectConfig) {
95
217
  const def = config.content[contentTypeName];
96
218
  return def?.$elements;
97
219
  }
98
- async function loadContentType(name, contentTypeDef, projectRoot, registry) {
220
+ async function loadContentType(name, contentTypeDef, projectRoot, registry, mount) {
99
221
  const { source } = contentTypeDef;
100
222
  if (!source) {
101
223
  return [];
@@ -139,7 +261,11 @@ async function loadContentType(name, contentTypeDef, projectRoot, registry) {
139
261
  const files2 = discoverJSONFiles(resolvedSource);
140
262
  const entries2 = [];
141
263
  for (const filePath of files2) {
142
- entries2.push(...loadJSONEntries(filePath));
264
+ const fileEntries = loadJSONEntries(filePath);
265
+ if (mount) {
266
+ rewriteEntryAssets(fileEntries, filePath, mount, name, schema);
267
+ }
268
+ entries2.push(...fileEntries);
143
269
  }
144
270
  if (schema) {
145
271
  validateEntries(entries2, schema, name);
@@ -158,11 +284,15 @@ async function loadContentType(name, contentTypeDef, projectRoot, registry) {
158
284
  const sourceRoot = extname(source) ? undefined : resolvedSource;
159
285
  const entries = [];
160
286
  for (const filePath of files) {
161
- entries.push(...await entry.call("load", filePath, {
287
+ const fileEntries = await entry.call("load", filePath, {
162
288
  directiveOptions,
163
289
  schema,
164
290
  ...sourceRoot !== undefined && { sourceRoot }
165
- }));
291
+ });
292
+ if (mount) {
293
+ rewriteEntryAssets(fileEntries, filePath, mount, name, schema);
294
+ }
295
+ entries.push(...fileEntries);
166
296
  }
167
297
  if (schema) {
168
298
  validateEntries(entries, schema, name);
@@ -263,6 +393,9 @@ class Content {
263
393
  resolveContentTypeRefs(data, section);
264
394
  return data;
265
395
  }
396
+ static assets(sectionValue, ctx) {
397
+ return contentAssetMounts(sectionValue ?? {}, ctx.root);
398
+ }
266
399
  static async resolvePaths(pathsDef, ctx) {
267
400
  const entries = ctx.data.get(pathsDef.contentType);
268
401
  if (!entries || entries.length === 0) {
@@ -277,9 +410,11 @@ class Content {
277
410
  }
278
411
  }
279
412
  export {
413
+ rewriteEntryAssets,
280
414
  resolveContentTypeRefs,
281
415
  loadContentSection,
282
416
  loadContentConfig,
283
417
  getContentTypeElements,
418
+ contentAssetMounts,
284
419
  Content
285
420
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Jx markdown parser and external class integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,8 +48,8 @@
48
48
  "upgrade": "bunx npm-check-updates -u && bun install"
49
49
  },
50
50
  "dependencies": {
51
- "@jxsuite/markup": "^0.4.0",
52
- "@jxsuite/schema": "^1.3.0",
51
+ "@jxsuite/markup": "^0.4.1",
52
+ "@jxsuite/schema": "^1.4.0",
53
53
  "@shikijs/langs": "^4.3.1",
54
54
  "@shikijs/themes": "^4.3.1",
55
55
  "glob": "^13.0.6",
@@ -66,8 +66,8 @@
66
66
  "yaml": "^2.9.0"
67
67
  },
68
68
  "devDependencies": {
69
- "@happy-dom/global-registrator": "^20.11.0",
70
- "@jxsuite/runtime": "^1.3.0",
69
+ "@happy-dom/global-registrator": "^20.11.1",
70
+ "@jxsuite/runtime": "^1.3.1",
71
71
  "@types/mdast": "^4.0.4",
72
72
  "ajv": "^8.20.0"
73
73
  },
@@ -40,6 +40,18 @@
40
40
  "type": "array",
41
41
  "description": "Route-param objects, one per content entry: { [param]: value }",
42
42
  "items": { "type": "object" }
43
+ },
44
+ "AssetMounts": {
45
+ "type": "array",
46
+ "description": "Static asset mounts, one per content type: { urlPrefix, dir }",
47
+ "items": {
48
+ "type": "object",
49
+ "properties": {
50
+ "urlPrefix": { "type": "string" },
51
+ "dir": { "type": "string" }
52
+ },
53
+ "required": ["urlPrefix", "dir"]
54
+ }
43
55
  }
44
56
  },
45
57
 
@@ -111,6 +123,34 @@
111
123
  ],
112
124
  "returnType": { "$ref": "#/$defs/returnTypes/PathEntries" },
113
125
  "description": "Expand a content-type $paths source into one route-param object per entry (param defaults to \"slug\", field to \"id\")"
126
+ },
127
+ "assets": {
128
+ "role": "assets",
129
+ "$prototype": "Function",
130
+ "access": "public",
131
+ "scope": "static",
132
+ "identifier": "assets",
133
+ "timing": ["compiler", "server"],
134
+ "parameters": [
135
+ {
136
+ "identifier": "sectionValue",
137
+ "type": { "type": "object" },
138
+ "description": "The project.json `content` section value (content type name → definition)"
139
+ },
140
+ {
141
+ "identifier": "ctx",
142
+ "type": {
143
+ "type": "object",
144
+ "properties": {
145
+ "projectConfig": { "type": "object" },
146
+ "root": { "type": "string" }
147
+ }
148
+ },
149
+ "description": "Host context: { projectConfig, root }"
150
+ }
151
+ ],
152
+ "returnType": { "$ref": "#/$defs/returnTypes/AssetMounts" },
153
+ "description": "Publish each content type's source directory at /content/<type>, so files sitting beside entries (images, downloads) resolve for hosts"
114
154
  }
115
155
  }
116
156
  }
@@ -17,11 +17,18 @@
17
17
  * @module @jxsuite/parser/content-loader
18
18
  */
19
19
 
20
- import { existsSync, readFileSync, readdirSync } from "node:fs";
21
- import { basename, extname, resolve } from "node:path";
20
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { basename, dirname, extname, resolve } from "node:path";
22
+ import { assetUrlFor } from "@jxsuite/schema/asset-paths";
23
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
22
24
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
23
25
  import type { FormatEntry, FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
24
- import type { ContentTypeSchema, JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
26
+ import type {
27
+ ContentTypeSchema,
28
+ JxElement,
29
+ JxMutableNode,
30
+ ProjectConfig,
31
+ } from "@jxsuite/schema/types";
25
32
  import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
26
33
 
27
34
  export type { ContentEntry, ContentLoaderEntry, TocEntry } from "./types.ts";
@@ -101,6 +108,176 @@ function discoverJSONFiles(resolvedSource: string): string[] {
101
108
  }
102
109
  }
103
110
 
111
+ // ─── Asset mounts and content-relative references ────────────────────────────
112
+
113
+ /** The project.json section key this class owns; also the URL namespace for its asset mounts. */
114
+ const SECTION_KEY = "content";
115
+
116
+ /** Content type names safe to place in a URL path segment unescaped. */
117
+ const SAFE_TYPE_NAME = /^[\w.~-]+$/;
118
+
119
+ /** Node keys whose value is a media reference the rewrite may remap. */
120
+ const ASSET_KEYS = ["src", "poster"] as const;
121
+
122
+ /** A value that is already a URL, a template, or a fragment — never a content-relative file. */
123
+ function isNonRelativeRef(value: string): boolean {
124
+ return (
125
+ value === "" ||
126
+ value.startsWith("/") ||
127
+ value.startsWith("#") ||
128
+ value.includes("${") ||
129
+ /^[a-z][\w+.-]*:/i.test(value)
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Asset mounts for a `content` section: every content type whose source is a local **directory**
135
+ * publishes that directory at `/content/<type>`, so files sitting beside the entries (images,
136
+ * downloads) have a stable site URL even when the source lives outside the project root.
137
+ * Single-file and remote sources get no mount — a lone file's siblings are not its collection.
138
+ *
139
+ * @param {ContentSection} section - The project.json `content` section value
140
+ * @param {string} root - Absolute project root directory
141
+ * @returns {AssetMount[]} One mount per eligible content type, in declaration order
142
+ */
143
+ export function contentAssetMounts(section: ContentSection, root: string): AssetMount[] {
144
+ const mounts: AssetMount[] = [];
145
+ for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
146
+ const source = contentTypeDef?.source;
147
+ if (!source || source.startsWith("http://") || source.startsWith("https://")) {
148
+ continue;
149
+ }
150
+ if (!SAFE_TYPE_NAME.test(name)) {
151
+ console.warn(
152
+ `Content type "${name}": name is not URL-safe — its source directory is not published ` +
153
+ `at /${SECTION_KEY}/${name}, so content-relative asset references stay unresolved.`,
154
+ );
155
+ continue;
156
+ }
157
+ const resolvedSource = resolve(root, source).split("\\").join("/");
158
+ if (extname(source) || !existsSync(resolvedSource) || !statSync(resolvedSource).isDirectory()) {
159
+ continue;
160
+ }
161
+ mounts.push({ dir: resolvedSource, urlPrefix: `/${SECTION_KEY}/${name}` });
162
+ }
163
+ return mounts;
164
+ }
165
+
166
+ /**
167
+ * Rewrite one content-relative reference to its mounted URL, or null to leave the value untouched.
168
+ * A value is rewritten only when it is relative, resolves against the entry's own directory to a
169
+ * file that exists, and lands inside the mount — so nothing an existing project authored (a
170
+ * project-root-relative path, an absolute URL, a bound template) changes meaning.
171
+ */
172
+ function rewriteRef(
173
+ value: string,
174
+ entryDir: string,
175
+ mount: AssetMount,
176
+ onMissing: (value: string) => void,
177
+ ): string | null {
178
+ if (isNonRelativeRef(value)) {
179
+ return null;
180
+ }
181
+ const bare = value.split("#")[0]!.split("?")[0]!;
182
+ let target: string;
183
+ try {
184
+ target = resolve(entryDir, decodeURIComponent(bare));
185
+ } catch {
186
+ return null;
187
+ }
188
+ const url = assetUrlFor([mount], target);
189
+ if (!url) {
190
+ return null; // Resolves outside the collection — not ours to publish
191
+ }
192
+ if (!existsSync(target)) {
193
+ onMissing(value);
194
+ return null;
195
+ }
196
+ const suffix = value.slice(bare.length);
197
+ return `${url}${suffix}`;
198
+ }
199
+
200
+ /** Rewrite media references on one node and its children, in place. */
201
+ function rewriteNodeAssets(
202
+ node: JxElement | string | undefined,
203
+ entryDir: string,
204
+ mount: AssetMount,
205
+ onMissing: (value: string) => void,
206
+ ) {
207
+ if (!node || typeof node !== "object") {
208
+ return;
209
+ }
210
+ const record = node as unknown as Record<string, unknown>;
211
+ const attributes = record.attributes as Record<string, unknown> | undefined;
212
+ for (const key of ASSET_KEYS) {
213
+ for (const holder of [record, attributes]) {
214
+ const value = holder?.[key];
215
+ if (typeof value === "string") {
216
+ const rewritten = rewriteRef(value, entryDir, mount, onMissing);
217
+ if (rewritten) {
218
+ holder![key] = rewritten;
219
+ }
220
+ }
221
+ }
222
+ }
223
+ const { children } = record;
224
+ if (Array.isArray(children)) {
225
+ for (const child of children) {
226
+ rewriteNodeAssets(child as JxElement | string, entryDir, mount, onMissing);
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Remap an entry's content-relative asset references onto the content type's mount.
233
+ *
234
+ * Markdown that says `![…](../images/hero.png)` reads correctly in any editor and, after this pass,
235
+ * renders as `/content/<type>/images/hero.png` everywhere the entry is used. Frontmatter is
236
+ * remapped only for fields the content-type schema declares `"format": "uri-reference"` — the
237
+ * schema is what distinguishes a media path from an ordinary string. The raw `body` is never
238
+ * touched: it is the round-trip source Studio writes back to disk.
239
+ *
240
+ * @param {ContentLoaderEntry[]} entries - Entries loaded from one source file (mutated in place)
241
+ * @param {string} filePath - Absolute path of that source file
242
+ * @param {AssetMount} mount - The content type's asset mount
243
+ * @param {string} typeName - Content type name, for warnings
244
+ * @param {ContentTypeSchema} [schema] - Content type schema, for `uri-reference` fields
245
+ */
246
+ export function rewriteEntryAssets(
247
+ entries: ContentLoaderEntry[],
248
+ filePath: string,
249
+ mount: AssetMount,
250
+ typeName: string,
251
+ schema?: ContentTypeSchema,
252
+ ) {
253
+ const entryDir = dirname(filePath);
254
+ const uriFields = Object.entries(schema?.properties ?? {})
255
+ .filter(([, def]) => def?.format === "uri-reference" || def?.items?.format === "uri-reference")
256
+ .map(([field]) => field);
257
+
258
+ for (const entry of entries) {
259
+ const onMissing = (value: string) =>
260
+ console.warn(
261
+ `Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`,
262
+ );
263
+
264
+ for (const child of entry.$children ?? []) {
265
+ rewriteNodeAssets(child, entryDir, mount, onMissing);
266
+ }
267
+
268
+ for (const field of uriFields) {
269
+ const value = entry.data[field];
270
+ if (typeof value === "string") {
271
+ entry.data[field] = rewriteRef(value, entryDir, mount, onMissing) ?? value;
272
+ } else if (Array.isArray(value)) {
273
+ entry.data[field] = value.map((item) =>
274
+ typeof item === "string" ? (rewriteRef(item, entryDir, mount, onMissing) ?? item) : item,
275
+ );
276
+ }
277
+ }
278
+ }
279
+ }
280
+
104
281
  // ─── Content Config ───────────────────────────────────────────────────────────
105
282
 
106
283
  /**
@@ -135,8 +312,11 @@ export async function loadContentSection(
135
312
  formats: FormatRegistry,
136
313
  ): Promise<Map<string, ContentLoaderEntry[]>> {
137
314
  const contentTypes = new Map<string, ContentLoaderEntry[]>();
315
+ const mounts = new Map(
316
+ contentAssetMounts(section, root).map((mount) => [mount.urlPrefix.split("/").pop()!, mount]),
317
+ );
138
318
  for (const [name, contentTypeDef] of Object.entries(section)) {
139
- const entries = await loadContentType(name, contentTypeDef, root, formats);
319
+ const entries = await loadContentType(name, contentTypeDef, root, formats, mounts.get(name));
140
320
  contentTypes.set(name, entries);
141
321
  }
142
322
  return contentTypes;
@@ -168,6 +348,7 @@ export function getContentTypeElements(
168
348
  * @param {ContentTypeDef} contentTypeDef - Content type definition from the `content` section
169
349
  * @param {string} projectRoot - Absolute path to project root directory
170
350
  * @param {FormatRegistry} registry - Format-dispatch view of the extension registry
351
+ * @param {AssetMount} [mount] - The type's asset mount; content-relative references remap onto it
171
352
  * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
172
353
  */
173
354
  async function loadContentType(
@@ -175,6 +356,7 @@ async function loadContentType(
175
356
  contentTypeDef: ContentTypeDef,
176
357
  projectRoot: string,
177
358
  registry: FormatRegistry,
359
+ mount?: AssetMount,
178
360
  ) {
179
361
  const { source } = contentTypeDef;
180
362
  if (!source) {
@@ -243,7 +425,11 @@ async function loadContentType(
243
425
  const files = discoverJSONFiles(resolvedSource);
244
426
  const entries: ContentLoaderEntry[] = [];
245
427
  for (const filePath of files) {
246
- entries.push(...loadJSONEntries(filePath));
428
+ const fileEntries = loadJSONEntries(filePath);
429
+ if (mount) {
430
+ rewriteEntryAssets(fileEntries, filePath, mount, name, schema);
431
+ }
432
+ entries.push(...fileEntries);
247
433
  }
248
434
  if (schema) {
249
435
  validateEntries(entries, schema, name);
@@ -273,13 +459,15 @@ async function loadContentType(
273
459
  const sourceRoot = extname(source) ? undefined : resolvedSource;
274
460
  const entries: ContentLoaderEntry[] = [];
275
461
  for (const filePath of files) {
276
- entries.push(
277
- ...((await entry.call("load", filePath, {
278
- directiveOptions,
279
- schema,
280
- ...(sourceRoot !== undefined && { sourceRoot }),
281
- })) as ContentLoaderEntry[]),
282
- );
462
+ const fileEntries = (await entry.call("load", filePath, {
463
+ directiveOptions,
464
+ schema,
465
+ ...(sourceRoot !== undefined && { sourceRoot }),
466
+ })) as ContentLoaderEntry[];
467
+ if (mount) {
468
+ rewriteEntryAssets(fileEntries, filePath, mount, name, schema);
469
+ }
470
+ entries.push(...fileEntries);
283
471
  }
284
472
 
285
473
  // Validate entries against schema if present
@@ -469,6 +657,19 @@ export class Content {
469
657
  return data;
470
658
  }
471
659
 
660
+ /**
661
+ * Publish each content type's source directory at `/content/<type>` (specs/extensions.md §8.5).
662
+ * Hosts serve, resolve, and copy files through the returned mounts, which is what lets an entry
663
+ * reference `../images/hero.png` and still resolve in a built site, the dev server, and Studio.
664
+ *
665
+ * @param {unknown} sectionValue - The project.json `content` section value
666
+ * @param {{ projectConfig?: ProjectConfig; root: string }} ctx - Host context
667
+ * @returns {AssetMount[]} One mount per content type with a local directory source
668
+ */
669
+ static assets(sectionValue: unknown, ctx: { root: string }): AssetMount[] {
670
+ return contentAssetMounts((sectionValue ?? {}) as ContentSection, ctx.root);
671
+ }
672
+
472
673
  /**
473
674
  * Expand a content-type `$paths` source into route-param maps: one `{ [param]: value }` per
474
675
  * entry, with `param` defaulting to "slug" and `field` to "id" (the entry id).