@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.
@@ -0,0 +1,273 @@
1
+ import { readEsm } from "./mdx.mjs";
2
+ import * as jsxRuntime from "remix/ui/jsx-runtime";
3
+ //#region src/render.ts
4
+ let rendered = /* @__PURE__ */ new WeakMap();
5
+ /**
6
+ * Renders one entry, once.
7
+ *
8
+ * Nothing parses Markdown until this is called, so loading a collection to list
9
+ * its titles never pays for the bodies it does not show. The result is cached
10
+ * per entry, because a page rendered twice should not compile twice.
11
+ */
12
+ function renderedEntry(collection, entry) {
13
+ let existing = rendered.get(entry);
14
+ if (existing) return existing;
15
+ let pending = render(collection, entry);
16
+ rendered.set(entry, pending);
17
+ return pending;
18
+ }
19
+ async function render(collection, entry) {
20
+ if (entry.prebuilt) return fromBundle(entry.prebuilt);
21
+ if (entry.body) return await fromSource(entry, entry.body);
22
+ throw new Error(`Entry "${collection}/${entry.id}" has no renderable content.`);
23
+ }
24
+ /** A body the build compiled: a module for MDX, an HTML string for Markdown. */
25
+ function fromBundle(prebuilt) {
26
+ if (prebuilt.format === "md") return {
27
+ Content: htmlComponent(prebuilt.html),
28
+ headings: headingList(prebuilt.headings)
29
+ };
30
+ return {
31
+ Content: mdxComponent(prebuilt.module.default),
32
+ headings: headingList(prebuilt.module.headings)
33
+ };
34
+ }
35
+ /** A body still in source form, rendered through Sätteri on demand. */
36
+ async function fromSource(entry, body) {
37
+ let satteri = await loadSatteri(entry.filePath ?? entry.id);
38
+ let options = await runtimeOptions(entry);
39
+ if (body.format === "md") {
40
+ let result = await satteri.markdownToHtml(body.source, options);
41
+ return {
42
+ Content: htmlComponent(result.html),
43
+ headings: dataHeadings(result.data)
44
+ };
45
+ }
46
+ let where = entry.filePath ?? entry.id;
47
+ let imported = await importedBindings(satteri, body.source, where, options);
48
+ let compiled = await satteri.mdxToJs(imported.body, {
49
+ ...options,
50
+ jsxImportSource: "remix/ui",
51
+ outputFormat: "function-body"
52
+ });
53
+ let names = [...imported.bindings.keys()];
54
+ let module = compile(names, compiled.code, entry.filePath ?? entry.id)({ ...jsxRuntime }, ...names.map((name) => imported.bindings.get(name)));
55
+ return {
56
+ Content: mdxComponent(module.default, imported.bindings),
57
+ headings: headingList(module.headings)
58
+ };
59
+ }
60
+ /**
61
+ * The compiled body, as a callable function.
62
+ *
63
+ * The engine reports a body it cannot compile against generated source the
64
+ * author never saw: `import.meta` in an expression, which is ordinary under a
65
+ * bundler, arrives as a bare `SyntaxError: Cannot use 'import.meta' outside a
66
+ * module` naming neither the document nor the reason. `readEsm` catches the
67
+ * form written in an `import`/`export` block; this catches every other one.
68
+ */
69
+ function compile(names, code, where) {
70
+ try {
71
+ return new Function("__mdxRuntime", ...names, code);
72
+ } catch (error) {
73
+ let cause = error instanceof Error ? error.message : String(error);
74
+ throw new Error(`"${where}" has a body that cannot be compiled outside a bundler: ${cause}. The document becomes a function body here rather than a module. Add contentLayer() from @pitlane/content/vite so the build compiles this collection.`, { cause: error });
75
+ }
76
+ }
77
+ /**
78
+ * MDX compiles to a plain function of props; a Remix component is a factory
79
+ * returning a render function. This is the bridge, and it is why props on
80
+ * `<Content />` reach the content and `components` overrides work.
81
+ *
82
+ * A document's own imports are merged last, so a caller's `components` cannot
83
+ * replace one. Under a bundler an `import` is a real import and nothing can
84
+ * override it; the two paths have to agree.
85
+ */
86
+ function mdxComponent(exported, imported = /* @__PURE__ */ new Map()) {
87
+ let component = exported;
88
+ if (imported.size === 0) return (handle) => () => component(handle?.props ?? {});
89
+ return (handle) => () => {
90
+ let props = handle?.props ?? {};
91
+ let components = {
92
+ ...props.components,
93
+ ...Object.fromEntries(imported)
94
+ };
95
+ return component({
96
+ ...props,
97
+ components
98
+ });
99
+ };
100
+ }
101
+ /**
102
+ * Markdown arrives as an HTML string, and `innerHTML` is an element prop, so
103
+ * the markup needs an element to land on. The wrapper is unavoidable.
104
+ */
105
+ function htmlComponent(html) {
106
+ return () => () => jsxRuntime.jsx("div", { innerHTML: html });
107
+ }
108
+ function headingList(value) {
109
+ return Array.isArray(value) ? value : [];
110
+ }
111
+ function dataHeadings(data) {
112
+ if (data && typeof data === "object" && "headings" in data) return headingList(data.headings);
113
+ return [];
114
+ }
115
+ /**
116
+ * The runtime rendering options: the application's, with the three things both
117
+ * paths must agree on forced on.
118
+ *
119
+ * `headings` and `rawStyles` are the same plugins `vite-plugin-satteri` runs
120
+ * on the prebuilt path, which is what makes the two produce the same list and
121
+ * the same working CSS. `rawStyles` goes last so it sees the `<style>` a
122
+ * highlighter appended. Frontmatter parsing stays on because a `LiveLoader`
123
+ * may hand back a body that still carries a fence, and rendering that as a
124
+ * horizontal rule would be worse than parsing it away.
125
+ */
126
+ async function runtimeOptions(entry) {
127
+ let { headings, rawStyles } = await import("./satteri.mjs");
128
+ let configured = entry.satteri ?? {};
129
+ return {
130
+ ...configured,
131
+ features: {
132
+ ...configured.features,
133
+ frontmatter: true
134
+ },
135
+ mdastPlugins: [headings(), ...configured.mdastPlugins ?? []],
136
+ hastPlugins: [...configured.hastPlugins ?? [], rawStyles()]
137
+ };
138
+ }
139
+ /**
140
+ * The components an MDX document imported, keyed by the local name it used,
141
+ * and the source with those import statements removed.
142
+ *
143
+ * Specifiers resolve relative to the document, which is what makes the same
144
+ * file behave identically whether the bundler compiled it or this did.
145
+ */
146
+ async function importedBindings(satteri, original, where, options) {
147
+ let source = original.replace(/^\uFEFF/, "");
148
+ let blocks = esmBlocks(satteri.mdxToMdast(source, { features: options.features }));
149
+ let bindings = /* @__PURE__ */ new Map();
150
+ if (blocks.length === 0) return {
151
+ body: source,
152
+ bindings
153
+ };
154
+ let node = await nodeResolution();
155
+ let from = node.pathToFileURL(where);
156
+ let body = source;
157
+ for (let block of [...blocks].reverse()) {
158
+ let { imports, remainder } = await readEsm(block.value, where);
159
+ body = body.slice(0, block.start) + remainder + body.slice(block.end);
160
+ for (let { specifier, bindings: names, attributes } of imports) {
161
+ let module = await importFrom(specifier, from, where, node, attributes);
162
+ for (let [local, exported] of names) {
163
+ if (!(exported in module)) throw missingExport(exported, specifier, where);
164
+ bindings.set(local, module[exported]);
165
+ }
166
+ }
167
+ }
168
+ return {
169
+ body,
170
+ bindings
171
+ };
172
+ }
173
+ /**
174
+ * An export the module does not have would otherwise arrive as `undefined` and
175
+ * render as nothing, which is the failure this whole path exists to remove.
176
+ */
177
+ function missingExport(exported, specifier, where) {
178
+ let name = exported === "default" ? "a default export" : `\`${exported}\``;
179
+ return /* @__PURE__ */ new Error(`"${where}" imports ${name} from "${specifier}", which that module does not export.`);
180
+ }
181
+ /**
182
+ * The top-level ESM blocks, with the offsets Sätteri measured them at.
183
+ *
184
+ * Offsets rather than a text search: a page may quote its own import in a
185
+ * fenced code block, and removing the first textual match would strike the
186
+ * fence and leave the real statement behind.
187
+ *
188
+ * The end comes from the block's own text rather than its reported end, which
189
+ * runs to the start of the next node and so swallows the blank line between
190
+ * them. MDX needs that blank line to tell an ESM block from the body.
191
+ */
192
+ function esmBlocks(tree) {
193
+ let children = tree.children ?? [];
194
+ let blocks = [];
195
+ for (let node of children) {
196
+ if (node.type !== "mdxjsEsm" || typeof node.value !== "string") continue;
197
+ let start = node.position?.start?.offset;
198
+ if (typeof start !== "number") continue;
199
+ blocks.push({
200
+ value: node.value,
201
+ start,
202
+ end: start + node.value.length
203
+ });
204
+ }
205
+ return blocks;
206
+ }
207
+ /**
208
+ * `node:module` and `node:url`, loaded on demand.
209
+ *
210
+ * A static import would put them in the chain `index.ts` pulls in, and the
211
+ * package documents itself as safe to import on any host. Only a runtime
212
+ * `.mdx` render reaches here, and that path is already Node, Bun, and Deno
213
+ * only because it needs `new Function`.
214
+ */
215
+ async function nodeResolution() {
216
+ let [{ createRequire }, { pathToFileURL }] = await Promise.all([import("node:module"), import("node:url")]);
217
+ return {
218
+ createRequire,
219
+ pathToFileURL
220
+ };
221
+ }
222
+ async function importFrom(specifier, from, where, node, attributes) {
223
+ let resolved = specifier.startsWith(".") ? new URL(specifier, from).href : resolveBare(specifier, from, where, node);
224
+ try {
225
+ return await (attributes ? import(resolved, { with: attributes }) : import(resolved));
226
+ } catch (error) {
227
+ let cause = error instanceof Error ? error.message : String(error);
228
+ throw new Error(`"${where}" imports "${specifier}", which could not be loaded: ${cause}`, { cause: error });
229
+ }
230
+ }
231
+ /**
232
+ * Resolves a bare or subpath specifier from the document's own location.
233
+ *
234
+ * `createRequire` rather than `import.meta.resolve`, whose parent argument is
235
+ * not part of the stable API: resolution has to start at the MDX file so that
236
+ * `#/ui/counter.tsx` means what it means in a controller of the same app. The
237
+ * cost is that it resolves under `require` conditions, so a dependency that
238
+ * publishes only an `import` condition fails here — loudly, naming the file
239
+ * and the specifier.
240
+ */
241
+ function resolveBare(specifier, from, where, node) {
242
+ try {
243
+ return node.pathToFileURL(node.createRequire(from).resolve(specifier)).href;
244
+ } catch (error) {
245
+ let cause = error instanceof Error ? error.message : String(error);
246
+ throw new Error(`"${where}" imports "${specifier}", which could not be resolved: ${cause}`, { cause: error });
247
+ }
248
+ }
249
+ /**
250
+ * `satteri` is an optional peer dependency: an application whose content is
251
+ * prebuilt never renders at runtime and so never needs it. The import has to be
252
+ * dynamic for that to be true, and the failure has to name both ways out.
253
+ *
254
+ * The specifier is a variable because a literal one is not dynamic to a
255
+ * bundler — Rolldown resolves it at build time and pulls the whole compiler
256
+ * into the output. Sätteri is a native addon, so a Worker bundle that reaches
257
+ * it fails to build: workerd resolves under the `browser` condition, which
258
+ * sends `satteri` to its WASM binding and on to an optional package that is
259
+ * not installed on a native platform.
260
+ */
261
+ async function loadSatteri(where) {
262
+ let specifier = "satteri";
263
+ try {
264
+ return await import(
265
+ /* @vite-ignore */
266
+ specifier
267
+ );
268
+ } catch {
269
+ throw new Error(`Rendering "${where}" needs the optional peer dependency "satteri"; install it, or add contentLayer() from @pitlane/content/vite so the build compiles this collection.`);
270
+ }
271
+ }
272
+ //#endregion
273
+ export { renderedEntry };
@@ -0,0 +1,41 @@
1
+ import { HastPluginEntry, MdastPluginEntry } from "satteri";
2
+ //#region src/satteri.d.ts
3
+ /**
4
+ * Collects every heading of a document as `{ depth, slug, text }`, publishes the
5
+ * list as `data.headings`, and gives each heading an `id` matching its slug so an
6
+ * anchor link lands on it. On MDX the list is also appended to the tree as
7
+ * `export const headings`, so the compiled module carries its own table of
8
+ * contents.
9
+ *
10
+ * The plugin is a factory rather than a definition because Sätteri resolves a
11
+ * factory once per compile: a single `headings()` shared by a whole build gets
12
+ * fresh slug state per document, instead of the second document's `notes`
13
+ * reading `notes-1`.
14
+ *
15
+ * The definition is written out rather than passed through Sätteri's
16
+ * `defineMdastPlugin`, whose entire body checks that `name` is set. Calling it
17
+ * would make this module import the `satteri` package, and `render.ts` imports
18
+ * this module: a Worker bundle whose collections `contentLayer()` already prebuilt
19
+ * would then have to resolve a native addon it can never load.
20
+ */
21
+ declare function headings(): MdastPluginEntry;
22
+ /**
23
+ * Hands a `<style>` element's CSS to Remix as markup rather than as text, so
24
+ * the stylesheet survives being rendered.
25
+ *
26
+ * `@remix-run/ui` escapes `&`, `<`, and `>` in the text children of every
27
+ * element except `<script>`, and emits an `innerHTML` prop verbatim. `<style>`
28
+ * is a raw-text element, which is exactly the case where a browser does not
29
+ * undo that escaping: a rule written `pre > code` reaches the page as
30
+ * `pre &gt; code`, matches nothing, and says nothing. Expressive Code is how
31
+ * most applications meet this, because it ships its theme as one such element.
32
+ *
33
+ * A workaround, not a design. The fix belongs in `@remix-run/ui`, which
34
+ * already special-cases `<script>` and should treat `<style>` the same way.
35
+ * `<script>` is deliberately not touched here: routing it through `innerHTML`
36
+ * would skip `escapeScriptTextContent`, which keeps a `</script>` inside a
37
+ * string from ending the element early.
38
+ */
39
+ declare function rawStyles(): HastPluginEntry;
40
+ //#endregion
41
+ export { headings, rawStyles };
@@ -0,0 +1,119 @@
1
+ //#region src/satteri.ts
2
+ /**
3
+ * The base slug for a heading with no letters or digits at all (`## ---`). An
4
+ * empty `id` is invalid HTML and makes the anchor a bare `#`, which lands
5
+ * nowhere; a fixed word plus the usual collision suffix keeps such headings
6
+ * addressable and distinct.
7
+ */
8
+ const FALLBACK_SLUG = "heading";
9
+ /**
10
+ * The slug for `text`, kept distinct from every slug `taken` already holds by
11
+ * suffixing `-1`, `-2`, … The map remembers the last suffix tried for a base so
12
+ * a document of a hundred identical headings stays linear, and the loop covers
13
+ * the case where the suffixed candidate is itself a real heading's slug.
14
+ */
15
+ function uniqueSlug(text, taken) {
16
+ let base = text.toLowerCase().replace(/[^\p{L}\p{N}\p{M}]+/gu, "-").replace(/^-+|-+$/g, "");
17
+ if (!base) base = FALLBACK_SLUG;
18
+ let suffix = taken.get(base) ?? 0;
19
+ let slug = base;
20
+ while (taken.has(slug)) {
21
+ suffix += 1;
22
+ slug = `${base}-${suffix}`;
23
+ }
24
+ taken.set(base, suffix);
25
+ if (!taken.has(slug)) taken.set(slug, 0);
26
+ return slug;
27
+ }
28
+ /**
29
+ * Collects every heading of a document as `{ depth, slug, text }`, publishes the
30
+ * list as `data.headings`, and gives each heading an `id` matching its slug so an
31
+ * anchor link lands on it. On MDX the list is also appended to the tree as
32
+ * `export const headings`, so the compiled module carries its own table of
33
+ * contents.
34
+ *
35
+ * The plugin is a factory rather than a definition because Sätteri resolves a
36
+ * factory once per compile: a single `headings()` shared by a whole build gets
37
+ * fresh slug state per document, instead of the second document's `notes`
38
+ * reading `notes-1`.
39
+ *
40
+ * The definition is written out rather than passed through Sätteri's
41
+ * `defineMdastPlugin`, whose entire body checks that `name` is set. Calling it
42
+ * would make this module import the `satteri` package, and `render.ts` imports
43
+ * this module: a Worker bundle whose collections `contentLayer()` already prebuilt
44
+ * would then have to resolve a native addon it can never load.
45
+ */
46
+ function headings() {
47
+ return (factoryContext) => {
48
+ let collected = [];
49
+ let taken = /* @__PURE__ */ new Map();
50
+ factoryContext.data.headings = collected;
51
+ return {
52
+ name: "pitlane-headings",
53
+ heading(node, context) {
54
+ let text = context.textContent(node);
55
+ let slug = uniqueSlug(text, taken);
56
+ collected.push({
57
+ depth: node.depth,
58
+ slug,
59
+ text
60
+ });
61
+ context.setProperty(node, "data", { hProperties: { id: slug } });
62
+ },
63
+ after(root, context) {
64
+ if (context.sourceFormat !== "mdx") return;
65
+ context.appendChild(root, {
66
+ type: "mdxjsEsm",
67
+ value: `export const headings = ${JSON.stringify(collected)};`
68
+ });
69
+ }
70
+ };
71
+ };
72
+ }
73
+ /**
74
+ * Hands a `<style>` element's CSS to Remix as markup rather than as text, so
75
+ * the stylesheet survives being rendered.
76
+ *
77
+ * `@remix-run/ui` escapes `&`, `<`, and `>` in the text children of every
78
+ * element except `<script>`, and emits an `innerHTML` prop verbatim. `<style>`
79
+ * is a raw-text element, which is exactly the case where a browser does not
80
+ * undo that escaping: a rule written `pre > code` reaches the page as
81
+ * `pre &gt; code`, matches nothing, and says nothing. Expressive Code is how
82
+ * most applications meet this, because it ships its theme as one such element.
83
+ *
84
+ * A workaround, not a design. The fix belongs in `@remix-run/ui`, which
85
+ * already special-cases `<script>` and should treat `<style>` the same way.
86
+ * `<script>` is deliberately not touched here: routing it through `innerHTML`
87
+ * would skip `escapeScriptTextContent`, which keeps a `<\/script>` inside a
88
+ * string from ending the element early.
89
+ */
90
+ function rawStyles() {
91
+ return (factoryContext) => {
92
+ if (factoryContext.sourceFormat !== "mdx") return false;
93
+ return {
94
+ name: "pitlane-raw-styles",
95
+ element: {
96
+ filter: ["style"],
97
+ visit(node) {
98
+ let css = "";
99
+ for (let child of node.children) {
100
+ if (child.type !== "text") return;
101
+ css += child.value;
102
+ }
103
+ if (!css) return;
104
+ return {
105
+ type: "element",
106
+ tagName: "style",
107
+ properties: {
108
+ ...node.properties,
109
+ innerHTML: css
110
+ },
111
+ children: []
112
+ };
113
+ }
114
+ }
115
+ };
116
+ };
117
+ }
118
+ //#endregion
119
+ export { headings, rawStyles };
@@ -0,0 +1,29 @@
1
+ //#region src/symbols.ts
2
+ /**
3
+ * The two globals `contentLayer()` and the runtime use to find each other.
4
+ *
5
+ * They are named here rather than at each site because a drifted spelling fails
6
+ * in the quietest possible way: the emitted manifest assigns one symbol,
7
+ * `prebuiltManifest()` reads another, every collection silently falls back to
8
+ * its loader, and that works on Node — so the mistake only surfaces on a host
9
+ * with no filesystem, in production.
10
+ *
11
+ * `manifest.ts` cannot import these through `prebuild.ts`, which imports it
12
+ * back for its side effect, so this module holds nothing but the names.
13
+ */
14
+ /** Set by `contentLayer()` before it runs the content entry; read to decide whether to prebuild. */
15
+ const PREBUILD_CHANNEL = Symbol.for("pitlane.content.prebuild");
16
+ /** Assigned by the module `contentLayer()` emits in place of `manifest.ts`. */
17
+ const PREBUILT_MANIFEST = Symbol.for("pitlane.content.manifest");
18
+ /** The spelling the emitted module writes, which has to match {@link PREBUILT_MANIFEST}. */
19
+ const PREBUILT_MANIFEST_KEY = "pitlane.content.manifest";
20
+ /**
21
+ * Hangs the development watcher's handle off a collection.
22
+ *
23
+ * A symbol rather than a method because `invalidate()` on the public
24
+ * `Collection` would let any caller empty a collection someone else is
25
+ * reading. `@pitlane/content/hot` is the only intended reader.
26
+ */
27
+ const HOT_COLLECTION = Symbol.for("pitlane.content.hot");
28
+ //#endregion
29
+ export { PREBUILT_MANIFEST_KEY as i, PREBUILD_CHANNEL as n, PREBUILT_MANIFEST as r, HOT_COLLECTION as t };