@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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Handle, RemixNode } from "remix/ui";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Standard Schema v1, declared here rather than depended on.
|
|
5
|
+
*
|
|
6
|
+
* The specification is frozen at version 1 and the interface is the whole of
|
|
7
|
+
* it, so a dependency would buy nothing this comment does not. Any conforming
|
|
8
|
+
* schema — `remix/data-schema`, Zod, Valibot, ArkType — satisfies it
|
|
9
|
+
* structurally.
|
|
10
|
+
*
|
|
11
|
+
* @see https://standardschema.dev
|
|
12
|
+
*/
|
|
13
|
+
interface StandardSchemaV1<Output = unknown> {
|
|
14
|
+
readonly "~standard": {
|
|
15
|
+
readonly version: 1;
|
|
16
|
+
readonly vendor: string;
|
|
17
|
+
readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
|
|
18
|
+
readonly types?: {
|
|
19
|
+
readonly output: Output;
|
|
20
|
+
} | undefined;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** The result of validating a value against a {@link StandardSchemaV1}. */
|
|
24
|
+
type StandardSchemaResult<Output> = {
|
|
25
|
+
readonly value: Output;
|
|
26
|
+
readonly issues?: undefined;
|
|
27
|
+
} | {
|
|
28
|
+
readonly issues: readonly StandardSchemaIssue[];
|
|
29
|
+
};
|
|
30
|
+
/** One validation failure, with the path into the input that produced it. */
|
|
31
|
+
interface StandardSchemaIssue {
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly path?: readonly (PropertyKey | {
|
|
34
|
+
readonly key: PropertyKey;
|
|
35
|
+
})[] | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The output type a schema produces. */
|
|
38
|
+
type InferSchema<S> = S extends StandardSchemaV1<infer Output> ? Output : never;
|
|
39
|
+
/**
|
|
40
|
+
* A resolved pointer from one entry to another, produced by
|
|
41
|
+
* `c.reference(collection)`.
|
|
42
|
+
*
|
|
43
|
+
* The `collection` type parameter is what stops a `Reference<"blog">` reaching
|
|
44
|
+
* `content.authors.getEntry`.
|
|
45
|
+
*/
|
|
46
|
+
interface Reference<C extends string> {
|
|
47
|
+
collection: C;
|
|
48
|
+
id: string;
|
|
49
|
+
}
|
|
50
|
+
/** A reusable check, shaped as `remix/data-schema`'s `Check` is. */
|
|
51
|
+
interface SchemaCheck<Output> {
|
|
52
|
+
check: (value: Output) => boolean;
|
|
53
|
+
message?: string;
|
|
54
|
+
code?: string;
|
|
55
|
+
values?: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
/** Where a value sits in the input being validated, and how it is being parsed. */
|
|
58
|
+
interface RunContext {
|
|
59
|
+
path: NonNullable<StandardSchemaIssue["path"]>;
|
|
60
|
+
options?: unknown;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The surface `remix/data-schema`'s combinators require of a schema they hold.
|
|
64
|
+
*
|
|
65
|
+
* `s.object`, `s.array`, and `s.optional` call `~run` rather than the Standard
|
|
66
|
+
* Schema `validate`, so a bare Standard Schema does not compose inside them and
|
|
67
|
+
* a reference that does not compose is not worth having. Declared here rather
|
|
68
|
+
* than imported: one type import would make Remix a requirement of reading a
|
|
69
|
+
* JSON file. Any schema satisfying it is accepted, whoever built it.
|
|
70
|
+
*/
|
|
71
|
+
interface ChainableSchema<Input, Output> {
|
|
72
|
+
readonly "~standard": {
|
|
73
|
+
readonly version: 1;
|
|
74
|
+
readonly vendor: string;
|
|
75
|
+
readonly validate: (value: unknown) => StandardSchemaResult<Output>;
|
|
76
|
+
readonly types?: {
|
|
77
|
+
readonly input: Input;
|
|
78
|
+
readonly output: Output;
|
|
79
|
+
} | undefined;
|
|
80
|
+
};
|
|
81
|
+
"~run": (value: unknown, context: RunContext) => StandardSchemaResult<Output>;
|
|
82
|
+
pipe: (...checks: SchemaCheck<Output>[]) => ChainableSchema<Input, Output>;
|
|
83
|
+
refine: (predicate: (value: Output) => boolean, message?: string) => ChainableSchema<Input, Output>;
|
|
84
|
+
transform: <Next>(transformer: (value: Output) => Next) => ChainableSchema<Input, Next>;
|
|
85
|
+
}
|
|
86
|
+
/** The schema `c.reference(collection)` returns. */
|
|
87
|
+
type ReferenceSchema<C extends string> = ChainableSchema<string, Reference<C>>;
|
|
88
|
+
/** A heading collected from a Markdown or MDX document. */
|
|
89
|
+
interface Heading {
|
|
90
|
+
depth: number;
|
|
91
|
+
slug: string;
|
|
92
|
+
text: string;
|
|
93
|
+
}
|
|
94
|
+
/** What `entry.render()` resolves to. */
|
|
95
|
+
interface RenderedEntry {
|
|
96
|
+
Content: (handle: Handle<Record<string, unknown>>) => () => RemixNode;
|
|
97
|
+
headings: Heading[];
|
|
98
|
+
}
|
|
99
|
+
/** One entry of a collection, as a caller sees it. */
|
|
100
|
+
interface Entry<Data> {
|
|
101
|
+
id: string;
|
|
102
|
+
collection: string;
|
|
103
|
+
data: Data;
|
|
104
|
+
filePath?: string;
|
|
105
|
+
render(): Promise<RenderedEntry>;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One entry of `collection`, named the way an application thinks of it:
|
|
109
|
+
* `CollectionEntry<typeof content.blog>`.
|
|
110
|
+
*
|
|
111
|
+
* {@link Entry} is keyed by the shape of the data, which is what the query
|
|
112
|
+
* methods resolve to and all this package needs internally. An application
|
|
113
|
+
* has the collection rather than the shape, and pulling one out of the other
|
|
114
|
+
* is a conditional type every consumer would otherwise write for itself.
|
|
115
|
+
*/
|
|
116
|
+
type CollectionEntry<C> = C extends Collection<string, infer Data> ? Entry<Data> : never;
|
|
117
|
+
/** The query surface of one collection. */
|
|
118
|
+
interface Collection<Name extends string, Data> {
|
|
119
|
+
getCollection(filter?: (entry: Entry<Data>) => unknown): Promise<Entry<Data>[]>;
|
|
120
|
+
getEntry(id: string | Reference<Name>): Promise<Entry<Data> | undefined>;
|
|
121
|
+
}
|
|
122
|
+
/** The raw source of an entry's document, before anything renders it. */
|
|
123
|
+
interface EntryBody {
|
|
124
|
+
format: "md" | "mdx";
|
|
125
|
+
source: string;
|
|
126
|
+
}
|
|
127
|
+
/** What a {@link ContentLoader} writes into the store for one entry. */
|
|
128
|
+
interface LoadedEntry {
|
|
129
|
+
id: string;
|
|
130
|
+
data: unknown;
|
|
131
|
+
filePath?: string;
|
|
132
|
+
body?: EntryBody;
|
|
133
|
+
}
|
|
134
|
+
/** What a {@link LiveLoader} returns for one entry. */
|
|
135
|
+
interface LiveEntry<Data = Record<string, unknown>> {
|
|
136
|
+
id: string;
|
|
137
|
+
data: Data;
|
|
138
|
+
body?: EntryBody;
|
|
139
|
+
}
|
|
140
|
+
/** What a loader is handed to read its source and validate what it found. */
|
|
141
|
+
interface LoaderContext {
|
|
142
|
+
collection: string;
|
|
143
|
+
/**
|
|
144
|
+
* The project root a relative path resolves against.
|
|
145
|
+
*
|
|
146
|
+
* `process.cwd()` at runtime, and the Vite root under `contentLayer()`, which is
|
|
147
|
+
* what keeps a collection pointing at the same files when the build runs
|
|
148
|
+
* from somewhere else.
|
|
149
|
+
*/
|
|
150
|
+
root: string;
|
|
151
|
+
parseData<D>(input: {
|
|
152
|
+
id: string;
|
|
153
|
+
data: unknown;
|
|
154
|
+
filePath?: string;
|
|
155
|
+
}): Promise<D>;
|
|
156
|
+
store: {
|
|
157
|
+
set(entry: LoadedEntry): void;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A loader that resolves a whole collection by filling a store.
|
|
162
|
+
*
|
|
163
|
+
* Its shape is the claim that one execution produces the complete answer, which
|
|
164
|
+
* is what lets `contentLayer()` run it during the build and inline the result.
|
|
165
|
+
*/
|
|
166
|
+
interface ContentLoader {
|
|
167
|
+
name: string;
|
|
168
|
+
load(context: LoaderContext): Promise<void> | void;
|
|
169
|
+
watchedPaths?(): string[];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A loader that answers one query at a time.
|
|
173
|
+
*
|
|
174
|
+
* There is no store to fill and so nothing to serialize: the only way to get
|
|
175
|
+
* entries out of it is to ask, which means asking on every read.
|
|
176
|
+
*/
|
|
177
|
+
interface LiveLoader<Data = Record<string, unknown>> {
|
|
178
|
+
name: string;
|
|
179
|
+
loadCollection(): Promise<LiveEntry<Data>[]>;
|
|
180
|
+
loadEntry(id: string): Promise<LiveEntry<Data> | undefined>;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Either kind of loader.
|
|
184
|
+
*
|
|
185
|
+
* A `LiveLoader`'s own `Data` parameter is for whoever writes it; a collection
|
|
186
|
+
* accepts any of them, because what a loader hands back is unvalidated until
|
|
187
|
+
* `parseData` has seen it.
|
|
188
|
+
*/
|
|
189
|
+
type Loader = ContentLoader | LiveLoader<unknown>;
|
|
190
|
+
/** What `c.collection({ loader, schema })` returns. */
|
|
191
|
+
interface CollectionDefinition<S extends StandardSchemaV1 = StandardSchemaV1> {
|
|
192
|
+
loader: Loader;
|
|
193
|
+
schema: S;
|
|
194
|
+
}
|
|
195
|
+
/** The builder `createContent` hands to its callback. */
|
|
196
|
+
interface ContentBuilder {
|
|
197
|
+
collection<S extends StandardSchemaV1>(input: {
|
|
198
|
+
loader: Loader;
|
|
199
|
+
schema: S;
|
|
200
|
+
}): CollectionDefinition<S>;
|
|
201
|
+
reference<C extends string>(collection: C): ReferenceSchema<C>;
|
|
202
|
+
}
|
|
203
|
+
/** The object `createContent` returns: one {@link Collection} per key. */
|
|
204
|
+
type Content<T extends Record<string, CollectionDefinition>> = { [K in keyof T]: Collection<K & string, InferSchema<T[K]["schema"]>>; };
|
|
205
|
+
/** How `contentLayer()` spells an entry's body in the manifest it emits. */
|
|
206
|
+
type PrebuiltBody = {
|
|
207
|
+
format: "md";
|
|
208
|
+
html: string;
|
|
209
|
+
headings?: unknown;
|
|
210
|
+
} | {
|
|
211
|
+
format: "mdx";
|
|
212
|
+
module: Record<string, unknown>;
|
|
213
|
+
};
|
|
214
|
+
/** One entry in the manifest `contentLayer()` emits. */
|
|
215
|
+
interface PrebuiltEntry {
|
|
216
|
+
id: string;
|
|
217
|
+
data: unknown;
|
|
218
|
+
filePath?: string;
|
|
219
|
+
body?: PrebuiltBody;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The manifest `contentLayer()` emits, keyed by collection name.
|
|
223
|
+
*
|
|
224
|
+
* A collection missing from it was not prebuilt, and its absence is what tells
|
|
225
|
+
* the runtime to use the loader.
|
|
226
|
+
*/
|
|
227
|
+
type PrebuiltCollections = Record<string, PrebuiltEntry[]>;
|
|
228
|
+
/** Options handed to a `loaders.glob` `generateId` function. */
|
|
229
|
+
interface GenerateIdOptions {
|
|
230
|
+
/** The matched path, relative to `base`. */
|
|
231
|
+
entry: string;
|
|
232
|
+
/** The directory the pattern resolved against. */
|
|
233
|
+
base: string;
|
|
234
|
+
/** The parsed data of the entry, for an id derived from frontmatter. */
|
|
235
|
+
data: Record<string, unknown>;
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
export { Reference as _, ContentBuilder as a, EntryBody as c, LiveEntry as d, LiveLoader as f, PrebuiltCollections as g, LoaderContext as h, Content as i, GenerateIdOptions as l, Loader as m, CollectionDefinition as n, ContentLoader as o, LoadedEntry as p, CollectionEntry as r, Entry as s, Collection as t, Heading as u, RenderedEntry as v };
|
package/dist/vite.d.mts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
//#region src/vite.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Resolves content collections during the build and inlines them.
|
|
5
|
+
*
|
|
6
|
+
* `entry` names the module that declares the collections, and defaults to
|
|
7
|
+
* `app/content.ts`. It is an ordinary application module rather than a
|
|
8
|
+
* configuration file this plugin owns; being told which module it is is the
|
|
9
|
+
* whole of the configuration.
|
|
10
|
+
*
|
|
11
|
+
* The result is a host with no filesystem serving the collections the
|
|
12
|
+
* application declared, with no change to the declarations.
|
|
13
|
+
*/
|
|
14
|
+
declare function contentLayer(options?: {
|
|
15
|
+
entry?: string;
|
|
16
|
+
}): Plugin;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { contentLayer };
|
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { closePrebuild, openPrebuild } from "./prebuild.mjs";
|
|
2
|
+
import { BODY_PREFIX, manifestModule } from "./codegen.mjs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRunnableDevEnvironment, createServer } from "vite";
|
|
6
|
+
//#region src/vite.ts
|
|
7
|
+
const MANIFEST_OWNER = "@pitlane/content";
|
|
8
|
+
const MANIFEST_SPECIFIER = `${MANIFEST_OWNER}/internal/manifest`;
|
|
9
|
+
const VIRTUAL_MANIFEST = "\0pitlane-content/manifest";
|
|
10
|
+
/**
|
|
11
|
+
* Resolves content collections during the build and inlines them.
|
|
12
|
+
*
|
|
13
|
+
* `entry` names the module that declares the collections, and defaults to
|
|
14
|
+
* `app/content.ts`. It is an ordinary application module rather than a
|
|
15
|
+
* configuration file this plugin owns; being told which module it is is the
|
|
16
|
+
* whole of the configuration.
|
|
17
|
+
*
|
|
18
|
+
* The result is a host with no filesystem serving the collections the
|
|
19
|
+
* application declared, with no change to the declarations.
|
|
20
|
+
*/
|
|
21
|
+
function contentLayer(options) {
|
|
22
|
+
let entry = options?.entry ?? "app/content.ts";
|
|
23
|
+
let root = process.cwd();
|
|
24
|
+
let collections = {};
|
|
25
|
+
let bodies = /* @__PURE__ */ new Map();
|
|
26
|
+
let watched = [];
|
|
27
|
+
let resolution = {};
|
|
28
|
+
let server;
|
|
29
|
+
let pending;
|
|
30
|
+
let queue = Promise.resolve();
|
|
31
|
+
async function prebuild(warn) {
|
|
32
|
+
let loaded = await inPrebuildServer(root, entry, resolution, (paths) => {
|
|
33
|
+
watched = [.../* @__PURE__ */ new Set([...watched, ...paths])];
|
|
34
|
+
for (let path of watched) server?.watcher.add(path);
|
|
35
|
+
});
|
|
36
|
+
collections = loaded.collections;
|
|
37
|
+
watched = loaded.watched;
|
|
38
|
+
for (let path of watched) server?.watcher.add(path);
|
|
39
|
+
for (let name of loaded.configuredSatteri) warn(`Collection "${name}" configures loader options.satteri, but contentLayer() prebuilt it, so vite-plugin-satteri renders it and those options do nothing. Move the plugins into satteri() in your Vite config, or drop contentLayer() for this collection.`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Prebuilds once, and again only after a content change invalidated it.
|
|
43
|
+
*
|
|
44
|
+
* Runs strictly one at a time. Prebuild mode is a process-global handshake
|
|
45
|
+
* with `createContent`, so a second run overlapping the first would close
|
|
46
|
+
* the channel underneath it and leave the loaders resolving against the
|
|
47
|
+
* wrong root.
|
|
48
|
+
*/
|
|
49
|
+
function ready(warn) {
|
|
50
|
+
let run = () => prebuild(warn);
|
|
51
|
+
pending ??= queue = queue.then(run, run);
|
|
52
|
+
return pending;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
name: "pitlane-content",
|
|
56
|
+
enforce: "post",
|
|
57
|
+
/**
|
|
58
|
+
* Two ways the runtime can end up with the manifest the package ships
|
|
59
|
+
* — the one that registers nothing — instead of the one emitted below.
|
|
60
|
+
*
|
|
61
|
+
* A server build externalizes its dependencies, so the package has to
|
|
62
|
+
* be bundled for the replacement to reach the runtime at all. In dev
|
|
63
|
+
* nothing is bundled, but the dependency optimizer pre-bundles an
|
|
64
|
+
* installed dependency before any plugin runs, inlining the shipped
|
|
65
|
+
* manifest where `load` can no longer replace it. Excluding the
|
|
66
|
+
* package is what keeps the two paths agreeing.
|
|
67
|
+
*/
|
|
68
|
+
config() {
|
|
69
|
+
return {
|
|
70
|
+
environments: { ssr: {
|
|
71
|
+
optimizeDeps: { exclude: [MANIFEST_OWNER] },
|
|
72
|
+
resolve: { noExternal: [MANIFEST_OWNER] }
|
|
73
|
+
} },
|
|
74
|
+
optimizeDeps: { exclude: [MANIFEST_OWNER] },
|
|
75
|
+
ssr: { noExternal: [MANIFEST_OWNER] }
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
configResolved(config) {
|
|
79
|
+
root = config.root;
|
|
80
|
+
resolution = {
|
|
81
|
+
alias: config.resolve.alias,
|
|
82
|
+
dedupe: config.resolve.dedupe,
|
|
83
|
+
extensions: config.resolve.extensions,
|
|
84
|
+
mainFields: config.resolve.mainFields,
|
|
85
|
+
preserveSymlinks: config.resolve.preserveSymlinks
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
async buildStart() {
|
|
89
|
+
if (this.environment.config.command === "build") await ready((message) => this.warn(message));
|
|
90
|
+
},
|
|
91
|
+
configureServer(created) {
|
|
92
|
+
server = created;
|
|
93
|
+
created.watcher.on("all", async (_event, changed) => {
|
|
94
|
+
if (!isWatched(watched, changed)) return;
|
|
95
|
+
pending = void 0;
|
|
96
|
+
try {
|
|
97
|
+
await ready((message) => created.config.logger.warn(message));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
created.config.logger.error(String(error));
|
|
100
|
+
}
|
|
101
|
+
invalidate(created, entry);
|
|
102
|
+
created.hot.send({ type: "full-reload" });
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
async resolveId(source, importer) {
|
|
106
|
+
if (source === MANIFEST_SPECIFIER) return VIRTUAL_MANIFEST;
|
|
107
|
+
if (source.startsWith("\0pitlane-content/entry/")) return source;
|
|
108
|
+
if (importer?.startsWith("\0pitlane-content/entry/") && source.startsWith(".")) {
|
|
109
|
+
let filePath = bodies.get(importer)?.filePath;
|
|
110
|
+
if (filePath === void 0) return void 0;
|
|
111
|
+
let from = resolve(root, filePath);
|
|
112
|
+
return await this.resolve(resolve(dirname(from), source), from, { skipSelf: true });
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
/**
|
|
116
|
+
* Catches the one misconfiguration that would otherwise surface as a
|
|
117
|
+
* JavaScript tokenizer error on a virtual path.
|
|
118
|
+
*
|
|
119
|
+
* A body module's id ends in `.md` or `.mdx` and its contents are raw
|
|
120
|
+
* Markdown. If it reaches here unchanged, no plugin claimed it, and the
|
|
121
|
+
* bundler is about to parse prose as JavaScript. Every other
|
|
122
|
+
* misconfiguration in this package names its own fix; this one sits on
|
|
123
|
+
* the documented adoption path, so it gets the same treatment.
|
|
124
|
+
*/
|
|
125
|
+
transform(code, id) {
|
|
126
|
+
if (!id.startsWith("\0pitlane-content/entry/") || code !== bodies.get(id)?.source) return void 0;
|
|
127
|
+
throw new Error(`Nothing compiled the Markdown in "${id.slice(BODY_PREFIX.length)}". Add vite-plugin-satteri to your Vite config, before remix():
|
|
128
|
+
satteri({ mdx: { jsxImportSource: "remix/ui" }, mdastPlugins: [headings()] })`);
|
|
129
|
+
},
|
|
130
|
+
async load(id) {
|
|
131
|
+
if (id === VIRTUAL_MANIFEST || isManifestModule(id)) {
|
|
132
|
+
await ready((message) => this.warn(message));
|
|
133
|
+
return manifestModule(collections, bodies, await markdownHeadings(collections));
|
|
134
|
+
}
|
|
135
|
+
if (id.startsWith("\0pitlane-content/entry/")) {
|
|
136
|
+
await ready((message) => this.warn(message));
|
|
137
|
+
return bodies.get(id)?.source;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The heading list for every Markdown entry, measured the way the runtime
|
|
144
|
+
* measures it.
|
|
145
|
+
*
|
|
146
|
+
* `vite-plugin-satteri` compiles a `.md` body to an HTML string and exports
|
|
147
|
+
* nothing else, so the list has to be taken here or the prebuilt path has none
|
|
148
|
+
* at all. MDX needs no such help: it compiles to a module that exports its own.
|
|
149
|
+
*/
|
|
150
|
+
async function markdownHeadings(collections) {
|
|
151
|
+
let measured = /* @__PURE__ */ new Map();
|
|
152
|
+
let markdown = Object.entries(collections).flatMap(([collection, loaded]) => loaded.filter((entry) => entry.body?.format === "md").map((entry) => ({
|
|
153
|
+
collection,
|
|
154
|
+
entry
|
|
155
|
+
})));
|
|
156
|
+
if (markdown.length === 0) return measured;
|
|
157
|
+
let [satteri, { headings }] = await Promise.all([import("satteri"), import("./satteri.mjs")]);
|
|
158
|
+
for (let { collection, entry } of markdown) {
|
|
159
|
+
let data = (await satteri.markdownToHtml(entry.body.source, {
|
|
160
|
+
features: { frontmatter: true },
|
|
161
|
+
mdastPlugins: [headings()]
|
|
162
|
+
})).data;
|
|
163
|
+
measured.set(`${BODY_PREFIX}${collection}/${entry.id}.md`, data.headings);
|
|
164
|
+
}
|
|
165
|
+
return measured;
|
|
166
|
+
}
|
|
167
|
+
function isWatched(watched, changed) {
|
|
168
|
+
let path = posix(changed);
|
|
169
|
+
return watched.some((base) => path === posix(base) || path.startsWith(`${posix(base)}/`));
|
|
170
|
+
}
|
|
171
|
+
function posix(path) {
|
|
172
|
+
return path.replace(/\\/g, "/");
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Whether a resolved id is this package's manifest module.
|
|
176
|
+
*
|
|
177
|
+
* Located relative to this module rather than matched by name: the plugin and
|
|
178
|
+
* the manifest ship side by side, in `src` during development and in `dist`
|
|
179
|
+
* once packed, so one relative lookup identifies it exactly. A suffix match
|
|
180
|
+
* either misses the workspace link Vite resolves through or claims an
|
|
181
|
+
* application's own `content/src/manifest.ts`.
|
|
182
|
+
*/
|
|
183
|
+
const MANIFEST_PATHS = new Set(["./manifest.ts", "./manifest.mjs"].map((name) => posix(fileURLToPath(new URL(name, import.meta.url)))));
|
|
184
|
+
function isManifestModule(id) {
|
|
185
|
+
return MANIFEST_PATHS.has(posix(id).split("?")[0] ?? "");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Invalidates the manifest, the body modules, and every module that imported
|
|
189
|
+
* them, so the next request re-evaluates the collections rather than reusing
|
|
190
|
+
* the entries the previous prebuild produced.
|
|
191
|
+
*/
|
|
192
|
+
function invalidate(server, entry) {
|
|
193
|
+
let entryPath = posix(entry).replace(/^\/+/, "");
|
|
194
|
+
for (let environment of Object.values(server.environments)) {
|
|
195
|
+
let graph = environment.moduleGraph;
|
|
196
|
+
let stale = [...graph.idToModuleMap.values()].filter((node) => {
|
|
197
|
+
let id = posix(node.id ?? "");
|
|
198
|
+
return isEmitted(id) || id.endsWith(`/${entryPath}`);
|
|
199
|
+
});
|
|
200
|
+
let seen = new Set(stale);
|
|
201
|
+
while (stale.length > 0) {
|
|
202
|
+
let node = stale.pop();
|
|
203
|
+
graph.invalidateModule(node);
|
|
204
|
+
for (let importer of node.importers) {
|
|
205
|
+
if (seen.has(importer)) continue;
|
|
206
|
+
seen.add(importer);
|
|
207
|
+
stale.push(importer);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function isEmitted(id) {
|
|
213
|
+
return id === VIRTUAL_MANIFEST || id.startsWith("\0pitlane-content/entry/") || isManifestModule(id);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Runs the content entry through Vite's own module runner, so TypeScript,
|
|
217
|
+
* aliases, and `vite.config.ts` resolution all apply.
|
|
218
|
+
*
|
|
219
|
+
* Declarations register deferred work. The plugin awaits it after module
|
|
220
|
+
* evaluation, with the filesystem available, before reading the manifest.
|
|
221
|
+
* Live loaders register no population work and stay untouched.
|
|
222
|
+
*/
|
|
223
|
+
async function inPrebuildServer(root, entry, resolution, onWatched) {
|
|
224
|
+
let server = await createServer({
|
|
225
|
+
root,
|
|
226
|
+
configFile: false,
|
|
227
|
+
logLevel: "silent",
|
|
228
|
+
resolve: resolution,
|
|
229
|
+
server: {
|
|
230
|
+
middlewareMode: true,
|
|
231
|
+
watch: null
|
|
232
|
+
},
|
|
233
|
+
environments: { ssr: { dev: { createEnvironment: (name, config) => createRunnableDevEnvironment(name, config) } } }
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
return await execute(server, root, entry, onWatched);
|
|
237
|
+
} finally {
|
|
238
|
+
await server.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function execute(server, root, entry, onWatched) {
|
|
242
|
+
let recorded = openPrebuild(root);
|
|
243
|
+
try {
|
|
244
|
+
await server.ssrLoadModule(entry.startsWith("/") ? entry : `/${entry}`);
|
|
245
|
+
for (let populate of recorded.tasks) await populate();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
let cause = error instanceof Error ? error.message : String(error);
|
|
248
|
+
throw new Error(`Failed to load the content entry "${entry}" in ${root}: ${cause}`, { cause: error });
|
|
249
|
+
} finally {
|
|
250
|
+
onWatched([...recorded.watched]);
|
|
251
|
+
closePrebuild();
|
|
252
|
+
}
|
|
253
|
+
let collections = {};
|
|
254
|
+
for (let [name, entries] of recorded.collections) collections[name] = entries;
|
|
255
|
+
return {
|
|
256
|
+
collections,
|
|
257
|
+
watched: [...recorded.watched],
|
|
258
|
+
configuredSatteri: [...recorded.configuredSatteri]
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
export { contentLayer };
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pitlane/content",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "createContent() — schema-validated, cross-referenced Markdown, MDX, and data collections for Remix 3, queryable at runtime and prebuildable into the bundle.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"content",
|
|
7
|
+
"content-collections",
|
|
8
|
+
"markdown",
|
|
9
|
+
"mdx",
|
|
10
|
+
"pitlane",
|
|
11
|
+
"remix",
|
|
12
|
+
"satteri"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://pitlane.tools/package/content/",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/pitlane-tools/pitlane/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Mark Malstrom <mark@malstrom.me>",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/pitlane-tools/pitlane.git",
|
|
23
|
+
"directory": "packages/content"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"CHANGELOG.md"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"import": "./dist/index.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./loaders": {
|
|
37
|
+
"types": "./dist/loaders.d.mts",
|
|
38
|
+
"import": "./dist/loaders.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./satteri": {
|
|
41
|
+
"types": "./dist/satteri.d.mts",
|
|
42
|
+
"import": "./dist/satteri.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./vite": {
|
|
45
|
+
"types": "./dist/vite.d.mts",
|
|
46
|
+
"import": "./dist/vite.mjs"
|
|
47
|
+
},
|
|
48
|
+
"./hot": {
|
|
49
|
+
"types": "./dist/hot.d.mts",
|
|
50
|
+
"import": "./dist/hot.mjs"
|
|
51
|
+
},
|
|
52
|
+
"./internal/manifest": {
|
|
53
|
+
"types": "./dist/manifest.d.mts",
|
|
54
|
+
"import": "./dist/manifest.mjs"
|
|
55
|
+
},
|
|
56
|
+
"./internal/prebuild": {
|
|
57
|
+
"types": "./dist/prebuild.d.mts",
|
|
58
|
+
"import": "./dist/prebuild.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./internal/codegen": {
|
|
61
|
+
"types": "./dist/codegen.d.mts",
|
|
62
|
+
"import": "./dist/codegen.mjs"
|
|
63
|
+
},
|
|
64
|
+
"./internal/mdx": {
|
|
65
|
+
"types": "./dist/mdx.d.mts",
|
|
66
|
+
"import": "./dist/mdx.mjs"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"prepublishOnly": "vp run build"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"es-module-lexer": "^2.3.1",
|
|
74
|
+
"yaml": "^2.8.1"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@types/node": "^25.5.0",
|
|
78
|
+
"remix": "3.0.0-rc.2",
|
|
79
|
+
"satteri": "^0.10.5",
|
|
80
|
+
"typescript": "^7.0.2",
|
|
81
|
+
"vite": "^8.1.5",
|
|
82
|
+
"vite-plugin-satteri": "^0.3.5",
|
|
83
|
+
"vite-plus": "^0.2.6"
|
|
84
|
+
},
|
|
85
|
+
"peerDependencies": {
|
|
86
|
+
"remix": "^3.0.0-rc.1",
|
|
87
|
+
"satteri": "^0.10.5",
|
|
88
|
+
"vite": ">=8.0.0"
|
|
89
|
+
},
|
|
90
|
+
"peerDependenciesMeta": {
|
|
91
|
+
"remix": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"satteri": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"vite": {
|
|
98
|
+
"optional": true
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"engines": {
|
|
102
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
103
|
+
}
|
|
104
|
+
}
|