@pitlane/content 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/codegen.d.mts +40 -0
- package/dist/codegen.mjs +131 -0
- package/dist/hot.d.mts +52 -0
- package/dist/hot.mjs +65 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +313 -0
- package/dist/loaders.d.mts +38 -0
- package/dist/loaders.mjs +255 -0
- package/dist/manifest.d.mts +1 -0
- package/dist/manifest.mjs +4 -0
- package/dist/mdx.d.mts +35 -0
- package/dist/mdx.mjs +389 -0
- package/dist/parse-DoKe2tNa.mjs +57 -0
- package/dist/prebuild.d.mts +65 -0
- package/dist/prebuild.mjs +81 -0
- package/dist/render-U9dXN6f0.mjs +273 -0
- package/dist/satteri.d.mts +41 -0
- package/dist/satteri.mjs +119 -0
- package/dist/symbols-DmXlrDbX.mjs +29 -0
- package/dist/types-xSR1WTBq.d.mts +238 -0
- package/dist/vite.d.mts +18 -0
- package/dist/vite.mjs +262 -0
- package/package.json +104 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { n as missingRenderer, r as parseEntryData, t as ContentError } from "./parse-DoKe2tNa.mjs";
|
|
2
|
+
import { t as HOT_COLLECTION } from "./symbols-DmXlrDbX.mjs";
|
|
3
|
+
import { contentRoot, isPrebuilding, prebuiltManifest, recordConfiguredSatteri, recordPrebuilt, recordWatched, registerPrebuild } from "./prebuild.mjs";
|
|
4
|
+
//#region src/reference.ts
|
|
5
|
+
/**
|
|
6
|
+
* A schema that reads an entry id and outputs a pointer into `collection`.
|
|
7
|
+
*
|
|
8
|
+
* Built by hand rather than with `remix/data-schema`'s `createSchema`, so that
|
|
9
|
+
* reading a JSON file into a validated collection needs no framework. The
|
|
10
|
+
* shape is the one `s.object`, `s.array`, and `s.optional` require of anything
|
|
11
|
+
* they hold, so a reference still composes inside them like any other schema.
|
|
12
|
+
*
|
|
13
|
+
* It does not check that the target exists: the collection it points at may
|
|
14
|
+
* not be populated yet, and populating it to check would turn reading one
|
|
15
|
+
* entry into loading every collection it references. A dangling pointer
|
|
16
|
+
* surfaces as `getEntry` resolving to `undefined`.
|
|
17
|
+
*/
|
|
18
|
+
function reference(collection) {
|
|
19
|
+
return chainable((value, context) => {
|
|
20
|
+
if (typeof value !== "string") {
|
|
21
|
+
let message = `Expected a reference id for collection "${collection}"`;
|
|
22
|
+
return { issues: [context.path.length > 0 ? {
|
|
23
|
+
message,
|
|
24
|
+
path: context.path
|
|
25
|
+
} : { message }] };
|
|
26
|
+
}
|
|
27
|
+
return { value: {
|
|
28
|
+
collection,
|
|
29
|
+
id: value
|
|
30
|
+
} };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Wraps a validator in the chainable surface.
|
|
35
|
+
*
|
|
36
|
+
* `pipe` and `refine` return the schema unchanged. A reference validates a
|
|
37
|
+
* fixed shape, so a check on top of it belongs on the combinator that consumes
|
|
38
|
+
* the resolved entry; they exist because the type requires them and a caller
|
|
39
|
+
* that chains one should get a schema back rather than `undefined`.
|
|
40
|
+
*/
|
|
41
|
+
function chainable(run) {
|
|
42
|
+
let schema = {
|
|
43
|
+
"~standard": {
|
|
44
|
+
version: 1,
|
|
45
|
+
vendor: "pitlane-content",
|
|
46
|
+
validate: (value) => run(value, { path: [] })
|
|
47
|
+
},
|
|
48
|
+
"~run": run,
|
|
49
|
+
pipe: () => schema,
|
|
50
|
+
refine: () => schema,
|
|
51
|
+
transform: (transformer) => chainable((value, context) => {
|
|
52
|
+
let result = run(value, context);
|
|
53
|
+
if (result.issues) return result;
|
|
54
|
+
return { value: transformer(result.value) };
|
|
55
|
+
})
|
|
56
|
+
};
|
|
57
|
+
return schema;
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/store.ts
|
|
61
|
+
/**
|
|
62
|
+
* Collects a collection's entries while its loader runs.
|
|
63
|
+
*
|
|
64
|
+
* An id claimed twice is a conflict rather than a merge. Two files writing one
|
|
65
|
+
* entry means one of them is silently unreachable, which is worth a build
|
|
66
|
+
* failure rather than a coin toss.
|
|
67
|
+
*/
|
|
68
|
+
function collectionStore(collection, root = "") {
|
|
69
|
+
let entries = /* @__PURE__ */ new Map();
|
|
70
|
+
function set(entry) {
|
|
71
|
+
if (entries.has(entry.id)) throw new ContentError(collection, `Duplicate entry id "${entry.id}" in collection "${collection}".`);
|
|
72
|
+
entries.set(entry.id, stored(entry, root));
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
entries,
|
|
76
|
+
set,
|
|
77
|
+
/** Whether any entry carried runtime rendering options. */
|
|
78
|
+
configuredSatteri() {
|
|
79
|
+
return [...entries.values()].some((entry) => entry.satteri !== void 0);
|
|
80
|
+
},
|
|
81
|
+
/** The entries `contentLayer()` reads back, before it compiles any body. */
|
|
82
|
+
serializable() {
|
|
83
|
+
return [...entries.values()].map((entry) => ({
|
|
84
|
+
id: entry.id,
|
|
85
|
+
data: entry.data,
|
|
86
|
+
...entry.filePath === void 0 ? {} : { filePath: entry.filePath },
|
|
87
|
+
...entry.body === void 0 ? {} : { body: entry.body }
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Normalizes one entry for the runtime.
|
|
94
|
+
*
|
|
95
|
+
* `filePath` is stored relative to the project root. Absolute would leak the
|
|
96
|
+
* build machine's layout into every shipped bundle, make two machines produce
|
|
97
|
+
* different artifacts from identical source, and give the field two meanings:
|
|
98
|
+
* the build host's path when prebuilt, the serving host's when not.
|
|
99
|
+
*/
|
|
100
|
+
function stored(entry, root) {
|
|
101
|
+
let body = "body" in entry ? entry.body : void 0;
|
|
102
|
+
let raw = body && "source" in body ? body : void 0;
|
|
103
|
+
let compiled = body && !("source" in body) ? body : void 0;
|
|
104
|
+
return {
|
|
105
|
+
id: entry.id,
|
|
106
|
+
data: entry.data,
|
|
107
|
+
filePath: relativeTo(root, entry.filePath),
|
|
108
|
+
body: raw,
|
|
109
|
+
prebuilt: compiled,
|
|
110
|
+
satteri: "satteri" in entry ? entry.satteri : void 0
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function relativeTo(root, filePath) {
|
|
114
|
+
if (!filePath || !root) return filePath;
|
|
115
|
+
let prefix = `${root.replace(/\\/g, "/").replace(/\/$/, "")}/`;
|
|
116
|
+
let path = filePath.replace(/\\/g, "/");
|
|
117
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/content.ts
|
|
121
|
+
/**
|
|
122
|
+
* Declares a set of content collections.
|
|
123
|
+
*
|
|
124
|
+
* Returns the collection object synchronously without loading entries.
|
|
125
|
+
* Reads and rendering remain asynchronous. Declaration errors throw here.
|
|
126
|
+
*
|
|
127
|
+
* Under `contentLayer()`, registers deferred population work. The plugin
|
|
128
|
+
* awaits it after evaluating the declarations and before emitting the bundle.
|
|
129
|
+
*/
|
|
130
|
+
function createContent(build) {
|
|
131
|
+
let referenced = [];
|
|
132
|
+
let definitions = build({
|
|
133
|
+
collection: (input) => input,
|
|
134
|
+
reference(name) {
|
|
135
|
+
referenced.push(name);
|
|
136
|
+
return reference(name);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
let names = Object.keys(definitions);
|
|
140
|
+
for (let name of referenced) if (!Object.hasOwn(definitions, name)) throw new Error(`Unknown collection "${name}" referenced by createContent; known collections are ${names.join(", ")}.`);
|
|
141
|
+
let manifest = prebuiltManifest();
|
|
142
|
+
let content = {};
|
|
143
|
+
for (let [name, definition] of Object.entries(definitions)) content[name] = wire(name, definition, manifest?.[name]);
|
|
144
|
+
if (isPrebuilding()) registerPrebuild(() => populateEagerly(content, definitions));
|
|
145
|
+
return content;
|
|
146
|
+
}
|
|
147
|
+
function wire(name, definition, entries) {
|
|
148
|
+
let loader = definition.loader;
|
|
149
|
+
if (isContentLoader(loader)) return contentCollection(name, definition.schema, loader, entries);
|
|
150
|
+
return liveCollection(name, definition.schema, loader);
|
|
151
|
+
}
|
|
152
|
+
function isContentLoader(loader) {
|
|
153
|
+
return "load" in loader && typeof loader.load === "function";
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A collection whose loader resolves it in one execution.
|
|
157
|
+
*
|
|
158
|
+
* Its entries come from the manifest when the build prebuilt them, and from the
|
|
159
|
+
* loader otherwise. A successful population is memoized for the life of the
|
|
160
|
+
* process and concurrent readers share one in-flight load; a failed one is not,
|
|
161
|
+
* so one timed-out fetch does not leave the collection broken until a restart.
|
|
162
|
+
*/
|
|
163
|
+
function contentCollection(name, schema, loader, prebuiltEntries) {
|
|
164
|
+
let populated;
|
|
165
|
+
let listeners = /* @__PURE__ */ new Set();
|
|
166
|
+
function entries() {
|
|
167
|
+
if (prebuiltEntries) {
|
|
168
|
+
populated ??= Promise.resolve(fromManifest(name, prebuiltEntries));
|
|
169
|
+
return populated;
|
|
170
|
+
}
|
|
171
|
+
populated ??= runLoader(name, schema, loader).then((stored) => {
|
|
172
|
+
announce(stored);
|
|
173
|
+
return stored;
|
|
174
|
+
}).catch((error) => {
|
|
175
|
+
populated = void 0;
|
|
176
|
+
throw error;
|
|
177
|
+
});
|
|
178
|
+
return populated;
|
|
179
|
+
}
|
|
180
|
+
function announce(stored) {
|
|
181
|
+
if (listeners.size === 0) return;
|
|
182
|
+
let files = [...new Set([...stored.values()].map((entry) => entry.filePath).filter((filePath) => filePath !== void 0))];
|
|
183
|
+
for (let listener of listeners) listener(files);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...queries(name, entries),
|
|
187
|
+
/**
|
|
188
|
+
* The seam `@pitlane/content/hot` reads. Prebuilt entries have no files
|
|
189
|
+
* to watch and cannot be reloaded, so that collection offers nothing.
|
|
190
|
+
*/
|
|
191
|
+
[HOT_COLLECTION]: prebuiltEntries ? void 0 : {
|
|
192
|
+
invalidate() {
|
|
193
|
+
populated = void 0;
|
|
194
|
+
},
|
|
195
|
+
onPopulated(listener) {
|
|
196
|
+
listeners.add(listener);
|
|
197
|
+
return () => listeners.delete(listener);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function runLoader(name, schema, loader) {
|
|
203
|
+
let store = collectionStore(name, contentRoot());
|
|
204
|
+
try {
|
|
205
|
+
await loader.load({
|
|
206
|
+
collection: name,
|
|
207
|
+
root: contentRoot(),
|
|
208
|
+
parseData: (input) => parseEntryData(schema, {
|
|
209
|
+
collection: name,
|
|
210
|
+
...input
|
|
211
|
+
}, input.data),
|
|
212
|
+
store: { set: store.set }
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw annotate(name, error);
|
|
216
|
+
} finally {
|
|
217
|
+
if (isPrebuilding()) recordWatched(loader.watchedPaths?.() ?? []);
|
|
218
|
+
}
|
|
219
|
+
if (isPrebuilding()) {
|
|
220
|
+
recordPrebuilt(name, store.serializable());
|
|
221
|
+
if (store.configuredSatteri()) recordConfiguredSatteri(name);
|
|
222
|
+
}
|
|
223
|
+
return store.entries;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* A collection whose loader answers one query at a time.
|
|
227
|
+
*
|
|
228
|
+
* There is nothing to memoize and nothing the build can inline, so every read
|
|
229
|
+
* asks the loader and validates what comes back.
|
|
230
|
+
*/
|
|
231
|
+
function liveCollection(name, schema, loader) {
|
|
232
|
+
async function validate(entry) {
|
|
233
|
+
return {
|
|
234
|
+
id: entry.id,
|
|
235
|
+
data: await parseEntryData(schema, {
|
|
236
|
+
collection: name,
|
|
237
|
+
id: entry.id
|
|
238
|
+
}, entry.data),
|
|
239
|
+
body: entry.body
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
async getCollection(filter) {
|
|
244
|
+
let live = await loader.loadCollection().catch((error) => {
|
|
245
|
+
throw annotate(name, error);
|
|
246
|
+
});
|
|
247
|
+
return present(name, await Promise.all(live.map(validate)), filter);
|
|
248
|
+
},
|
|
249
|
+
async getEntry(id) {
|
|
250
|
+
let key = typeof id === "string" ? id : id.id;
|
|
251
|
+
let entry = await loader.loadEntry(key).catch((error) => {
|
|
252
|
+
throw annotate(name, error);
|
|
253
|
+
});
|
|
254
|
+
if (!entry) return void 0;
|
|
255
|
+
return view(name, await validate(entry));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function queries(name, entries) {
|
|
260
|
+
return {
|
|
261
|
+
async getCollection(filter) {
|
|
262
|
+
return present(name, [...(await entries()).values()], filter);
|
|
263
|
+
},
|
|
264
|
+
async getEntry(id) {
|
|
265
|
+
let key = typeof id === "string" ? id : id.id;
|
|
266
|
+
let entry = (await entries()).get(key);
|
|
267
|
+
return entry ? view(name, entry) : void 0;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** Sorts by id so a prerender never depends on filesystem order, then filters. */
|
|
272
|
+
function present(name, entries, filter) {
|
|
273
|
+
let views = entries.slice().sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0).map((entry) => view(name, entry));
|
|
274
|
+
return filter ? views.filter((entry) => filter(entry)) : views;
|
|
275
|
+
}
|
|
276
|
+
function view(collection, entry) {
|
|
277
|
+
return {
|
|
278
|
+
id: entry.id,
|
|
279
|
+
collection,
|
|
280
|
+
data: entry.data,
|
|
281
|
+
...entry.filePath === void 0 ? {} : { filePath: entry.filePath },
|
|
282
|
+
render: async () => {
|
|
283
|
+
return await (await import("./render-U9dXN6f0.mjs").catch((cause) => {
|
|
284
|
+
throw missingRenderer(`${collection}/${entry.id}`, cause) ?? cause;
|
|
285
|
+
})).renderedEntry(collection, entry);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function fromManifest(name, entries) {
|
|
290
|
+
let store = collectionStore(name);
|
|
291
|
+
for (let entry of entries) store.set(entry);
|
|
292
|
+
return store.entries;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Frames a loader failure with the collection, unless it already names one.
|
|
296
|
+
*
|
|
297
|
+
* Recognised by type rather than by a substring of the message: matching on
|
|
298
|
+
* wording would couple every thrower to this function's idea of what a framed
|
|
299
|
+
* message looks like, and rewording one of them would double-wrap or skip.
|
|
300
|
+
*/
|
|
301
|
+
function annotate(collection, error) {
|
|
302
|
+
if (error instanceof ContentError) return error;
|
|
303
|
+
return new ContentError(collection, `Failed to load collection "${collection}": ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
304
|
+
}
|
|
305
|
+
/** Under `contentLayer()`, every `ContentLoader` collection loads before the build reads it. */
|
|
306
|
+
async function populateEagerly(content, definitions) {
|
|
307
|
+
for (let [name, definition] of Object.entries(definitions)) {
|
|
308
|
+
if (!isContentLoader(definition.loader)) continue;
|
|
309
|
+
await content[name].getCollection();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
export { createContent };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { l as GenerateIdOptions, o as ContentLoader } from "./types-xSR1WTBq.mjs";
|
|
2
|
+
import { CompileOptions } from "satteri";
|
|
3
|
+
//#region src/loaders/file.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Turns a file's text into whatever it holds.
|
|
6
|
+
*
|
|
7
|
+
* The return type is `unknown` rather than the object-or-array this loader
|
|
8
|
+
* actually needs, because every parser worth passing here — `@std/jsonc`, a
|
|
9
|
+
* TOML reader — is typed as returning a JSON-value union, which a narrower
|
|
10
|
+
* type rejects. `entriesOf` refuses an unusable result by name, so demanding
|
|
11
|
+
* the caller narrow first would buy a wrapper and no safety.
|
|
12
|
+
*/
|
|
13
|
+
type Parser = (text: string) => unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Reads one file holding many entries.
|
|
16
|
+
*
|
|
17
|
+
* Every entry is data rather than a document: a file of records has no body to
|
|
18
|
+
* render, so `render()` on one of these entries is a mistake and says so.
|
|
19
|
+
*/
|
|
20
|
+
declare function file(fileName: string, options?: {
|
|
21
|
+
parser?: Parser;
|
|
22
|
+
}): ContentLoader;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/loaders/glob.d.ts
|
|
25
|
+
/**
|
|
26
|
+
* Reads every file a pattern matches as one entry.
|
|
27
|
+
*
|
|
28
|
+
* `pattern` is an ordinary runtime value — computed, read from the environment,
|
|
29
|
+
* or assembled in a loop — because nothing about it is read statically.
|
|
30
|
+
*/
|
|
31
|
+
declare function glob(options: {
|
|
32
|
+
pattern: string | string[];
|
|
33
|
+
base?: string;
|
|
34
|
+
generateId?: (options: GenerateIdOptions) => string;
|
|
35
|
+
satteri?: CompileOptions;
|
|
36
|
+
}): ContentLoader;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { file, glob };
|
package/dist/loaders.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { t as ContentError } from "./parse-DoKe2tNa.mjs";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
//#region src/loaders/filesystem.ts
|
|
5
|
+
/**
|
|
6
|
+
* `node:fs/promises`, or a diagnostic for a host that has none.
|
|
7
|
+
*
|
|
8
|
+
* The import is dynamic on purpose, against this repository's preference for
|
|
9
|
+
* static ones: a static import of `node:fs` makes a Cloudflare Workers bundle
|
|
10
|
+
* fail to *build*, which tells an author nothing. Reaching this code at all
|
|
11
|
+
* means the collection was never prebuilt, so the fix is a line of Vite config
|
|
12
|
+
* rather than a code change, and only a runtime error can say so.
|
|
13
|
+
*
|
|
14
|
+
* Only the import is guarded, and every import failure means the same thing: a
|
|
15
|
+
* builtin module never touches the disk, so it either resolves or is absent.
|
|
16
|
+
* The reads happen at the call site, which is what keeps an ordinary I/O
|
|
17
|
+
* failure — a `base` that does not exist, a file deleted mid-read — surfacing
|
|
18
|
+
* as itself rather than as a claim about the host.
|
|
19
|
+
*/
|
|
20
|
+
async function filesystem(collection) {
|
|
21
|
+
try {
|
|
22
|
+
return await import("node:fs/promises");
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new ContentError(collection, `Collection "${collection}" has no prebuilt content and no filesystem to read.\nAdd contentLayer() from "@pitlane/content/vite" to your Vite config.`, { cause: error });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/loaders/read.ts
|
|
29
|
+
/**
|
|
30
|
+
* Parses one file, naming it when the parse fails.
|
|
31
|
+
*
|
|
32
|
+
* Malformed frontmatter and malformed JSON are the two most likely authoring
|
|
33
|
+
* mistakes here, and the parsers report a line and column relative to the
|
|
34
|
+
* snippet they were handed. Without the path that is unactionable in a
|
|
35
|
+
* collection of any size.
|
|
36
|
+
*/
|
|
37
|
+
function read(parse, text, filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return parse(text);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
let cause = error instanceof Error ? error.message : String(error);
|
|
42
|
+
throw new Error(`Failed to parse "${filePath}": ${cause}`, { cause: error });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/loaders/file.ts
|
|
47
|
+
/**
|
|
48
|
+
* Reads one file holding many entries.
|
|
49
|
+
*
|
|
50
|
+
* Every entry is data rather than a document: a file of records has no body to
|
|
51
|
+
* render, so `render()` on one of these entries is a mistake and says so.
|
|
52
|
+
*/
|
|
53
|
+
function file(fileName, options) {
|
|
54
|
+
let watched = [];
|
|
55
|
+
return {
|
|
56
|
+
name: "file",
|
|
57
|
+
async load(context) {
|
|
58
|
+
let filePath = resolve(context.root, fileName);
|
|
59
|
+
watched = [filePath];
|
|
60
|
+
let entries = entriesOf(read(parserFor(extname(filePath), options?.parser), await (await filesystem(context.collection)).readFile(filePath, "utf8"), filePath), filePath);
|
|
61
|
+
for (let [id, data] of entries) context.store.set({
|
|
62
|
+
id,
|
|
63
|
+
data: await context.parseData({
|
|
64
|
+
id,
|
|
65
|
+
data,
|
|
66
|
+
filePath
|
|
67
|
+
}),
|
|
68
|
+
filePath
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
watchedPaths: () => watched
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function parserFor(extension, custom) {
|
|
75
|
+
if (custom) return custom;
|
|
76
|
+
if (extension === ".json") return JSON.parse;
|
|
77
|
+
if (extension === ".yaml" || extension === ".yml") return parse;
|
|
78
|
+
throw new Error(`No parser for "${extension}"; pass options.parser to loaders.file.`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The entries a parse result holds, refusing a result that holds none.
|
|
82
|
+
*
|
|
83
|
+
* `JSON.parse` and the YAML parser both answer with whatever the file says,
|
|
84
|
+
* including `null` for an empty document or a bare scalar for a stray one, and
|
|
85
|
+
* `Object.entries` on either of those reports a type error naming neither the
|
|
86
|
+
* file nor the shape the loader wanted.
|
|
87
|
+
*/
|
|
88
|
+
function entriesOf(parsed, filePath) {
|
|
89
|
+
if (Array.isArray(parsed)) return fromArray(parsed, filePath);
|
|
90
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`Parsing "${filePath}" produced ${parsed === null ? "null" : typeof parsed}; loaders.file needs an object whose keys are entry ids, or an array of entries each carrying an id.`);
|
|
91
|
+
return Object.entries(parsed);
|
|
92
|
+
}
|
|
93
|
+
/** An array's items carry their own ids, which are not part of their data. */
|
|
94
|
+
function fromArray(items, filePath) {
|
|
95
|
+
return items.map((item, index) => {
|
|
96
|
+
if (!identified(item)) throw new Error(`Entry at index ${index} of "${filePath}" has no string id; every item in an array needs one to become its entry id.`);
|
|
97
|
+
let { id, ...data } = item;
|
|
98
|
+
return [id, data];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function identified(item) {
|
|
102
|
+
return !!item && typeof item === "object" && "id" in item && typeof item.id === "string";
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/loaders/frontmatter.ts
|
|
106
|
+
/**
|
|
107
|
+
* A leading `---`-fenced block, and everything after it.
|
|
108
|
+
*
|
|
109
|
+
* Anchored with `\A`-style intent rather than `m`: with the multiline flag,
|
|
110
|
+
* `^---` matches at any line start, so an ordinary document containing two
|
|
111
|
+
* thematic breaks reads as a fenced block and loses both its data and the top
|
|
112
|
+
* of its body. Only a fence at position zero is frontmatter.
|
|
113
|
+
*
|
|
114
|
+
* Both fences must own a whole line. The opening one does by the anchor; the
|
|
115
|
+
* closing one does because its preceding newline sits *inside* the data group
|
|
116
|
+
* rather than being optional beside it. A value ending in `---`, or an
|
|
117
|
+
* indented `---` inside a block scalar, therefore cannot close the block. The
|
|
118
|
+
* group is optional only for `---\n---`, an empty block whose closing fence
|
|
119
|
+
* has no data line in front of it; that branch is tried last, so a block with
|
|
120
|
+
* data never loses it.
|
|
121
|
+
*
|
|
122
|
+
* The data match is lazy, so a `---` break after real frontmatter stays in the
|
|
123
|
+
* body rather than ending the block early.
|
|
124
|
+
*/
|
|
125
|
+
let fenced = /^---[^\S\n]*\r?\n(?:([\s\S]*?)\r?\n)?---[^\S\n]*(?:\r?\n|$)/;
|
|
126
|
+
/**
|
|
127
|
+
* Splits a Markdown document's YAML frontmatter from its body.
|
|
128
|
+
*
|
|
129
|
+
* `@pitlane/content` parses frontmatter here rather than reading the one
|
|
130
|
+
* `vite-plugin-satteri` also produces, so a prebuilt and a runtime-resolved
|
|
131
|
+
* collection cannot disagree about an entry's data.
|
|
132
|
+
*/
|
|
133
|
+
function splitFrontmatter(text) {
|
|
134
|
+
let match = fenced.exec(text);
|
|
135
|
+
if (!match) return {
|
|
136
|
+
data: {},
|
|
137
|
+
body: text
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
data: parse(match[1] ?? "") ?? {},
|
|
141
|
+
body: text.slice(match[0].length)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/loaders/glob.ts
|
|
146
|
+
/**
|
|
147
|
+
* The formats `glob` reads.
|
|
148
|
+
*
|
|
149
|
+
* A match in any other format — including a directory a pattern happened to
|
|
150
|
+
* match — is skipped silently. A pattern is an ordinary value and may be as
|
|
151
|
+
* broad as its author likes, so matching a README is not a mistake worth
|
|
152
|
+
* failing a build over.
|
|
153
|
+
*/
|
|
154
|
+
let formats = {
|
|
155
|
+
".md": (text) => markdown("md", text),
|
|
156
|
+
".mdx": (text) => markdown("mdx", text),
|
|
157
|
+
".json": (text) => ({ data: JSON.parse(text) }),
|
|
158
|
+
".yaml": (text) => ({ data: parse(text) ?? {} }),
|
|
159
|
+
".yml": (text) => ({ data: parse(text) ?? {} })
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Reads every file a pattern matches as one entry.
|
|
163
|
+
*
|
|
164
|
+
* `pattern` is an ordinary runtime value — computed, read from the environment,
|
|
165
|
+
* or assembled in a loop — because nothing about it is read statically.
|
|
166
|
+
*/
|
|
167
|
+
function glob(options) {
|
|
168
|
+
let watched = [];
|
|
169
|
+
return {
|
|
170
|
+
name: "glob",
|
|
171
|
+
async load(context) {
|
|
172
|
+
let base = resolve(context.root, options.base ?? ".");
|
|
173
|
+
watched = watchedDirectories(base, options.pattern);
|
|
174
|
+
let fs = await filesystem(context.collection);
|
|
175
|
+
let matches = [];
|
|
176
|
+
for await (let match of fs.glob(options.pattern, { cwd: base })) {
|
|
177
|
+
let entry = match.replaceAll("\\", "/");
|
|
178
|
+
let extension = extname(entry);
|
|
179
|
+
let parse = formats[extension];
|
|
180
|
+
if (!parse) continue;
|
|
181
|
+
let filePath = resolve(base, entry);
|
|
182
|
+
let document = read(parse, await fs.readFile(filePath, "utf8"), filePath);
|
|
183
|
+
matches.push({
|
|
184
|
+
entry,
|
|
185
|
+
extension,
|
|
186
|
+
filePath,
|
|
187
|
+
document
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
for (let { id, filePath, document } of identify(matches, base, options.generateId)) {
|
|
191
|
+
let stored = {
|
|
192
|
+
id,
|
|
193
|
+
data: await context.parseData({
|
|
194
|
+
id,
|
|
195
|
+
data: document.data,
|
|
196
|
+
filePath
|
|
197
|
+
}),
|
|
198
|
+
filePath,
|
|
199
|
+
body: document.body,
|
|
200
|
+
satteri: options.satteri
|
|
201
|
+
};
|
|
202
|
+
context.store.set(stored);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
watchedPaths: () => watched
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** The syntax that makes a pattern segment a glob rather than a literal name. */
|
|
209
|
+
const GLOB_SYNTAX_RE = /[*?[\]{}!()]/;
|
|
210
|
+
/**
|
|
211
|
+
* The directories a pattern can match under, resolved against `base`.
|
|
212
|
+
*
|
|
213
|
+
* Watching `base` would watch the project root whenever `base` is left at its
|
|
214
|
+
* default, and then every save anywhere in the project looks like a content
|
|
215
|
+
* change: a whole extra Vite server and a full browser reload for editing an
|
|
216
|
+
* unrelated module. A pattern's leading literal segments are the narrowest
|
|
217
|
+
* directories that still contain everything it can match.
|
|
218
|
+
*/
|
|
219
|
+
function watchedDirectories(base, pattern) {
|
|
220
|
+
return [...new Set((Array.isArray(pattern) ? pattern : [pattern]).map((one) => resolve(base, literalPrefix(one))))];
|
|
221
|
+
}
|
|
222
|
+
function literalPrefix(pattern) {
|
|
223
|
+
let segments = pattern.replaceAll("\\", "/").split("/");
|
|
224
|
+
let firstGlob = segments.findIndex((segment) => GLOB_SYNTAX_RE.test(segment));
|
|
225
|
+
return segments.slice(0, firstGlob === -1 ? -1 : firstGlob).join("/");
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Pairs each match with its id, in id order.
|
|
229
|
+
*
|
|
230
|
+
* Sorting here is what keeps a collection from inheriting the order its
|
|
231
|
+
* directories happened to be traversed in.
|
|
232
|
+
*/
|
|
233
|
+
function identify(matches, base, generateId) {
|
|
234
|
+
return matches.map(({ entry, extension, filePath, document }) => ({
|
|
235
|
+
id: generateId?.({
|
|
236
|
+
entry,
|
|
237
|
+
base,
|
|
238
|
+
data: document.data
|
|
239
|
+
}) ?? entry.slice(0, -extension.length),
|
|
240
|
+
filePath,
|
|
241
|
+
document
|
|
242
|
+
})).sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
243
|
+
}
|
|
244
|
+
function markdown(format, text) {
|
|
245
|
+
let { data, body } = splitFrontmatter(text);
|
|
246
|
+
return {
|
|
247
|
+
data,
|
|
248
|
+
body: {
|
|
249
|
+
format,
|
|
250
|
+
source: body
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
export { file, glob };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/mdx.d.mts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/mdx.d.ts
|
|
2
|
+
/** One `import` statement an MDX document makes. */
|
|
3
|
+
interface Import {
|
|
4
|
+
specifier: string;
|
|
5
|
+
/** Local name to exported name; `default` names the default import. */
|
|
6
|
+
bindings: Map<string, string>;
|
|
7
|
+
/** A `with { ... }` clause, for a module that needs one to load. */
|
|
8
|
+
attributes?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The imports in one top-level ESM block, and what is left after removing them.
|
|
12
|
+
*
|
|
13
|
+
* The remainder matters twice over. A block holds whatever the author wrote, so
|
|
14
|
+
* an `export const` can sit beside an import and the document's body may use
|
|
15
|
+
* it; dropping the whole block would take the export with the import. And what
|
|
16
|
+
* is left is spliced back into the document, so it has to stay recognisable as
|
|
17
|
+
* ESM: a comment surviving on its own line starts a paragraph, which swallows
|
|
18
|
+
* the export that follows it.
|
|
19
|
+
*
|
|
20
|
+
* Statement boundaries come from `es-module-lexer`, the lexer Vite reads the
|
|
21
|
+
* same imports with. Deciding them here instead means reimplementing JavaScript
|
|
22
|
+
* tokenization: a quote inside a regex literal is not a string, a line starting
|
|
23
|
+
* with `import` inside a template is not a statement, `{ import: "x" }` is a
|
|
24
|
+
* property, and each of those was wrong before the lexer answered it.
|
|
25
|
+
*
|
|
26
|
+
* Anything import-shaped the lexer reports and this cannot read throws rather
|
|
27
|
+
* than being skipped: a skipped import is a component that silently renders as
|
|
28
|
+
* nothing, which is the failure this whole path exists to remove.
|
|
29
|
+
*/
|
|
30
|
+
declare function readEsm(block: string, where: string): Promise<{
|
|
31
|
+
imports: Import[];
|
|
32
|
+
remainder: string;
|
|
33
|
+
}>;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { readEsm };
|