@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.
- package/dist/content-loader.js +142 -7
- package/package.json +5 -5
- package/src/Content.class.json +40 -0
- package/src/content-loader.ts +213 -12
package/dist/content-loader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
52
|
-
"@jxsuite/schema": "^1.
|
|
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.
|
|
70
|
-
"@jxsuite/runtime": "^1.3.
|
|
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
|
},
|
package/src/Content.class.json
CHANGED
|
@@ -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
|
}
|
package/src/content-loader.ts
CHANGED
|
@@ -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 {
|
|
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 `` 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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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).
|