create-sprinkles 0.2.3
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/bin.mjs +295 -0
- package/dist/index.d.mts +46 -0
- package/dist/index.mjs +180 -0
- package/package.json +45 -0
- package/templates/react-router-convex/.env.local +2 -0
- package/templates/react-router-convex/convex/schema.ts +9 -0
- package/templates/react-router-rsc/app/home.tsx.hbs +31 -0
- package/templates/react-router-rsc/app/root.tsx.hbs +55 -0
- package/templates/react-router-rsc/tsconfig.json.hbs +31 -0
- package/templates/react-router-rsc/workers/entry.rsc.tsx +44 -0
- package/templates/react-router-rsc/workers/entry.ssr.tsx +41 -0
- package/templates/react-router-rsc/wrangler.rsc.jsonc.hbs +25 -0
- package/templates/react-router-rsc/wrangler.ssr.jsonc.hbs +14 -0
- package/templates/react-router-rsc-content-layer/app/content.config.ts.hbs +26 -0
- package/templates/react-router-rsc-content-layer/content-layer/api.ts +350 -0
- package/templates/react-router-rsc-content-layer/content-layer/codegen.ts +89 -0
- package/templates/react-router-rsc-content-layer/content-layer/config.ts +20 -0
- package/templates/react-router-rsc-content-layer/content-layer/digest.ts +6 -0
- package/templates/react-router-rsc-content-layer/content-layer/frontmatter.ts +19 -0
- package/templates/react-router-rsc-content-layer/content-layer/loaders/file.ts +55 -0
- package/templates/react-router-rsc-content-layer/content-layer/loaders/glob.ts +82 -0
- package/templates/react-router-rsc-content-layer/content-layer/loaders/index.ts +2 -0
- package/templates/react-router-rsc-content-layer/content-layer/plugin.ts +419 -0
- package/templates/react-router-rsc-content-layer/content-layer/resolve-hook.js +12 -0
- package/templates/react-router-rsc-content-layer/content-layer/runtime.ts +73 -0
- package/templates/react-router-rsc-content-layer/content-layer/store.ts +59 -0
- package/templates/react-router-spa/app/home.tsx.hbs +7 -0
- package/templates/react-router-spa/app/root.tsx.hbs +60 -0
- package/templates/react-router-spa/tsconfig.json.hbs +26 -0
- package/templates/react-router-spa/wrangler.jsonc.hbs +9 -0
- package/templates/react-router-ssr/app/home.tsx.hbs +21 -0
- package/templates/react-router-ssr/app/root.tsx.hbs +105 -0
- package/templates/react-router-ssr/convex/schema.ts +7 -0
- package/templates/react-router-ssr/tsconfig.json.hbs +28 -0
- package/templates/react-router-ssr/wrangler.jsonc.hbs +13 -0
- package/templates/react-router-ssr-convex/app/lib/client.ts +19 -0
- package/templates/react-router-ssr-convex/app/tanstack-query-integration/middleware.ts +18 -0
- package/templates/react-router-ssr-convex/app/tanstack-query-integration/query-preloader.ts +125 -0
- package/templates/react-shared/app/routes.ts.hbs +3 -0
- package/templates/react-shared/app/styles/tailwind.css +1 -0
- package/templates/react-shared/react-compiler.plugin.ts.hbs +10 -0
- package/templates/react-shared/react-router.config.ts.hbs +9 -0
- package/templates/shared/.gitignore.hbs +23 -0
- package/templates/shared/.node-version +1 -0
- package/templates/shared/.vscode/extensions.json.hbs +8 -0
- package/templates/shared/.vscode/settings.json.hbs +72 -0
- package/templates/shared/AGENTS.md.hbs +599 -0
- package/templates/shared/README.md.hbs +24 -0
- package/templates/shared/package.json.hbs +41 -0
- package/templates/shared/vite.config.ts.hbs +384 -0
- package/templates/ts-package/src/index.ts +3 -0
- package/templates/ts-package/tests/index.test.ts +9 -0
- package/templates/ts-package/tsconfig.json +18 -0
- package/templates/ts-package-cli/bin/index.ts.hbs +1 -0
- package/templates/ts-package-cli/src/cli.ts.hbs +37 -0
- package/templates/ts-package-generator/bin/create.ts.hbs +2 -0
- package/templates/ts-package-generator/src/template.ts.hbs +22 -0
- package/templates/ts-package-sea/sea-config.json.hbs +2 -0
- package/templates/ts-package-sea/src/sea-entry.ts.hbs +4 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parse as parseJsonc } from "@std/jsonc";
|
|
2
|
+
import { parse as parseYaml } from "@std/yaml";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { extname } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { ContentLoader } from "../api.ts";
|
|
7
|
+
|
|
8
|
+
interface FileOptions {
|
|
9
|
+
parser?: (text: string) => Record<string, Record<string, unknown>> | Record<string, unknown>[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const YAML_EXTENSIONS = new Set([".yaml", ".yml"]);
|
|
13
|
+
|
|
14
|
+
function defaultParser(text: string, ext: string) {
|
|
15
|
+
if (YAML_EXTENSIONS.has(ext)) {
|
|
16
|
+
return parseYaml(text) as
|
|
17
|
+
| Record<string, Record<string, unknown>>
|
|
18
|
+
| Record<string, unknown>[];
|
|
19
|
+
}
|
|
20
|
+
// JSON and JSONC both handled by JSONC parser (superset)
|
|
21
|
+
return parseJsonc(text) as Record<string, Record<string, unknown>> | Record<string, unknown>[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function file(fileName: string, options?: FileOptions): ContentLoader {
|
|
25
|
+
return {
|
|
26
|
+
getWatchedPaths() {
|
|
27
|
+
return [fileName];
|
|
28
|
+
},
|
|
29
|
+
async load(context) {
|
|
30
|
+
let raw = await readFile(fileName, "utf-8");
|
|
31
|
+
let ext = extname(fileName);
|
|
32
|
+
let parsed = options?.parser ? options.parser(raw) : defaultParser(raw, ext);
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(parsed)) {
|
|
35
|
+
// Array of objects with `id` field
|
|
36
|
+
for (let item of parsed) {
|
|
37
|
+
let record = item as Record<string, unknown>;
|
|
38
|
+
let id = record.id as string;
|
|
39
|
+
let data = await context.parseData({ id, data: record, filePath: fileName });
|
|
40
|
+
let digest = context.generateDigest(record);
|
|
41
|
+
context.store.set({ id, data, filePath: fileName, digest });
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
// Object with string keys as IDs
|
|
45
|
+
for (let [id, value] of Object.entries(parsed)) {
|
|
46
|
+
let data = await context.parseData({ id, data: value, filePath: fileName });
|
|
47
|
+
let digest = context.generateDigest(value);
|
|
48
|
+
context.store.set({ id, data, filePath: fileName, digest });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
name: "file",
|
|
53
|
+
schema: null as never,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { parse as parseJsonc } from "@std/jsonc";
|
|
2
|
+
import { parse as parseYaml } from "@std/yaml";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { glob as fsGlob } from "node:fs/promises";
|
|
5
|
+
import { extname, join, relative } from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { ContentLoader, GenerateIdOptions } from "../api.ts";
|
|
8
|
+
|
|
9
|
+
import { parseFrontmatter } from "../frontmatter.ts";
|
|
10
|
+
|
|
11
|
+
interface GlobOptions {
|
|
12
|
+
pattern: string | string[];
|
|
13
|
+
base?: string | URL;
|
|
14
|
+
generateId?: (options: GenerateIdOptions) => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
18
|
+
const JSON_EXTENSIONS = new Set([".json", ".jsonc"]);
|
|
19
|
+
const YAML_EXTENSIONS = new Set([".yaml", ".yml"]);
|
|
20
|
+
|
|
21
|
+
function defaultGenerateId({ entry }: GenerateIdOptions): string {
|
|
22
|
+
// Strip extension, use forward slashes
|
|
23
|
+
const ext = extname(entry);
|
|
24
|
+
return entry.slice(0, -ext.length).replaceAll("\\", "/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function glob(globOptions: GlobOptions): ContentLoader {
|
|
28
|
+
const { pattern, base = ".", generateId = defaultGenerateId } = globOptions;
|
|
29
|
+
const baseDir = base instanceof URL ? base.pathname : base;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: "glob",
|
|
33
|
+
schema: null as never, // Schema is provided by the collection, not the loader
|
|
34
|
+
getWatchedPaths() {
|
|
35
|
+
return [baseDir];
|
|
36
|
+
},
|
|
37
|
+
async load(context) {
|
|
38
|
+
const patterns = Array.isArray(pattern) ? pattern : [pattern];
|
|
39
|
+
|
|
40
|
+
for (const pat of patterns) {
|
|
41
|
+
const fullPattern = join(baseDir, pat);
|
|
42
|
+
for await (const filePath of fsGlob(fullPattern)) {
|
|
43
|
+
const relativePath = relative(baseDir, filePath);
|
|
44
|
+
const ext = extname(filePath);
|
|
45
|
+
const raw = await readFile(filePath, "utf-8");
|
|
46
|
+
|
|
47
|
+
if (MARKDOWN_EXTENSIONS.has(ext)) {
|
|
48
|
+
const { data, body } = parseFrontmatter(raw);
|
|
49
|
+
const id = generateId({
|
|
50
|
+
entry: relativePath,
|
|
51
|
+
base: new URL(`file://${baseDir}`),
|
|
52
|
+
data,
|
|
53
|
+
});
|
|
54
|
+
const parsedData = await context.parseData({ id, data, filePath });
|
|
55
|
+
const digest = context.generateDigest(raw);
|
|
56
|
+
context.store.set({ body, data: parsedData, digest, filePath, id });
|
|
57
|
+
} else if (JSON_EXTENSIONS.has(ext)) {
|
|
58
|
+
const data = parseJsonc(raw) as Record<string, unknown>;
|
|
59
|
+
const id = generateId({
|
|
60
|
+
entry: relativePath,
|
|
61
|
+
base: new URL(`file://${baseDir}`),
|
|
62
|
+
data,
|
|
63
|
+
});
|
|
64
|
+
const parsedData = await context.parseData({ id, data, filePath });
|
|
65
|
+
const digest = context.generateDigest(raw);
|
|
66
|
+
context.store.set({ data: parsedData, digest, filePath, id });
|
|
67
|
+
} else if (YAML_EXTENSIONS.has(ext)) {
|
|
68
|
+
const data = parseYaml(raw) as Record<string, unknown>;
|
|
69
|
+
const id = generateId({
|
|
70
|
+
entry: relativePath,
|
|
71
|
+
base: new URL(`file://${baseDir}`),
|
|
72
|
+
data,
|
|
73
|
+
});
|
|
74
|
+
const parsedData = await context.parseData({ id, data, filePath });
|
|
75
|
+
const digest = context.generateDigest(raw);
|
|
76
|
+
context.store.set({ data: parsedData, digest, filePath, id });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import type { Plugin, ResolvedConfig, ViteDevServer } from "vite-plus";
|
|
2
|
+
|
|
3
|
+
import { parseSafe } from "@remix-run/data-schema";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { register } from "node:module";
|
|
6
|
+
import { join, relative, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
import type { Collection, DataStore, LoaderContext, ParseDataOptions } from "./api.ts";
|
|
10
|
+
|
|
11
|
+
import { generateTypes } from "./codegen.ts";
|
|
12
|
+
import { generateDigest } from "./digest.ts";
|
|
13
|
+
import { createDataStore, createMetaStore } from "./store.ts";
|
|
14
|
+
|
|
15
|
+
/** Serializes a value to a JavaScript expression string, preserving Date instances. */
|
|
16
|
+
function toJSLiteral(value: unknown): string {
|
|
17
|
+
if (value === null || value === undefined) {
|
|
18
|
+
return String(value);
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "string") {
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
}
|
|
26
|
+
if (value instanceof Date) {
|
|
27
|
+
return `new Date(${JSON.stringify(value.toISOString())})`;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
return `[${value.map(toJSLiteral).join(",")}]`;
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === "object") {
|
|
33
|
+
const entries = Object.entries(value as Record<string, unknown>).map(
|
|
34
|
+
([k, v]) => `${JSON.stringify(k)}:${toJSLiteral(v)}`,
|
|
35
|
+
);
|
|
36
|
+
return `{${entries.join(",")}}`;
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const VIRTUAL_MODULE_ID = "sprinkles:content";
|
|
42
|
+
const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
|
|
43
|
+
const VIRTUAL_STORE_ID = "virtual:sprinkles-content/store";
|
|
44
|
+
const RESOLVED_STORE_ID = `\0${VIRTUAL_STORE_ID}`;
|
|
45
|
+
const VIRTUAL_ENTRY_PREFIX = "virtual:sprinkles-content/entry/";
|
|
46
|
+
// No \0 prefix so @mdx-js/rollup's transform hook can process these modules.
|
|
47
|
+
// The .mdx suffix signals @mdx-js/rollup to compile the raw markdown.
|
|
48
|
+
const RESOLVED_ENTRY_PREFIX = VIRTUAL_ENTRY_PREFIX;
|
|
49
|
+
|
|
50
|
+
interface ContentLayerPluginOptions {
|
|
51
|
+
/** Path to the content config file, relative to the project root. Defaults to "app/content.config.ts" */
|
|
52
|
+
configPath?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function contentLayer(options: ContentLayerPluginOptions = {}): Plugin {
|
|
56
|
+
const { configPath = "app/content.config.ts" } = options;
|
|
57
|
+
let config: ResolvedConfig;
|
|
58
|
+
let stores = new Map<string, DataStore>();
|
|
59
|
+
// Map of entry file paths to their collection entries for HMR.
|
|
60
|
+
// Multiple entries can share the same file (e.g. a JSONC file with an array of entries).
|
|
61
|
+
let entryFilePaths = new Map<string, { collection: string; id: string }[]>();
|
|
62
|
+
// Resolved paths that should trigger content reloads (files and directories from loaders)
|
|
63
|
+
let watchedContentPaths = new Set<string>();
|
|
64
|
+
|
|
65
|
+
async function loadConfig(root: string): Promise<Record<string, Collection>> {
|
|
66
|
+
const fullPath = resolve(root, configPath);
|
|
67
|
+
// Use dynamic import with timestamp to bust cache during dev
|
|
68
|
+
const mod = await import(/* @vite-ignore */ `${fullPath}?t=${Date.now()}`);
|
|
69
|
+
return mod.collections ?? {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createParseData(schema: Collection["schema"]): LoaderContext["parseData"] {
|
|
73
|
+
return async <Data extends Record<string, unknown>>(
|
|
74
|
+
props: ParseDataOptions<Data>,
|
|
75
|
+
): Promise<Data> => {
|
|
76
|
+
if (!schema) {
|
|
77
|
+
return props.data;
|
|
78
|
+
}
|
|
79
|
+
// Resolve schema (may be a function that takes SchemaContext)
|
|
80
|
+
const resolvedSchema =
|
|
81
|
+
typeof schema === "function"
|
|
82
|
+
? schema({
|
|
83
|
+
image: () =>
|
|
84
|
+
({
|
|
85
|
+
"~standard": {
|
|
86
|
+
version: 1,
|
|
87
|
+
vendor: "data-schema",
|
|
88
|
+
validate: (v: unknown) => ({ value: v as string }),
|
|
89
|
+
},
|
|
90
|
+
}) as never,
|
|
91
|
+
})
|
|
92
|
+
: schema;
|
|
93
|
+
const result = parseSafe(resolvedSchema, props.data);
|
|
94
|
+
if (!result.success) {
|
|
95
|
+
const messages = result.issues.map(i => i.message).join(", ");
|
|
96
|
+
throw new Error(`Validation failed for entry "${props.id}": ${messages}`);
|
|
97
|
+
}
|
|
98
|
+
return result.value as Data;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Loads all collections and populates stores. Uses atomic swap so that on error,
|
|
104
|
+
* the previous valid state is preserved.
|
|
105
|
+
*/
|
|
106
|
+
async function runLoaders(
|
|
107
|
+
root: string,
|
|
108
|
+
loadedCollections: Record<string, Collection>,
|
|
109
|
+
server?: ViteDevServer,
|
|
110
|
+
) {
|
|
111
|
+
const newStores = new Map<string, DataStore>();
|
|
112
|
+
const newEntryFilePaths = new Map<string, { collection: string; id: string }[]>();
|
|
113
|
+
const newWatchedPaths = new Set<string>();
|
|
114
|
+
|
|
115
|
+
// Always watch the config file itself
|
|
116
|
+
newWatchedPaths.add(resolve(root, configPath));
|
|
117
|
+
|
|
118
|
+
for (const [name, collection] of Object.entries(loadedCollections)) {
|
|
119
|
+
const store = createDataStore();
|
|
120
|
+
const meta = createMetaStore();
|
|
121
|
+
newStores.set(name, store);
|
|
122
|
+
|
|
123
|
+
// Collect watched paths from loader
|
|
124
|
+
const loaderPaths = collection.loader.getWatchedPaths?.() ?? [];
|
|
125
|
+
for (const p of loaderPaths) {
|
|
126
|
+
newWatchedPaths.add(resolve(root, p));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const context: LoaderContext = {
|
|
130
|
+
collection: name,
|
|
131
|
+
store,
|
|
132
|
+
meta,
|
|
133
|
+
parseData: createParseData(collection.schema),
|
|
134
|
+
renderMarkdown: async markdown => ({ html: markdown }),
|
|
135
|
+
generateDigest,
|
|
136
|
+
watcher: server?.watcher as LoaderContext["watcher"],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
await collection.loader.load(context);
|
|
140
|
+
|
|
141
|
+
// Track file paths for HMR
|
|
142
|
+
for (const entry of store.values()) {
|
|
143
|
+
if (entry.filePath) {
|
|
144
|
+
const key = resolve(root, entry.filePath);
|
|
145
|
+
const existing = newEntryFilePaths.get(key) ?? [];
|
|
146
|
+
existing.push({ collection: name, id: entry.id });
|
|
147
|
+
newEntryFilePaths.set(key, existing);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Atomic swap: only update state after everything succeeds
|
|
153
|
+
stores = newStores;
|
|
154
|
+
entryFilePaths = newEntryFilePaths;
|
|
155
|
+
watchedContentPaths = newWatchedPaths;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Checks whether a file path is a content-related path that should trigger a reload. */
|
|
159
|
+
function isContentPath(filePath: string): boolean {
|
|
160
|
+
const resolved = resolve(filePath);
|
|
161
|
+
// Direct match: tracked entry file or watched path
|
|
162
|
+
if (entryFilePaths.has(resolved)) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
if (watchedContentPaths.has(resolved)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
// Check if file is under a watched directory
|
|
169
|
+
for (const watched of watchedContentPaths) {
|
|
170
|
+
if (resolved.startsWith(`${watched}/`)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function serializeStores(): string {
|
|
178
|
+
const data: Record<string, Record<string, unknown>> = {};
|
|
179
|
+
for (const [name, store] of stores) {
|
|
180
|
+
const entries: Record<string, unknown> = {};
|
|
181
|
+
for (const entry of store.values()) {
|
|
182
|
+
// Exclude body from serialized store (it's in virtual entry modules)
|
|
183
|
+
const { body, rendered, ...rest } = entry;
|
|
184
|
+
entries[entry.id] = { ...rest, collection: name };
|
|
185
|
+
}
|
|
186
|
+
data[name] = entries;
|
|
187
|
+
}
|
|
188
|
+
return toJSLiteral(data);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Generates a static import map for all entries with body content. */
|
|
192
|
+
function generateImporterMap(): string {
|
|
193
|
+
const lines: string[] = [];
|
|
194
|
+
for (const [name, store] of stores) {
|
|
195
|
+
for (const entry of store.values()) {
|
|
196
|
+
if (entry.body) {
|
|
197
|
+
const key = `${name}/${entry.id}`;
|
|
198
|
+
lines.push(
|
|
199
|
+
`${JSON.stringify(key)}: () => import(${JSON.stringify(`${VIRTUAL_ENTRY_PREFIX}${key}`)})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return `{${lines.join(",\n")}}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function writeTypes(root: string, cols: Record<string, Collection>) {
|
|
208
|
+
const outDir = join(root, ".sprinkles", "content-layer");
|
|
209
|
+
await mkdir(outDir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
const collectionInfos: Record<string, { schema: unknown }> = {};
|
|
212
|
+
for (const [name, collection] of Object.entries(cols)) {
|
|
213
|
+
collectionInfos[name] = { schema: collection.schema };
|
|
214
|
+
}
|
|
215
|
+
const configRelPath = relative(outDir, resolve(root, configPath)).replace(/\\/g, "/");
|
|
216
|
+
const types = generateTypes(
|
|
217
|
+
collectionInfos as Record<string, { schema: never }>,
|
|
218
|
+
configRelPath,
|
|
219
|
+
);
|
|
220
|
+
await writeFile(join(outDir, "content.d.ts"), types);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Invalidates all content virtual modules across all Vite environments (client, ssr, rsc). */
|
|
224
|
+
function invalidateContentModules(server: ViteDevServer, entryKeys?: string[]) {
|
|
225
|
+
const moduleIds = [RESOLVED_VIRTUAL_ID, RESOLVED_STORE_ID];
|
|
226
|
+
if (entryKeys) {
|
|
227
|
+
for (const key of entryKeys) {
|
|
228
|
+
moduleIds.push(`${VIRTUAL_ENTRY_PREFIX}${key}.mdx`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const env of Object.values(server.environments)) {
|
|
233
|
+
for (const id of moduleIds) {
|
|
234
|
+
const mod = env.moduleGraph.getModuleById(id);
|
|
235
|
+
if (mod) {
|
|
236
|
+
env.moduleGraph.invalidateModule(mod);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Handles a content-related file event during development. */
|
|
243
|
+
async function handleContentChange(server: ViteDevServer, filePath: string) {
|
|
244
|
+
const resolvedPath = resolve(filePath);
|
|
245
|
+
if (!isContentPath(resolvedPath)) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Collect entry keys that existed before reload for module invalidation
|
|
250
|
+
const previousEntryKeys: string[] = [];
|
|
251
|
+
const mappings = entryFilePaths.get(resolvedPath);
|
|
252
|
+
if (mappings) {
|
|
253
|
+
for (const m of mappings) {
|
|
254
|
+
previousEntryKeys.push(`${m.collection}/${m.id}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let loadedCollections: Record<string, Collection>;
|
|
259
|
+
try {
|
|
260
|
+
loadedCollections = await loadConfig(config.root);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
config.logger.error(
|
|
263
|
+
`[content-layer] Failed to load content config:\n${error instanceof Error ? error.message : String(error)}`,
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Always update types when config is valid
|
|
269
|
+
await writeTypes(config.root, loadedCollections);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await runLoaders(config.root, loadedCollections, server);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
config.logger.error(
|
|
275
|
+
`[content-layer] ${error instanceof Error ? error.message : String(error)}`,
|
|
276
|
+
);
|
|
277
|
+
// Keep previous valid content state
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Collect new entry keys for the changed file
|
|
282
|
+
const newMappings = entryFilePaths.get(resolvedPath);
|
|
283
|
+
const entryKeys = [...previousEntryKeys];
|
|
284
|
+
if (newMappings) {
|
|
285
|
+
for (const m of newMappings) {
|
|
286
|
+
const key = `${m.collection}/${m.id}`;
|
|
287
|
+
if (!entryKeys.includes(key)) {
|
|
288
|
+
entryKeys.push(key);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
invalidateContentModules(server, entryKeys);
|
|
294
|
+
server.hot.send({ type: "full-reload" });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
async buildStart() {
|
|
299
|
+
// Load config first — types only depend on schemas, not content data
|
|
300
|
+
let loadedCollections: Record<string, Collection>;
|
|
301
|
+
try {
|
|
302
|
+
loadedCollections = await loadConfig(config.root);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (config.command === "build") throw error;
|
|
305
|
+
config.logger.error(
|
|
306
|
+
`[content-layer] Failed to load content config:\n${error instanceof Error ? error.message : String(error)}`,
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Always write types when config is valid
|
|
312
|
+
await writeTypes(config.root, loadedCollections);
|
|
313
|
+
|
|
314
|
+
// Then load content data (loaders may fail independently)
|
|
315
|
+
try {
|
|
316
|
+
await runLoaders(config.root, loadedCollections);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (config.command === "build") throw error;
|
|
319
|
+
config.logger.error(
|
|
320
|
+
`[content-layer] Failed to load content:\n${error instanceof Error ? error.message : String(error)}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
configResolved(resolvedConfig) {
|
|
326
|
+
config = resolvedConfig;
|
|
327
|
+
// Register a Node.js resolve hook so native import() can handle sprinkles: imports.
|
|
328
|
+
// This is needed because loadConfig uses native import() which bypasses Vite's plugin pipeline.
|
|
329
|
+
let hookPath = resolve(config.root, "content-layer/resolve-hook.js");
|
|
330
|
+
register(pathToFileURL(hookPath));
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
configureServer(server) {
|
|
334
|
+
// Watch for content file changes, additions, and deletions
|
|
335
|
+
server.watcher.on("change", filePath => handleContentChange(server, filePath));
|
|
336
|
+
server.watcher.on("add", filePath => handleContentChange(server, filePath));
|
|
337
|
+
server.watcher.on("unlink", filePath => handleContentChange(server, filePath));
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
handleHotUpdate({ file }) {
|
|
341
|
+
let resolvedPath = resolve(file);
|
|
342
|
+
if (isContentPath(resolvedPath)) {
|
|
343
|
+
// Already handled in configureServer watcher
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
load(id) {
|
|
349
|
+
if (id === RESOLVED_VIRTUAL_ID) {
|
|
350
|
+
return `
|
|
351
|
+
import "server-only";
|
|
352
|
+
export { defineCollection, reference } from "${resolve(config.root, "content-layer/config.ts")}";
|
|
353
|
+
|
|
354
|
+
import { createRuntime } from "${resolve(config.root, "content-layer/runtime.ts")}";
|
|
355
|
+
import { createDataStore } from "${resolve(config.root, "content-layer/store.ts")}";
|
|
356
|
+
|
|
357
|
+
let storeData = ${serializeStores()};
|
|
358
|
+
|
|
359
|
+
let importers = ${generateImporterMap()};
|
|
360
|
+
|
|
361
|
+
let stores = new Map();
|
|
362
|
+
for (let [name, entries] of Object.entries(storeData)) {
|
|
363
|
+
let store = createDataStore();
|
|
364
|
+
for (let entry of Object.values(entries)) {
|
|
365
|
+
store.set(entry);
|
|
366
|
+
}
|
|
367
|
+
stores.set(name, store);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let runtime = createRuntime(stores, importers);
|
|
371
|
+
export let getCollection = runtime.getCollection;
|
|
372
|
+
export let getEntry = runtime.getEntry;
|
|
373
|
+
export let getEntries = runtime.getEntries;
|
|
374
|
+
export let render = runtime.render;
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (id === RESOLVED_STORE_ID) {
|
|
379
|
+
return `export default ${serializeStores()};`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (id.startsWith(RESOLVED_ENTRY_PREFIX) && id.endsWith(".mdx")) {
|
|
383
|
+
// virtual:sprinkles-content/entry/<collection>/<id>.mdx
|
|
384
|
+
let path = id.slice(RESOLVED_ENTRY_PREFIX.length, -4);
|
|
385
|
+
let slashIndex = path.indexOf("/");
|
|
386
|
+
let collectionName = path.slice(0, slashIndex);
|
|
387
|
+
let entryId = path.slice(slashIndex + 1);
|
|
388
|
+
|
|
389
|
+
let store = stores.get(collectionName);
|
|
390
|
+
if (!store) return null;
|
|
391
|
+
|
|
392
|
+
let entry = store.get(entryId);
|
|
393
|
+
if (!entry?.body) return null;
|
|
394
|
+
|
|
395
|
+
// Return raw MDX/MD content - @mdx-js/rollup will transform it
|
|
396
|
+
return {
|
|
397
|
+
code: entry.body,
|
|
398
|
+
// Signal to @mdx-js/rollup that this is MDX
|
|
399
|
+
map: null,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
name: "sprinkles-content-layer",
|
|
405
|
+
|
|
406
|
+
resolveId(id) {
|
|
407
|
+
if (id === VIRTUAL_MODULE_ID) {
|
|
408
|
+
return RESOLVED_VIRTUAL_ID;
|
|
409
|
+
}
|
|
410
|
+
if (id === VIRTUAL_STORE_ID) {
|
|
411
|
+
return RESOLVED_STORE_ID;
|
|
412
|
+
}
|
|
413
|
+
if (id.startsWith(VIRTUAL_ENTRY_PREFIX) && !id.endsWith(".mdx")) {
|
|
414
|
+
// Add .mdx extension so @mdx-js/rollup's transform hook compiles it
|
|
415
|
+
return `${id}.mdx`;
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
// Resolve config.ts relative to this hook file (same directory)
|
|
5
|
+
const configUrl = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), "config.ts")).href;
|
|
6
|
+
|
|
7
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
8
|
+
if (specifier === "sprinkles:content") {
|
|
9
|
+
return { shortCircuit: true, url: configUrl };
|
|
10
|
+
}
|
|
11
|
+
return nextResolve(specifier, context);
|
|
12
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
import type { DataEntry, DataStore, MarkdownHeading, RenderedEntry } from "./api.ts";
|
|
4
|
+
|
|
5
|
+
type EntryImporter = () => Promise<{ default: ComponentType; headings?: MarkdownHeading[] }>;
|
|
6
|
+
|
|
7
|
+
interface ReferenceEntry {
|
|
8
|
+
collection: string;
|
|
9
|
+
id: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createRuntime(
|
|
13
|
+
stores: Map<string, DataStore>,
|
|
14
|
+
importers: Record<string, EntryImporter>,
|
|
15
|
+
) {
|
|
16
|
+
async function getCollection(
|
|
17
|
+
collection: string,
|
|
18
|
+
filter?: (entry: DataEntry) => unknown,
|
|
19
|
+
): Promise<DataEntry[]> {
|
|
20
|
+
const store = stores.get(collection);
|
|
21
|
+
if (!store) {return [];}
|
|
22
|
+
const entries = store.values();
|
|
23
|
+
if (filter) {
|
|
24
|
+
return entries.filter(filter);
|
|
25
|
+
}
|
|
26
|
+
return entries;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getEntry(
|
|
30
|
+
collectionOrRef: string | ReferenceEntry,
|
|
31
|
+
slug?: string,
|
|
32
|
+
): Promise<DataEntry | undefined> {
|
|
33
|
+
if (typeof collectionOrRef === "object") {
|
|
34
|
+
const store = stores.get(collectionOrRef.collection);
|
|
35
|
+
return store?.get(collectionOrRef.id);
|
|
36
|
+
}
|
|
37
|
+
const store = stores.get(collectionOrRef);
|
|
38
|
+
return store?.get(slug!);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getEntries(refs: ReferenceEntry[]): Promise<DataEntry[]> {
|
|
42
|
+
const results: DataEntry[] = [];
|
|
43
|
+
for (const ref of refs) {
|
|
44
|
+
const entry = await getEntry(ref);
|
|
45
|
+
if (entry) {
|
|
46
|
+
results.push(entry);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function render(entry: DataEntry): Promise<RenderedEntry> {
|
|
53
|
+
const collectionName = findCollectionForEntry(entry);
|
|
54
|
+
const key = `${collectionName}/${entry.id}`;
|
|
55
|
+
const importer = importers[key];
|
|
56
|
+
if (!importer) {
|
|
57
|
+
throw new Error(`No content found for entry "${key}"`);
|
|
58
|
+
}
|
|
59
|
+
const mod = await importer();
|
|
60
|
+
return { Content: mod.default, headings: mod.headings ?? [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function findCollectionForEntry(entry: DataEntry): string {
|
|
64
|
+
for (const [name, store] of stores) {
|
|
65
|
+
if (store.has(entry.id)) {
|
|
66
|
+
return name;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Entry "${entry.id}" not found in any collection`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { getCollection, getEntries, getEntry, render };
|
|
73
|
+
}
|