@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.
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ extractTags,
4
+ extractExistingImports,
5
+ extractImportedNames,
6
+ } from "./imports";
7
+
8
+ describe("extractTags", () => {
9
+ test("finds component tags", () => {
10
+ expect(extractTags("<Button>click</Button>")).toEqual(new Set(["Button"]));
11
+ });
12
+
13
+ test("finds multiple distinct tags", () => {
14
+ const snippet = "<Card><Button>ok</Button><Input /></Card>";
15
+ expect(extractTags(snippet)).toEqual(new Set(["Card", "Button", "Input"]));
16
+ });
17
+
18
+ test("deduplicates repeated tags", () => {
19
+ const snippet = "<Button /><Button /><Button />";
20
+ expect(extractTags(snippet)).toEqual(new Set(["Button"]));
21
+ });
22
+
23
+ test("ignores lowercase html tags", () => {
24
+ expect(extractTags("<div><span>hi</span></div>")).toEqual(new Set());
25
+ });
26
+
27
+ test("returns empty set for plain text", () => {
28
+ expect(extractTags("no tags here")).toEqual(new Set());
29
+ });
30
+
31
+ test("finds self-closing tags", () => {
32
+ expect(extractTags("<Separator />")).toEqual(new Set(["Separator"]));
33
+ });
34
+
35
+ test("finds tags with attributes", () => {
36
+ expect(extractTags('<Input type="text" className="w-full" />')).toEqual(
37
+ new Set(["Input"]),
38
+ );
39
+ });
40
+ });
41
+
42
+ describe("extractExistingImports", () => {
43
+ test("separates import lines from body", () => {
44
+ const source = `import Foo from 'foo';
45
+ import { Bar } from 'bar';
46
+
47
+ # Hello
48
+
49
+ <Foo />`;
50
+ const result = extractExistingImports(source);
51
+ expect(result.importLines).toEqual([
52
+ "import Foo from 'foo';",
53
+ "import { Bar } from 'bar';",
54
+ ]);
55
+ expect(result.body).toStartWith("# Hello");
56
+ });
57
+
58
+ test("handles source with no imports", () => {
59
+ const source = "# Just markdown\n\n<Button />";
60
+ const result = extractExistingImports(source);
61
+ expect(result.importLines).toEqual([]);
62
+ expect(result.body).toBe("# Just markdown\n\n<Button />");
63
+ });
64
+
65
+ test("handles empty source", () => {
66
+ const result = extractExistingImports("");
67
+ expect(result.importLines).toEqual([]);
68
+ expect(result.body).toBe("");
69
+ });
70
+ });
71
+
72
+ describe("extractImportedNames", () => {
73
+ test("extracts named imports", () => {
74
+ const names = extractImportedNames([
75
+ "import { Button, Card } from '@/components/ui/button';",
76
+ ]);
77
+ expect(names).toEqual(new Set(["Button", "Card"]));
78
+ });
79
+
80
+ test("extracts default imports starting with uppercase", () => {
81
+ const names = extractImportedNames([
82
+ "import MyComponent from './my-component';",
83
+ ]);
84
+ expect(names).toEqual(new Set(["MyComponent"]));
85
+ });
86
+
87
+ test("handles aliased imports", () => {
88
+ const names = extractImportedNames(["import { Foo as Bar } from 'baz';"]);
89
+ expect(names).toEqual(new Set(["Bar"]));
90
+ });
91
+
92
+ test("returns empty set for no imports", () => {
93
+ expect(extractImportedNames([])).toEqual(new Set());
94
+ });
95
+ });
@@ -0,0 +1,129 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const EXPORT_DECL_RE =
5
+ /export\s+(?:function|const|class)\s+([A-Z][a-zA-Z0-9]*)/g;
6
+ const EXPORT_BLOCK_RE = /export\s*\{([^}]+)\}/g;
7
+ const JSX_TAG_RE = /<([A-Z][a-zA-Z0-9]*)/g;
8
+
9
+ function buildExportMap(componentsDir: string): Map<string, string> {
10
+ const exportMap = new Map<string, string>();
11
+
12
+ let files: string[];
13
+ try {
14
+ files = readdirSync(componentsDir).filter((f) => f.endsWith(".tsx"));
15
+ } catch {
16
+ return exportMap;
17
+ }
18
+
19
+ for (const file of files) {
20
+ const src = readFileSync(path.resolve(componentsDir, file), "utf8");
21
+ const stem = file.replace(/\.tsx$/, "");
22
+ const importPath = `@/components/ui/${stem}`;
23
+
24
+ let m;
25
+
26
+ EXPORT_DECL_RE.lastIndex = 0;
27
+ while ((m = EXPORT_DECL_RE.exec(src)) !== null) {
28
+ exportMap.set(m[1], importPath);
29
+ }
30
+
31
+ EXPORT_BLOCK_RE.lastIndex = 0;
32
+ while ((m = EXPORT_BLOCK_RE.exec(src)) !== null) {
33
+ for (const name of m[1].split(",")) {
34
+ const parts = name.trim().split(/\s+as\s+/);
35
+ const trimmed = parts.at(-1)?.trim();
36
+ if (trimmed && /^[A-Z]/.test(trimmed)) {
37
+ exportMap.set(trimmed, importPath);
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ return exportMap;
44
+ }
45
+
46
+ const IMPORT_RE = /^\s*import\s+.*$/gm;
47
+
48
+ export function extractExistingImports(source: string): {
49
+ importLines: string[];
50
+ body: string;
51
+ } {
52
+ const importLines: string[] = [];
53
+ const body = source.replaceAll(IMPORT_RE, (match) => {
54
+ importLines.push(match.trim());
55
+ return "";
56
+ });
57
+ return { importLines, body: body.trimStart() };
58
+ }
59
+
60
+ export function extractImportedNames(importLines: string[]): Set<string> {
61
+ const names = new Set<string>();
62
+ const namedRe = /\{\s*([^}]+)\s*\}/;
63
+ const defaultRe = /import\s+([A-Z][a-zA-Z0-9]*)/;
64
+ for (const line of importLines) {
65
+ const namedMatch = line.match(namedRe);
66
+ if (namedMatch) {
67
+ for (const name of namedMatch[1].split(",")) {
68
+ const parts = name.trim().split(/\s+as\s+/);
69
+ const final = parts.at(-1)?.trim();
70
+ if (final) names.add(final);
71
+ }
72
+ }
73
+ const defMatch = line.match(defaultRe);
74
+ if (defMatch) names.add(defMatch[1]);
75
+ }
76
+ return names;
77
+ }
78
+
79
+ export function extractTags(snippet: string): Set<string> {
80
+ const tags = new Set<string>();
81
+ let m;
82
+ JSX_TAG_RE.lastIndex = 0;
83
+ while ((m = JSX_TAG_RE.exec(snippet)) !== null) {
84
+ tags.add(m[1]);
85
+ }
86
+ return tags;
87
+ }
88
+
89
+ export function resolveImports(
90
+ snippetsByFile: Map<string, string>,
91
+ componentsDir: string,
92
+ ) {
93
+ const exportMap = buildExportMap(componentsDir);
94
+ const resolved = new Map<string, string>();
95
+ const missing: { file: string; components: string[] }[] = [];
96
+
97
+ for (const [file, snippet] of snippetsByFile) {
98
+ const tags = extractTags(snippet);
99
+ const grouped = new Map<string, string[]>();
100
+ const fileMissing: string[] = [];
101
+
102
+ for (const tag of tags) {
103
+ const importPath = exportMap.get(tag);
104
+ if (importPath) {
105
+ let tags = grouped.get(importPath);
106
+ if (!tags) {
107
+ tags = [];
108
+ grouped.set(importPath, tags);
109
+ }
110
+ tags.push(tag);
111
+ } else {
112
+ fileMissing.push(tag);
113
+ }
114
+ }
115
+
116
+ const lines: string[] = [];
117
+ for (const [specifier, names] of grouped) {
118
+ lines.push(`import { ${names.join(", ")} } from '${specifier}';`);
119
+ }
120
+
121
+ resolved.set(file, lines.join("\n"));
122
+
123
+ if (fileMissing.length > 0) {
124
+ missing.push({ file, components: fileMissing });
125
+ }
126
+ }
127
+
128
+ return { resolved, missing };
129
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Discover layout.tsx files for each .mdx entry point by walking
6
+ * from the page's directory up to appDir.
7
+ *
8
+ * Returns { slug: [absPath, ...] } ordered outermost-first.
9
+ * ts/tsx/js/jsx pass-through entries are skipped (they are not wrapped).
10
+ */
11
+ export function discoverLayouts(
12
+ entryPoints: Record<string, string>,
13
+ appDir: string,
14
+ ): Record<string, string[]> {
15
+ const root = path.resolve(appDir);
16
+ const layouts: Record<string, string[]> = {};
17
+
18
+ for (const [slug, sourcePath] of Object.entries(entryPoints)) {
19
+ if (!sourcePath.endsWith(".mdx")) continue;
20
+
21
+ const chain: string[] = [];
22
+ let dir = path.dirname(path.resolve(sourcePath));
23
+
24
+ while (dir.startsWith(root) || dir === root) {
25
+ const candidate = path.join(dir, "layout.tsx");
26
+ if (existsSync(candidate)) {
27
+ chain.unshift(candidate);
28
+ }
29
+ if (dir === root) break;
30
+ dir = path.dirname(dir);
31
+ }
32
+
33
+ if (chain.length > 0) {
34
+ layouts[slug] = chain;
35
+ }
36
+ }
37
+
38
+ return layouts;
39
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { buildPropsString, preprocess } from "./preprocess";
5
+
6
+ describe("buildPropsString", () => {
7
+ test("returns empty string for undefined", () => {
8
+ expect(buildPropsString()).toBe("");
9
+ });
10
+
11
+ test("returns empty string for empty object", () => {
12
+ expect(buildPropsString({})).toBe("");
13
+ });
14
+
15
+ test("converts string value", () => {
16
+ expect(buildPropsString({ name: "hello" })).toBe(' name={ "hello" }');
17
+ });
18
+
19
+ test("converts number value", () => {
20
+ expect(buildPropsString({ count: 42 })).toBe(" count={ 42 }");
21
+ });
22
+
23
+ test("converts boolean value", () => {
24
+ expect(buildPropsString({ open: true })).toBe(" open={ true }");
25
+ });
26
+
27
+ test("converts multiple props", () => {
28
+ const result = buildPropsString({ a: 1, b: "two" });
29
+ expect(result).toBe(' a={ 1 } b={ "two" }');
30
+ });
31
+
32
+ test("converts nested object", () => {
33
+ const result = buildPropsString({ style: { color: "red" } });
34
+ expect(result).toBe(' style={ {"color":"red"} }');
35
+ });
36
+ });
37
+
38
+ describe("preprocess MDX", () => {
39
+ const tmpBase = path.join(process.cwd(), "scratch", "tmp", "mdx-test");
40
+ const componentsDir = path.join(tmpBase, "components");
41
+ const mdxDir = path.join(tmpBase, "pages");
42
+
43
+ function setup() {
44
+ rmSync(tmpBase, { recursive: true, force: true });
45
+ mkdirSync(componentsDir, { recursive: true });
46
+ mkdirSync(mdxDir, { recursive: true });
47
+
48
+ // Create a fake component file so resolveImports can find it
49
+ writeFileSync(
50
+ path.join(componentsDir, "button.tsx"),
51
+ "export function Button() { return null; }\n",
52
+ );
53
+ writeFileSync(
54
+ path.join(componentsDir, "card.tsx"),
55
+ "export function Card() { return null; }\n",
56
+ );
57
+ }
58
+
59
+ function cleanup() {
60
+ rmSync(tmpBase, { recursive: true, force: true });
61
+ }
62
+
63
+ test("produces client + app + mdx virtual modules for .mdx entry", async () => {
64
+ setup();
65
+ try {
66
+ const mdxFile = path.join(mdxDir, "hello.mdx");
67
+ writeFileSync(mdxFile, "# Hello\n\n<Button>click</Button>\n");
68
+
69
+ const { entries, serverEntries, modules } = await preprocess(
70
+ { hello: mdxFile },
71
+ componentsDir,
72
+ );
73
+
74
+ // Client entry → hydration module.
75
+ expect(entries.hello).toBe("n6k:client:hello");
76
+ const clientMod = modules.get("n6k:client:hello")!;
77
+ expect(clientMod.contents).toContain("createRoot");
78
+ expect(clientMod.contents).toContain("hydrateRoot");
79
+ expect(clientMod.contents).toContain("n6k:app:hello");
80
+
81
+ // Server entry → SSR wrapper importing the compiled MDX.
82
+ expect(serverEntries.hello).toBe("n6k:app:hello");
83
+ const appMod = modules.get("n6k:app:hello")!;
84
+ expect(appMod.contents).toContain("import Content from 'n6k:mdx:hello'");
85
+ expect(appMod.contents).toContain("export default function App");
86
+
87
+ // Compiled MDX module exists.
88
+ expect(modules.has("n6k:mdx:hello")).toBe(true);
89
+ } finally {
90
+ cleanup();
91
+ }
92
+ });
93
+
94
+ test("auto-imports appear in the compiled MDX module", async () => {
95
+ setup();
96
+ try {
97
+ const mdxFile = path.join(mdxDir, "page.mdx");
98
+ writeFileSync(mdxFile, "# Page\n\n<Button />\n<Card />\n");
99
+
100
+ const { modules } = await preprocess({ page: mdxFile }, componentsDir);
101
+
102
+ const mdx = modules.get("n6k:mdx:page")!.contents;
103
+ expect(mdx).toContain("@/components/ui/button");
104
+ expect(mdx).toContain("@/components/ui/card");
105
+ } finally {
106
+ cleanup();
107
+ }
108
+ });
109
+
110
+ test("existing MDX imports are not duplicated", async () => {
111
+ setup();
112
+ try {
113
+ const mdxFile = path.join(mdxDir, "existing.mdx");
114
+ writeFileSync(
115
+ mdxFile,
116
+ `import { Button } from '@/components/ui/button';
117
+
118
+ # Existing
119
+
120
+ <Button />\n<Card />\n`,
121
+ );
122
+
123
+ const { modules } = await preprocess(
124
+ { existing: mdxFile },
125
+ componentsDir,
126
+ );
127
+
128
+ const mdx = modules.get("n6k:mdx:existing")!.contents;
129
+ // The button path should appear once (the original import); Card auto-imported.
130
+ const buttonMatches = mdx.match(/@\/components\/ui\/button/g) || [];
131
+ expect(buttonMatches).toHaveLength(1);
132
+ expect(mdx).toContain("@/components/ui/card");
133
+ } finally {
134
+ cleanup();
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,212 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ resolveImports,
6
+ extractExistingImports,
7
+ extractImportedNames,
8
+ } from "./imports";
9
+ import Handlebars from "handlebars";
10
+ import { compile } from "@mdx-js/mdx";
11
+ import remarkFrontmatter from "remark-frontmatter";
12
+ import remarkMdxFrontmatter from "remark-mdx-frontmatter";
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const templatesDir = path.resolve(__dirname, "..", "templates");
16
+ const mdxTemplate = Handlebars.compile(
17
+ readFileSync(path.resolve(templatesDir, "contents-mdx.hbs"), "utf8"),
18
+ );
19
+ const entryTemplate = Handlebars.compile(
20
+ readFileSync(path.resolve(templatesDir, "entry.hbs"), "utf8"),
21
+ );
22
+
23
+ export type VirtualModule = { contents: string; loader: "js" | "jsx" };
24
+
25
+ type Wrapper = {
26
+ component: string;
27
+ import: string;
28
+ props?: Record<string, unknown>;
29
+ };
30
+
31
+ type PreprocessOptions = {
32
+ wrappers?: Wrapper[];
33
+ layouts?: Record<string, string[]>;
34
+ mdxComponents?: string;
35
+ };
36
+
37
+ export type PreprocessResult = {
38
+ // Virtual modules served to esbuild (no temp dir): sentinel id -> source.
39
+ modules: Map<string, VirtualModule>;
40
+ // Client esbuild entry points: slug -> sentinel id (mdx) or absolute path (pass-through).
41
+ entries: Record<string, string>;
42
+ // SSR esbuild entry points: slug -> sentinel id (mdx pages only).
43
+ serverEntries: Record<string, string>;
44
+ missing: ReturnType<typeof resolveImports>["missing"];
45
+ meta: Map<string, Record<string, string>>;
46
+ };
47
+
48
+ // Virtual-module sentinel ids (resolved/loaded by build.ts's esbuild plugin).
49
+ const clientId = (slug: string) => `n6k:client:${slug}`;
50
+ const appId = (slug: string) => `n6k:app:${slug}`;
51
+ const mdxId = (slug: string) => `n6k:mdx:${slug}`;
52
+
53
+ export function buildPropsString(props?: Record<string, unknown>): string {
54
+ if (!props) return "";
55
+ return Object.entries(props)
56
+ .map(([key, value]) => ` ${key}={ ${JSON.stringify(value)} }`)
57
+ .join("");
58
+ }
59
+
60
+ export function parseMdxFrontmatter(raw: string): {
61
+ meta: Record<string, string>;
62
+ body: string;
63
+ } {
64
+ const fenceRe = /^---\s*\n([\s\S]*?)---\s*\n([\s\S]*)$/;
65
+ const match = raw.match(fenceRe);
66
+ if (!match) return { meta: {}, body: raw };
67
+
68
+ const meta: Record<string, string> = {};
69
+ for (const line of match[1].split("\n")) {
70
+ const kvMatch = line.match(/^(\w+)\s*:\s*(.+)$/);
71
+ if (kvMatch) {
72
+ meta[kvMatch[1]] = kvMatch[2].trim();
73
+ }
74
+ }
75
+ return { meta, body: match[2] };
76
+ }
77
+
78
+ export async function preprocess(
79
+ entryPoints: Record<string, string>,
80
+ componentsDir: string,
81
+ { wrappers = [], layouts = {}, mdxComponents }: PreprocessOptions = {},
82
+ ): Promise<PreprocessResult> {
83
+ const modules = new Map<string, VirtualModule>();
84
+ const entries: Record<string, string> = {};
85
+ const serverEntries: Record<string, string> = {};
86
+ const meta = new Map<string, Record<string, string>>();
87
+ const missing: ReturnType<typeof resolveImports>["missing"] = [];
88
+
89
+ // Separate .mdx (needs transform) from ts/tsx/js/jsx (pass through).
90
+ const mdxSources = new Map<string, string>();
91
+ for (const [slug, sourcePath] of Object.entries(entryPoints)) {
92
+ const absPath = path.resolve(sourcePath);
93
+ if (absPath.endsWith(".mdx")) {
94
+ mdxSources.set(slug, readFileSync(absPath, "utf8"));
95
+ } else {
96
+ entries[slug] = absPath;
97
+ }
98
+ }
99
+
100
+ if (mdxSources.size === 0) {
101
+ return { modules, entries, serverEntries, missing, meta };
102
+ }
103
+
104
+ // Wrapper JSX (same for all pages)
105
+ const wrapperImports = wrappers.map((w) => w.import).join("\n");
106
+ const wrapperOpen = wrappers
107
+ .map((w) => `<${w.component}${buildPropsString(w.props)}>`)
108
+ .join("\n ");
109
+ const wrapperClose = wrappers
110
+ .toReversed()
111
+ .map((w) => `</${w.component}>`)
112
+ .join("\n ");
113
+
114
+ // Parse frontmatter + split out existing imports per page.
115
+ const mdxBodies = new Map<string, string>();
116
+ const mdxExistingImports = new Map<string, string[]>();
117
+ for (const [slug, raw] of mdxSources) {
118
+ const { meta: pageMeta, body: bodyWithoutFrontmatter } =
119
+ parseMdxFrontmatter(raw);
120
+ if (Object.keys(pageMeta).length > 0) {
121
+ meta.set(slug, pageMeta);
122
+ }
123
+ const { importLines, body } = extractExistingImports(
124
+ bodyWithoutFrontmatter,
125
+ );
126
+ mdxBodies.set(slug, body);
127
+ mdxExistingImports.set(slug, importLines);
128
+ }
129
+
130
+ const { resolved: mdxResolved, missing: mdxMissing } = resolveImports(
131
+ mdxBodies,
132
+ componentsDir,
133
+ );
134
+ missing.push(...mdxMissing);
135
+
136
+ for (const [slug] of mdxSources) {
137
+ const existingImports = mdxExistingImports.get(slug) || [];
138
+ const alreadyImported = extractImportedNames(existingImports);
139
+
140
+ // Filter auto-imports to skip already-imported names.
141
+ const autoImportBlock = mdxResolved.get(slug) || "";
142
+ const filteredAutoImports = autoImportBlock
143
+ .split("\n")
144
+ .filter((line) => {
145
+ if (!line.trim()) return false;
146
+ const namedMatch = line.match(/\{\s*([^}]+)\s*\}/);
147
+ if (!namedMatch) return true;
148
+ const names = namedMatch[1]
149
+ .split(",")
150
+ .map((n) => n.trim())
151
+ .filter((n) => !alreadyImported.has(n));
152
+ return names.length > 0;
153
+ })
154
+ .join("\n");
155
+
156
+ const processedMdx = [
157
+ ...existingImports,
158
+ filteredAutoImports,
159
+ "",
160
+ mdxBodies.get(slug),
161
+ ]
162
+ .filter((part) => part !== undefined)
163
+ .join("\n");
164
+
165
+ // Compile MDX → JS in memory (no temp file, no esbuild MDX plugin).
166
+ const compiled = await compile(processedMdx, {
167
+ remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
168
+ ...(mdxComponents ? { providerImportSource: mdxComponents } : {}),
169
+ });
170
+ modules.set(mdxId(slug), { contents: String(compiled), loader: "jsx" });
171
+
172
+ // Per-page layout chain (imported by absolute path so it resolves with no
173
+ // base dir).
174
+ const layoutChain = layouts[slug] || [];
175
+ const layoutImports = layoutChain
176
+ .map(
177
+ (absPath: string, i: number) =>
178
+ `import Layout${i} from ${JSON.stringify(absPath)};`,
179
+ )
180
+ .join("\n");
181
+ const layoutOpen = layoutChain
182
+ .map((_: string, i: number) => `<Layout${i}>`)
183
+ .join("\n ");
184
+ const layoutClose = layoutChain
185
+ .toReversed()
186
+ .map(
187
+ (_: string, i: number, arr: string[]) =>
188
+ `</Layout${arr.length - 1 - i}>`,
189
+ )
190
+ .join("\n ");
191
+
192
+ // SSR wrapper module: imports the compiled MDX + wraps it.
193
+ const appContents = mdxTemplate({
194
+ mdxPath: mdxId(slug),
195
+ wrapperImports,
196
+ wrapperOpen,
197
+ wrapperClose,
198
+ layoutImports,
199
+ layoutOpen,
200
+ layoutClose,
201
+ });
202
+ modules.set(appId(slug), { contents: appContents, loader: "jsx" });
203
+ serverEntries[slug] = appId(slug);
204
+
205
+ // Client hydration entry module.
206
+ const entryContents = entryTemplate({ appModule: appId(slug) });
207
+ modules.set(clientId(slug), { contents: entryContents, loader: "jsx" });
208
+ entries[slug] = clientId(slug);
209
+ }
210
+
211
+ return { modules, entries, serverEntries, missing, meta };
212
+ }