@n6k.io/build 0.0.1
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/README.md +71 -0
- package/package.json +102 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +1 -0
- package/src/pipeline/build.ts +301 -0
- package/src/pipeline/html.test.ts +173 -0
- package/src/pipeline/imports.test.ts +95 -0
- package/src/pipeline/imports.ts +129 -0
- package/src/pipeline/layouts.ts +39 -0
- package/src/pipeline/preprocess.test.ts +137 -0
- package/src/pipeline/preprocess.ts +212 -0
- package/src/storybook/decorators.tsx +93 -0
- package/src/storybook/index.ts +13 -0
- package/src/storybook/preview.ts +17 -0
- package/src/storybook/seed-demo-data.ts +206 -0
- package/src/storybook/story-shell.tsx +21 -0
- package/src/templates/contents-mdx.hbs +9 -0
- package/src/templates/entry.hbs +4 -0
- package/src/templates/page.hbs +18 -0
- package/src/util/logger.ts +42 -0
- package/src/vite/cross-origin.ts +19 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/wasm.ts +67 -0
- package/src/vite/workers.ts +91 -0
- package/src/wasm/extensions.ts +32 -0
- package/src/workers/manifest.ts +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @n6k.io/build
|
|
2
|
+
|
|
3
|
+
Static build pipeline for [n6k](https://github.com/n6k-io) apps. Preprocesses
|
|
4
|
+
`.mdx` pages (and passes `.ts`/`.tsx`/`.js`/`.jsx` entries through), bundles them
|
|
5
|
+
with esbuild, and optionally renders static HTML.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @n6k.io/build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package ships **raw TypeScript source** (no build step) — your bundler
|
|
14
|
+
compiles it. `esbuild`, `react`, and `react-dom` are peer dependencies resolved
|
|
15
|
+
from the consuming app's `node_modules`.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
As a CLI (the `n6k-build` bin):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
n6k-build [appDir] [--debug]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`appDir` defaults to the current working directory. Configuration is read from
|
|
26
|
+
`<appDir>/build/n6k.build.json`.
|
|
27
|
+
|
|
28
|
+
As a library:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { build } from "@n6k.io/build";
|
|
32
|
+
|
|
33
|
+
await build(process.cwd());
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## MDX components
|
|
37
|
+
|
|
38
|
+
The MDX provider (`useMDXComponents`) is resolved, in order:
|
|
39
|
+
|
|
40
|
+
1. `mdxComponents` in `n6k.build.json` — a package specifier or path, e.g.
|
|
41
|
+
`"@n6k.io/ui/lib/mdx-components"`.
|
|
42
|
+
2. `<appDir>/mdx-components.tsx`, if present.
|
|
43
|
+
3. Otherwise none — MDX renders with its built-in default components.
|
|
44
|
+
|
|
45
|
+
This package has no dependency on `@n6k.io/ui`; apps opt into a component set
|
|
46
|
+
via the `mdxComponents` config field.
|
|
47
|
+
|
|
48
|
+
## Peer dependencies
|
|
49
|
+
|
|
50
|
+
`esbuild`, `react`, and `react-dom` are resolved from the consuming app's
|
|
51
|
+
`node_modules`. Optional integrations (`@n6k.io/db`, `@tanstack/react-query`,
|
|
52
|
+
`@storybook/react`, `storybook`, `vite`) are declared as optional peers and only
|
|
53
|
+
needed when you use the matching entry point.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bun install
|
|
59
|
+
bun test # run the test suite
|
|
60
|
+
bun run typecheck
|
|
61
|
+
bun run fmt # format with prettier
|
|
62
|
+
bun run ci # fmt:check + typecheck + test (the CI gate)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
CI runs `bun ci` on every push and pull request to `main`. Pushing a `v*` tag
|
|
66
|
+
runs the same gate and then publishes the package to npm
|
|
67
|
+
(`.github/workflows/publish.yml`).
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@n6k.io/build",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Static build pipeline (.mdx + ts/tsx/js/jsx → esbuild bundle → HTML) for n6k apps",
|
|
6
|
+
"homepage": "https://github.com/n6k-io/build#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/n6k-io/build/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": "https://github.com/n6k-io/build",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "src/index.ts",
|
|
15
|
+
"module": "src/index.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"n6k-build": "src/cli.ts"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.ts",
|
|
24
|
+
"./imports": "./src/pipeline/imports.ts",
|
|
25
|
+
"./workers": "./src/workers/manifest.ts",
|
|
26
|
+
"./wasm": "./src/wasm/extensions.ts",
|
|
27
|
+
"./vite": "./src/vite/index.ts",
|
|
28
|
+
"./storybook": "./src/storybook/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"fmt": "prettier --write --log-level=warn .",
|
|
35
|
+
"check": "bun run fmt && bun run lint --fix && bun run typecheck",
|
|
36
|
+
"fmt:check": "prettier --check .",
|
|
37
|
+
"ci": "bun run fmt:check && bun run lint && bun run typecheck && bun test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@mdx-js/mdx": "^3.1.1",
|
|
41
|
+
"handlebars": "^4.7.8",
|
|
42
|
+
"pino": "^10.3.1",
|
|
43
|
+
"pino-pretty": "^13.1.3",
|
|
44
|
+
"remark-frontmatter": "^5.0.0",
|
|
45
|
+
"remark-mdx-frontmatter": "^5.2.0",
|
|
46
|
+
"zod": "^4.3.6"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@n6k.io/db": "*",
|
|
50
|
+
"@storybook/react": "^10.4.4",
|
|
51
|
+
"@tanstack/react-query": "^5.101.0",
|
|
52
|
+
"esbuild": "^0.27.3",
|
|
53
|
+
"react": "^19.2.4",
|
|
54
|
+
"react-dom": "^19.2.4",
|
|
55
|
+
"storybook": "^10.4.4",
|
|
56
|
+
"typescript": "^5",
|
|
57
|
+
"vite": "^8"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"@n6k.io/db": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@storybook/react": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@tanstack/react-query": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"react": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"react-dom": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"storybook": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"vite": {
|
|
79
|
+
"optional": true
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"@eslint/js": "^10.0.1",
|
|
84
|
+
"@n6k.io/db": "link:@n6k.io/db",
|
|
85
|
+
"@storybook/react": "^10.4.4",
|
|
86
|
+
"@tanstack/react-query": "^5.101.0",
|
|
87
|
+
"@types/bun": "latest",
|
|
88
|
+
"@types/react": "^19.2.0",
|
|
89
|
+
"@types/react-dom": "^19.2.0",
|
|
90
|
+
"esbuild": "^0.27.3",
|
|
91
|
+
"eslint": "^10.4.1",
|
|
92
|
+
"eslint-plugin-react": "^7.37.5",
|
|
93
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
94
|
+
"eslint-plugin-storybook": "^10.4.4",
|
|
95
|
+
"eslint-plugin-unicorn": "^65.0.1",
|
|
96
|
+
"prettier": "^3.8.1",
|
|
97
|
+
"storybook": "^10.4.4",
|
|
98
|
+
"typescript": "^5.9.3",
|
|
99
|
+
"typescript-eslint": "^8.61.0",
|
|
100
|
+
"vite": "^8"
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { initLogger, flushLogger } from "./util/logger";
|
|
5
|
+
import { build, BuildError } from "./pipeline/build";
|
|
6
|
+
|
|
7
|
+
const { values, positionals } = parseArgs({
|
|
8
|
+
args: process.argv.slice(2),
|
|
9
|
+
options: {
|
|
10
|
+
debug: { type: "boolean", default: false },
|
|
11
|
+
},
|
|
12
|
+
allowPositionals: true,
|
|
13
|
+
strict: true,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
initLogger(values.debug!);
|
|
17
|
+
const uiDir = positionals[0] || process.cwd();
|
|
18
|
+
try {
|
|
19
|
+
await build(path.resolve(uiDir));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err instanceof BuildError) {
|
|
22
|
+
console.error(err.message);
|
|
23
|
+
await flushLogger();
|
|
24
|
+
process.exit(err.exitCode);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
await flushLogger();
|
|
29
|
+
process.exit(0);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { build } from "./pipeline/build";
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import * as esbuild from "esbuild";
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import Handlebars from "handlebars";
|
|
7
|
+
import { preprocess, type VirtualModule } from "./preprocess";
|
|
8
|
+
import { discoverLayouts } from "./layouts";
|
|
9
|
+
import { logger } from "../util/logger";
|
|
10
|
+
|
|
11
|
+
// Signals a build failure with a process exit code. The CLI (the bin) catches
|
|
12
|
+
// this and exits with `exitCode`; callers (tests, programmatic use) get a normal
|
|
13
|
+
// throw instead of the process being killed. `404` is a contract: it tells the
|
|
14
|
+
// server unresolved components were written to build/missing.json.
|
|
15
|
+
export class BuildError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly exitCode: number,
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "BuildError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// esbuild plugin: serve preprocess's generated modules (client entry, SSR
|
|
26
|
+
// wrapper, compiled MDX) from memory under the `n6k:` namespace — no temp dir.
|
|
27
|
+
function virtualModulesPlugin(
|
|
28
|
+
modules: Map<string, VirtualModule>,
|
|
29
|
+
resolveDir: string,
|
|
30
|
+
): esbuild.Plugin {
|
|
31
|
+
return {
|
|
32
|
+
name: "n6k-virtual",
|
|
33
|
+
setup(build) {
|
|
34
|
+
build.onResolve({ filter: /^n6k:/ }, (args) => ({
|
|
35
|
+
path: args.path,
|
|
36
|
+
namespace: "n6k-virtual",
|
|
37
|
+
}));
|
|
38
|
+
build.onLoad({ filter: /.*/, namespace: "n6k-virtual" }, (args) => {
|
|
39
|
+
const mod = modules.get(args.path);
|
|
40
|
+
if (!mod) return;
|
|
41
|
+
return { contents: mod.contents, loader: mod.loader, resolveDir };
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// The MDX provider (useMDXComponents) is a content/presentation concern owned
|
|
47
|
+
// by the consuming app, not the build tool. Resolve it from config first, then
|
|
48
|
+
// a conventional appDir/mdx-components.tsx, else leave it unset (plain MDX).
|
|
49
|
+
function discoverMdxComponents(
|
|
50
|
+
appDir: string,
|
|
51
|
+
config: Config,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
if (config.mdxComponents) return config.mdxComponents;
|
|
54
|
+
const userFile = path.join(appDir, "mdx-components.tsx");
|
|
55
|
+
if (existsSync(userFile)) return userFile;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
|
|
61
|
+
const workerSchema = z.object({
|
|
62
|
+
name: z.string(),
|
|
63
|
+
path: z.string(),
|
|
64
|
+
url: z.string(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const wrapperSchema = z.object({
|
|
68
|
+
component: z.string(),
|
|
69
|
+
import: z.string(),
|
|
70
|
+
props: z.record(z.string(), z.any()).optional(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const configSchema = z.object({
|
|
74
|
+
srcDir: z.string().optional(),
|
|
75
|
+
appDir: z.string().optional(),
|
|
76
|
+
entryPoints: z.record(z.string(), z.string()).optional(),
|
|
77
|
+
workers: z.array(workerSchema).optional(),
|
|
78
|
+
wrappers: z.array(wrapperSchema).optional(),
|
|
79
|
+
assetPrefix: z.string().optional(),
|
|
80
|
+
scripts: z.array(z.string()).optional(),
|
|
81
|
+
headTags: z.array(z.string()).optional(),
|
|
82
|
+
mdxComponents: z.string().optional(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
type Config = z.infer<typeof configSchema>;
|
|
86
|
+
|
|
87
|
+
function loadConfig(uiDir: string): Config {
|
|
88
|
+
const configPath = path.join(uiDir, "build", "n6k.build.json");
|
|
89
|
+
if (!existsSync(configPath)) return {};
|
|
90
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
91
|
+
return configSchema.parse(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveEntryPoints(config: Config): Record<string, string> {
|
|
95
|
+
if (!config.entryPoints || Object.keys(config.entryPoints).length === 0) {
|
|
96
|
+
throw new BuildError(
|
|
97
|
+
"No entryPoints in n6k.build.json. Run the discover/build step first.",
|
|
98
|
+
1,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return config.entryPoints;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function build(uiDir: string = process.cwd()) {
|
|
105
|
+
const buildStart = performance.now();
|
|
106
|
+
uiDir = path.resolve(uiDir);
|
|
107
|
+
|
|
108
|
+
const config = loadConfig(uiDir);
|
|
109
|
+
logger.debug({ uiDir, config }, "config loaded");
|
|
110
|
+
const sourceEntries = resolveEntryPoints(config);
|
|
111
|
+
logger.debug(
|
|
112
|
+
{ entryPoints: Object.keys(sourceEntries) },
|
|
113
|
+
"entry points resolved",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const appDir = config.appDir ? path.resolve(config.appDir) : uiDir;
|
|
117
|
+
const srcDir = config.srcDir
|
|
118
|
+
? path.resolve(config.srcDir)
|
|
119
|
+
: path.join(uiDir, "src");
|
|
120
|
+
const componentsDir = path.join(srcDir, "components", "ui");
|
|
121
|
+
|
|
122
|
+
const wrappers = config.wrappers || [];
|
|
123
|
+
const layouts = discoverLayouts(sourceEntries, appDir);
|
|
124
|
+
const mdxComponentsSource = discoverMdxComponents(appDir, config);
|
|
125
|
+
|
|
126
|
+
// Pre-process: .mdx → in-memory virtual modules; ts/tsx/js/jsx pass through.
|
|
127
|
+
logger.debug("preprocessing entries");
|
|
128
|
+
const {
|
|
129
|
+
entries: esbuildEntries,
|
|
130
|
+
serverEntries,
|
|
131
|
+
missing,
|
|
132
|
+
meta,
|
|
133
|
+
modules,
|
|
134
|
+
} = await preprocess(sourceEntries, componentsDir, {
|
|
135
|
+
wrappers,
|
|
136
|
+
layouts,
|
|
137
|
+
mdxComponents: mdxComponentsSource,
|
|
138
|
+
});
|
|
139
|
+
logger.debug(
|
|
140
|
+
{
|
|
141
|
+
entries: Object.keys(esbuildEntries).length,
|
|
142
|
+
serverEntries: Object.keys(serverEntries).length,
|
|
143
|
+
missing: missing.length,
|
|
144
|
+
},
|
|
145
|
+
"preprocess complete",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (missing.length > 0) {
|
|
149
|
+
const outPath = path.join(uiDir, "build", "missing.json");
|
|
150
|
+
writeFileSync(outPath, JSON.stringify(missing, null, 2) + "\n");
|
|
151
|
+
let report = "\nMissing components:\n";
|
|
152
|
+
for (const { file, components } of missing) {
|
|
153
|
+
report += ` ${file}: ${components.join(", ")}\n`;
|
|
154
|
+
}
|
|
155
|
+
report += `\nWrote ${outPath}\n`;
|
|
156
|
+
throw new BuildError(report, 404);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// esbuild: .tsx → .js → build/public/
|
|
160
|
+
const buildDir = path.join(uiDir, "build");
|
|
161
|
+
const publicDir = path.join(buildDir, "public");
|
|
162
|
+
const nodeModules = path.join(uiDir, "node_modules");
|
|
163
|
+
|
|
164
|
+
logger.debug("esbuild: bundling client entries");
|
|
165
|
+
const esbuildStart = performance.now();
|
|
166
|
+
await esbuild.build({
|
|
167
|
+
entryPoints: esbuildEntries,
|
|
168
|
+
bundle: true,
|
|
169
|
+
splitting: true,
|
|
170
|
+
format: "esm",
|
|
171
|
+
jsx: "automatic",
|
|
172
|
+
absWorkingDir: uiDir,
|
|
173
|
+
tsconfig: path.join(uiDir, "tsconfig.json"),
|
|
174
|
+
outdir: publicDir,
|
|
175
|
+
nodePaths: [nodeModules],
|
|
176
|
+
preserveSymlinks: true,
|
|
177
|
+
plugins: [virtualModulesPlugin(modules, appDir)],
|
|
178
|
+
alias: {
|
|
179
|
+
"@": srcDir,
|
|
180
|
+
react: path.join(nodeModules, "react"),
|
|
181
|
+
"react-dom": path.join(nodeModules, "react-dom"),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
logger.debug(
|
|
186
|
+
{ ms: Math.round(performance.now() - esbuildStart) },
|
|
187
|
+
"esbuild: client bundle complete",
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Bundle workers as IIFE → build/public/
|
|
191
|
+
if (config.workers && config.workers.length > 0) {
|
|
192
|
+
const workerEntries: Record<string, string> = {};
|
|
193
|
+
for (const w of config.workers) {
|
|
194
|
+
workerEntries[w.name] = w.path;
|
|
195
|
+
}
|
|
196
|
+
logger.debug(
|
|
197
|
+
{ workers: Object.keys(workerEntries) },
|
|
198
|
+
"esbuild: bundling workers",
|
|
199
|
+
);
|
|
200
|
+
const workerStart = performance.now();
|
|
201
|
+
await esbuild.build({
|
|
202
|
+
entryPoints: workerEntries,
|
|
203
|
+
bundle: true,
|
|
204
|
+
format: "iife",
|
|
205
|
+
absWorkingDir: uiDir,
|
|
206
|
+
outdir: publicDir,
|
|
207
|
+
});
|
|
208
|
+
logger.debug(
|
|
209
|
+
{ ms: Math.round(performance.now() - workerStart) },
|
|
210
|
+
"esbuild: workers complete",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Generate HTML documents (opt-in via assetPrefix)
|
|
215
|
+
if (config.assetPrefix !== undefined) {
|
|
216
|
+
// Server build: bundle .app.jsx files for renderToString → build/__server__/
|
|
217
|
+
const serverOutdir = path.join(buildDir, "__server__");
|
|
218
|
+
const serverEsbuildEntries: Record<string, string> = {};
|
|
219
|
+
for (const [slug, entryPath] of Object.entries(serverEntries)) {
|
|
220
|
+
serverEsbuildEntries[slug] = entryPath;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (Object.keys(serverEsbuildEntries).length > 0) {
|
|
224
|
+
logger.debug(
|
|
225
|
+
{ entries: Object.keys(serverEsbuildEntries) },
|
|
226
|
+
"esbuild: bundling server entries",
|
|
227
|
+
);
|
|
228
|
+
const serverStart = performance.now();
|
|
229
|
+
await esbuild.build({
|
|
230
|
+
entryPoints: serverEsbuildEntries,
|
|
231
|
+
bundle: true,
|
|
232
|
+
format: "esm",
|
|
233
|
+
platform: "node",
|
|
234
|
+
jsx: "automatic",
|
|
235
|
+
absWorkingDir: uiDir,
|
|
236
|
+
outdir: serverOutdir,
|
|
237
|
+
nodePaths: [nodeModules],
|
|
238
|
+
external: ["react", "react-dom", "react/jsx-runtime"],
|
|
239
|
+
preserveSymlinks: true,
|
|
240
|
+
plugins: [virtualModulesPlugin(modules, appDir)],
|
|
241
|
+
alias: {
|
|
242
|
+
"@": srcDir,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
logger.debug(
|
|
246
|
+
{ ms: Math.round(performance.now() - serverStart) },
|
|
247
|
+
"esbuild: server bundle complete",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { createElement } = await import(path.join(nodeModules, "react"));
|
|
252
|
+
const { renderToString } = await import(
|
|
253
|
+
path.join(nodeModules, "react-dom", "server")
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const pageTemplatePath = path.resolve(
|
|
257
|
+
__dirname,
|
|
258
|
+
"..",
|
|
259
|
+
"templates",
|
|
260
|
+
"page.hbs",
|
|
261
|
+
);
|
|
262
|
+
const pageTemplate = Handlebars.compile(
|
|
263
|
+
readFileSync(pageTemplatePath, "utf8"),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const prefix = config.assetPrefix.replace(/\/+$/, "");
|
|
267
|
+
const headTagsStr = (config.headTags || []).join("\n ");
|
|
268
|
+
const scriptsStr = (config.scripts || []).join("\n ");
|
|
269
|
+
|
|
270
|
+
logger.debug("generating HTML pages");
|
|
271
|
+
for (const slug of Object.keys(sourceEntries)) {
|
|
272
|
+
let content = "";
|
|
273
|
+
const serverJsPath = path.join(serverOutdir, slug + ".js");
|
|
274
|
+
if (existsSync(serverJsPath)) {
|
|
275
|
+
const mod = await import(serverJsPath);
|
|
276
|
+
content = renderToString(createElement(mod.default));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const pageMeta = meta.get(slug) || {};
|
|
280
|
+
const html = pageTemplate({
|
|
281
|
+
assetPrefix: prefix,
|
|
282
|
+
slug,
|
|
283
|
+
headTags: headTagsStr,
|
|
284
|
+
scripts: scriptsStr,
|
|
285
|
+
content,
|
|
286
|
+
...pageMeta,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const htmlPath = path.join(publicDir, slug + ".html");
|
|
290
|
+
mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
291
|
+
writeFileSync(htmlPath, html);
|
|
292
|
+
logger.debug({ slug, htmlPath }, "wrote HTML");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
logger.debug(
|
|
297
|
+
{ ms: Math.round(performance.now() - buildStart) },
|
|
298
|
+
"build complete",
|
|
299
|
+
);
|
|
300
|
+
esbuild.stop();
|
|
301
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import Handlebars from "handlebars";
|
|
6
|
+
import { parseMdxFrontmatter, preprocess } from "./preprocess";
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
function loadPageTemplate() {
|
|
11
|
+
const templatePath = path.resolve(__dirname, "..", "templates", "page.hbs");
|
|
12
|
+
return Handlebars.compile(readFileSync(templatePath, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("parseMdxFrontmatter", () => {
|
|
16
|
+
test("parses title and description", () => {
|
|
17
|
+
const raw =
|
|
18
|
+
"---\ntitle: Dashboard\ndescription: Overview\n---\n# Content\n";
|
|
19
|
+
const { meta, body } = parseMdxFrontmatter(raw);
|
|
20
|
+
expect(meta.title).toBe("Dashboard");
|
|
21
|
+
expect(meta.description).toBe("Overview");
|
|
22
|
+
expect(body).toBe("# Content\n");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns empty meta for no frontmatter", () => {
|
|
26
|
+
const raw = "# Just content\n";
|
|
27
|
+
const { meta, body } = parseMdxFrontmatter(raw);
|
|
28
|
+
expect(meta).toEqual({});
|
|
29
|
+
expect(body).toBe("# Just content\n");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("handles empty frontmatter block", () => {
|
|
33
|
+
const raw = "---\n---\n# Content\n";
|
|
34
|
+
const { meta, body } = parseMdxFrontmatter(raw);
|
|
35
|
+
expect(meta).toEqual({});
|
|
36
|
+
expect(body).toBe("# Content\n");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("handles values with colons", () => {
|
|
40
|
+
const raw = "---\ntitle: My Page: A Subtitle\n---\n# Content\n";
|
|
41
|
+
const { meta } = parseMdxFrontmatter(raw);
|
|
42
|
+
expect(meta.title).toBe("My Page: A Subtitle");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("page.hbs template", () => {
|
|
47
|
+
const template = loadPageTemplate();
|
|
48
|
+
|
|
49
|
+
test("renders basic HTML document", () => {
|
|
50
|
+
const html = template({ assetPrefix: "/_nextpy", slug: "index" });
|
|
51
|
+
expect(html).toContain("<html lang");
|
|
52
|
+
expect(html).toContain('<div id="root">');
|
|
53
|
+
expect(html).toContain('href="/_nextpy/styles.css"');
|
|
54
|
+
expect(html).toContain('src="/_nextpy/index.js"');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("renders content inside root div", () => {
|
|
58
|
+
const html = template({
|
|
59
|
+
assetPrefix: "/_nextpy",
|
|
60
|
+
slug: "index",
|
|
61
|
+
content: "<h1>Hello World</h1>",
|
|
62
|
+
});
|
|
63
|
+
expect(html).toContain('<div id="root"><h1>Hello World</h1></div>');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("includes title when provided", () => {
|
|
67
|
+
const html = template({
|
|
68
|
+
assetPrefix: "/_nextpy",
|
|
69
|
+
slug: "dashboard",
|
|
70
|
+
title: "My Dashboard",
|
|
71
|
+
});
|
|
72
|
+
expect(html).toContain("<title>My Dashboard</title>");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("excludes title when not provided", () => {
|
|
76
|
+
const html = template({ assetPrefix: "/_nextpy", slug: "index" });
|
|
77
|
+
expect(html).not.toContain("<title>");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("includes description meta when provided", () => {
|
|
81
|
+
const html = template({
|
|
82
|
+
assetPrefix: "/_nextpy",
|
|
83
|
+
slug: "dashboard",
|
|
84
|
+
description: "Overview of metrics",
|
|
85
|
+
});
|
|
86
|
+
expect(html).toContain('name="description"');
|
|
87
|
+
expect(html).toContain('content="Overview of metrics"');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("excludes description meta when not provided", () => {
|
|
91
|
+
const html = template({ assetPrefix: "/_nextpy", slug: "index" });
|
|
92
|
+
expect(html).not.toContain('<meta name="description"');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("includes custom scripts", () => {
|
|
96
|
+
const html = template({
|
|
97
|
+
assetPrefix: "/_nextpy",
|
|
98
|
+
slug: "index",
|
|
99
|
+
scripts: "<script>console.log('dev')</script>",
|
|
100
|
+
});
|
|
101
|
+
expect(html).toContain("<script>console.log('dev')</script>");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("includes custom headTags", () => {
|
|
105
|
+
const html = template({
|
|
106
|
+
assetPrefix: "/_nextpy",
|
|
107
|
+
slug: "index",
|
|
108
|
+
headTags: '<meta name="theme-color" content="#000">',
|
|
109
|
+
});
|
|
110
|
+
expect(html).toContain('<meta name="theme-color" content="#000">');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("normalizes trailing slash on assetPrefix", () => {
|
|
114
|
+
const prefix = "/_nextpy/".replace(/\/+$/, "");
|
|
115
|
+
const html = template({ assetPrefix: prefix, slug: "index" });
|
|
116
|
+
expect(html).toContain('href="/_nextpy/styles.css"');
|
|
117
|
+
expect(html).not.toContain("/_nextpy//");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("preprocess returns meta for MDX", () => {
|
|
122
|
+
const tmpBase = path.join(process.cwd(), "scratch", "tmp", "meta-test");
|
|
123
|
+
const componentsDir = path.join(tmpBase, "components");
|
|
124
|
+
|
|
125
|
+
function setup() {
|
|
126
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
127
|
+
mkdirSync(componentsDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cleanup() {
|
|
131
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("returns meta from MDX frontmatter", async () => {
|
|
135
|
+
setup();
|
|
136
|
+
try {
|
|
137
|
+
const mdxDir = path.join(tmpBase, "pages");
|
|
138
|
+
mkdirSync(mdxDir, { recursive: true });
|
|
139
|
+
|
|
140
|
+
const mdxFile = path.join(mdxDir, "dashboard.mdx");
|
|
141
|
+
writeFileSync(
|
|
142
|
+
mdxFile,
|
|
143
|
+
"---\ntitle: Dashboard\ndescription: Overview\n---\n\n# Dashboard\n",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const { meta } = await preprocess({ dashboard: mdxFile }, componentsDir);
|
|
147
|
+
|
|
148
|
+
expect(meta.get("dashboard")).toEqual({
|
|
149
|
+
title: "Dashboard",
|
|
150
|
+
description: "Overview",
|
|
151
|
+
});
|
|
152
|
+
} finally {
|
|
153
|
+
cleanup();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("MDX without frontmatter has no meta", async () => {
|
|
158
|
+
setup();
|
|
159
|
+
try {
|
|
160
|
+
const mdxDir = path.join(tmpBase, "pages");
|
|
161
|
+
mkdirSync(mdxDir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
const mdxFile = path.join(mdxDir, "plain.mdx");
|
|
164
|
+
writeFileSync(mdxFile, "# Just content\n");
|
|
165
|
+
|
|
166
|
+
const { meta } = await preprocess({ plain: mdxFile }, componentsDir);
|
|
167
|
+
|
|
168
|
+
expect(meta.get("plain")).toBeUndefined();
|
|
169
|
+
} finally {
|
|
170
|
+
cleanup();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|