create-eziwiki 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.
Files changed (98) hide show
  1. package/README.md +46 -0
  2. package/bin/create-eziwiki.mjs +83 -0
  3. package/lib/scaffold.mjs +230 -0
  4. package/lib/scaffold.test.mjs +270 -0
  5. package/package.json +38 -0
  6. package/template/README.md +39 -0
  7. package/template/app/[...slug]/page.tsx +162 -0
  8. package/template/app/error.tsx +77 -0
  9. package/template/app/global-error.tsx +76 -0
  10. package/template/app/globals.css +44 -0
  11. package/template/app/graph/page.tsx +62 -0
  12. package/template/app/layout.tsx +164 -0
  13. package/template/app/not-found.tsx +48 -0
  14. package/template/app/page.tsx +34 -0
  15. package/template/app/robots.ts +20 -0
  16. package/template/app/sitemap.ts +48 -0
  17. package/template/components/ThemeToggle.tsx +77 -0
  18. package/template/components/graph/GraphView.tsx +156 -0
  19. package/template/components/layout/Backlinks.tsx +42 -0
  20. package/template/components/layout/Breadcrumb.tsx +88 -0
  21. package/template/components/layout/MobileMenu.tsx +299 -0
  22. package/template/components/layout/NavigationButtons.tsx +87 -0
  23. package/template/components/layout/PageLayout.tsx +89 -0
  24. package/template/components/layout/Sidebar.tsx +376 -0
  25. package/template/components/layout/TabBar.tsx +312 -0
  26. package/template/components/layout/TabBarSkeleton.tsx +12 -0
  27. package/template/components/layout/TabInitializer.tsx +99 -0
  28. package/template/components/layout/TableOfContents.tsx +138 -0
  29. package/template/components/markdown/CodeCopy.tsx +65 -0
  30. package/template/components/markdown/MarkdownContent.tsx +38 -0
  31. package/template/components/markdown/PageTransition.tsx +56 -0
  32. package/template/components/providers/UrlMapProvider.tsx +68 -0
  33. package/template/components/search/SearchDialog.tsx +286 -0
  34. package/template/components/search/SearchTrigger.tsx +41 -0
  35. package/template/content/guides/_meta.json +4 -0
  36. package/template/content/guides/writing.md +82 -0
  37. package/template/content/intro.md +29 -0
  38. package/template/eslintignore +7 -0
  39. package/template/eslintrc.js +40 -0
  40. package/template/gitignore +40 -0
  41. package/template/lib/basePath.test.ts +120 -0
  42. package/template/lib/basePath.ts +108 -0
  43. package/template/lib/cache.ts +36 -0
  44. package/template/lib/content/registry.ts +311 -0
  45. package/template/lib/content/resolver.ts +109 -0
  46. package/template/lib/graph/build.ts +214 -0
  47. package/template/lib/graph/layout.test.ts +189 -0
  48. package/template/lib/graph/layout.ts +247 -0
  49. package/template/lib/markdown/languages.test.ts +85 -0
  50. package/template/lib/markdown/languages.ts +103 -0
  51. package/template/lib/markdown/rehype-plugins.ts +240 -0
  52. package/template/lib/markdown/remark-wikilink.ts +141 -0
  53. package/template/lib/markdown/render.ts +175 -0
  54. package/template/lib/markdown/wikilink.test.ts +91 -0
  55. package/template/lib/markdown/wikilink.ts +85 -0
  56. package/template/lib/navigation/auto.ts +227 -0
  57. package/template/lib/navigation/builder.test.ts +129 -0
  58. package/template/lib/navigation/builder.ts +122 -0
  59. package/template/lib/navigation/hash.ts +32 -0
  60. package/template/lib/navigation/url.test.ts +88 -0
  61. package/template/lib/navigation/url.ts +108 -0
  62. package/template/lib/navigation/urlMap.ts +81 -0
  63. package/template/lib/payload/schema.ts +81 -0
  64. package/template/lib/payload/types.ts +105 -0
  65. package/template/lib/payload/validator.ts +56 -0
  66. package/template/lib/search/build.ts +204 -0
  67. package/template/lib/search/client.ts +190 -0
  68. package/template/lib/search/tokenizer.test.ts +60 -0
  69. package/template/lib/search/tokenizer.ts +83 -0
  70. package/template/lib/search/types.ts +40 -0
  71. package/template/lib/site.ts +86 -0
  72. package/template/lib/store/searchStore.ts +26 -0
  73. package/template/lib/store/tabStore.ts +313 -0
  74. package/template/next-env.d.ts +5 -0
  75. package/template/next.config.js +43 -0
  76. package/template/package-lock.json +9933 -0
  77. package/template/package.json +69 -0
  78. package/template/payload/config.ts +34 -0
  79. package/template/postcss.config.js +6 -0
  80. package/template/prettierignore +7 -0
  81. package/template/prettierrc +8 -0
  82. package/template/public/favicon.svg +10 -0
  83. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  84. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  85. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  86. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  87. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  88. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
  89. package/template/public/images/.gitkeep +0 -0
  90. package/template/scripts/build-search-index.ts +36 -0
  91. package/template/scripts/check-links.ts +40 -0
  92. package/template/scripts/show-urls.ts +48 -0
  93. package/template/scripts/validate-payload.ts +30 -0
  94. package/template/styles/markdown.css +167 -0
  95. package/template/styles/theme.css +65 -0
  96. package/template/tailwind.config.ts +156 -0
  97. package/template/tsconfig.json +32 -0
  98. package/template/vitest.config.ts +30 -0
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # create-eziwiki
2
+
3
+ Scaffold a new [eziwiki](https://github.com/i3months/eziwiki) documentation site.
4
+
5
+ ```bash
6
+ npx create-eziwiki my-docs
7
+ cd my-docs
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ ## What you get
13
+
14
+ A complete, static-exportable wiki:
15
+
16
+ - **Pages from files** — every Markdown file under `content/` is published, no registration step
17
+ - **Search** — full-text over titles, headings, and body, with a ⌘K palette; runs entirely in the browser
18
+ - **Contents rail** with scroll tracking, generated at build time
19
+ - **Wiki links** — `[[page]]` resolves by path, file name, or title
20
+ - **Backlinks** on every page, and a **graph view** of how pages connect
21
+ - **Build-time rendering** — Markdown is compiled and syntax-highlighted during the build, so no parser ships to the browser
22
+ - Dark mode, maths, GFM, SEO metadata, sitemap
23
+
24
+ ## Layout
25
+
26
+ ```
27
+ my-docs/
28
+ ├── content/ # Your Markdown. Folders become sidebar sections.
29
+ │ └── _meta.json # Optional per-folder name, order, colour
30
+ ├── payload/config.ts # Title, theme, URL strategy, optional navigation
31
+ ├── public/ # Static assets
32
+ └── app/ lib/ components/ # The engine — edit only if you want to
33
+ ```
34
+
35
+ ## Development
36
+
37
+ The template is generated from the eziwiki repository rather than kept as a
38
+ separate copy, so it never drifts from the tested source:
39
+
40
+ ```bash
41
+ npm run build:template # in the eziwiki repo, regenerates ./template
42
+ ```
43
+
44
+ ## Licence
45
+
46
+ MIT
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { scaffold, validateProjectName } from '../lib/scaffold.mjs';
6
+
7
+ /**
8
+ * `create-eziwiki` command line entry point.
9
+ *
10
+ * Deliberately non-interactive: it takes a name, writes a project, and prints
11
+ * the next commands. Anything it could prompt for is a value the user can
12
+ * change in `payload/config.ts` afterwards, and a prompt would only get in the
13
+ * way of scripted use.
14
+ */
15
+
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const TEMPLATE_DIR = path.resolve(HERE, '..', 'template');
18
+
19
+ const HELP = `
20
+ create-eziwiki — scaffold a new eziwiki documentation site
21
+
22
+ Usage
23
+ $ npx create-eziwiki <project-name>
24
+
25
+ Options
26
+ -h, --help Show this message
27
+ -v, --version Show the version
28
+
29
+ Example
30
+ $ npx create-eziwiki my-docs
31
+ $ cd my-docs
32
+ $ npm install
33
+ $ npm run dev
34
+ `;
35
+
36
+ function main() {
37
+ const args = process.argv.slice(2);
38
+
39
+ if (args.includes('-h') || args.includes('--help') || args.length === 0) {
40
+ console.log(HELP);
41
+ process.exit(args.length === 0 ? 1 : 0);
42
+ }
43
+
44
+ if (args.includes('-v') || args.includes('--version')) {
45
+ console.log(process.env.npm_package_version ?? 'unknown');
46
+ process.exit(0);
47
+ }
48
+
49
+ const projectName = args.find((arg) => !arg.startsWith('-'));
50
+ const check = validateProjectName(projectName);
51
+
52
+ if (!check.valid) {
53
+ console.error(`\n ✖ ${check.problem}\n`);
54
+ process.exit(1);
55
+ }
56
+
57
+ const targetDir = path.resolve(process.cwd(), projectName);
58
+
59
+ let result;
60
+ try {
61
+ result = scaffold({ templateDir: TEMPLATE_DIR, targetDir, projectName });
62
+ } catch (error) {
63
+ console.error(`\n ✖ ${error instanceof Error ? error.message : String(error)}\n`);
64
+ process.exit(1);
65
+ }
66
+
67
+ const relative = path.relative(process.cwd(), targetDir) || '.';
68
+
69
+ console.log(`
70
+ ✔ Created ${projectName} (${result.files} files)
71
+
72
+ Next steps:
73
+
74
+ cd ${relative}
75
+ npm install
76
+ npm run dev
77
+
78
+ Then edit content/ to write pages, and payload/config.ts for the title,
79
+ navigation, and theme.
80
+ `);
81
+ }
82
+
83
+ main();
@@ -0,0 +1,230 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Scaffolding logic for `create-eziwiki`.
6
+ *
7
+ * Kept separate from the CLI entry point so the decisions — what a valid
8
+ * project name is, how the template's package.json is rewritten, which files
9
+ * get renamed on the way out — can be tested without spawning a process or
10
+ * touching a real project directory.
11
+ */
12
+
13
+ /**
14
+ * Files that npm refuses to publish under their real name, and what they must
15
+ * be restored to on disk.
16
+ *
17
+ * npm silently excludes `.gitignore` from a package tarball and renames
18
+ * `.npmrc`, so the template ships them under safe names.
19
+ */
20
+ export const RENAME_ON_COPY = {
21
+ gitignore: '.gitignore',
22
+ npmrc: '.npmrc',
23
+ 'eslintrc.js': '.eslintrc.js',
24
+ prettierrc: '.prettierrc',
25
+ prettierignore: '.prettierignore',
26
+ eslintignore: '.eslintignore',
27
+ };
28
+
29
+ /** Directory names never copied out of the template. */
30
+ export const SKIP_DIRS = new Set(['node_modules', '.next', 'out', '.git']);
31
+
32
+ /**
33
+ * Validates a project directory name.
34
+ *
35
+ * The name becomes both a directory and the `name` field of a package.json, so
36
+ * it has to satisfy npm's rules; rejecting it up front is friendlier than
37
+ * letting `npm install` fail later with a less obvious message.
38
+ *
39
+ * @param {string} name - Proposed project name
40
+ * @returns {{ valid: boolean, problem?: string }} Validation outcome
41
+ *
42
+ * @example
43
+ * validateProjectName('my-wiki'); // { valid: true }
44
+ * validateProjectName('My Wiki'); // { valid: false, problem: '...' }
45
+ */
46
+ export function validateProjectName(name) {
47
+ if (!name || !name.trim()) {
48
+ return { valid: false, problem: 'Project name is required.' };
49
+ }
50
+
51
+ if (name.length > 214) {
52
+ return { valid: false, problem: 'Project name must be 214 characters or fewer.' };
53
+ }
54
+
55
+ if (name.startsWith('.') || name.startsWith('_')) {
56
+ return { valid: false, problem: 'Project name cannot start with "." or "_".' };
57
+ }
58
+
59
+ if (name !== name.toLowerCase()) {
60
+ return { valid: false, problem: 'Project name must be lowercase.' };
61
+ }
62
+
63
+ if (!/^[a-z0-9-~][a-z0-9._~-]*$/.test(name)) {
64
+ return {
65
+ valid: false,
66
+ problem: 'Project name may only contain lowercase letters, digits, "-", "_", "." and "~".',
67
+ };
68
+ }
69
+
70
+ return { valid: true };
71
+ }
72
+
73
+ /**
74
+ * Rewrites the template's package.json for a freshly created project.
75
+ *
76
+ * The new project is the user's own, so anything identifying it as the eziwiki
77
+ * repository — the name, the repository link, the published-package fields — is
78
+ * replaced or dropped rather than inherited.
79
+ *
80
+ * @param {object} pkg - Parsed template package.json
81
+ * @param {string} projectName - Name for the new project
82
+ * @returns {object} The rewritten manifest
83
+ */
84
+ export function buildProjectPackageJson(pkg, projectName) {
85
+ const next = {
86
+ ...pkg,
87
+ name: projectName,
88
+ version: '0.1.0',
89
+ private: true,
90
+ description: `Documentation site built with eziwiki`,
91
+ };
92
+
93
+ // Fields that only make sense for the source repository.
94
+ delete next.repository;
95
+ delete next.homepage;
96
+ delete next.bugs;
97
+ delete next.bin;
98
+ delete next.files;
99
+ delete next.publishConfig;
100
+ delete next.workspaces;
101
+
102
+ return next;
103
+ }
104
+
105
+ /**
106
+ * Whether a directory can be used as the target for a new project.
107
+ *
108
+ * An existing but empty directory is fine — people often `mkdir` first — while
109
+ * one with files in it is refused, because overwriting someone's work is not
110
+ * something a scaffolder should decide on their behalf.
111
+ *
112
+ * @param {string} target - Absolute path to the intended project directory
113
+ * @returns {{ ok: boolean, problem?: string }} Whether scaffolding may proceed
114
+ */
115
+ export function checkTarget(target) {
116
+ if (!fs.existsSync(target)) return { ok: true };
117
+
118
+ const stat = fs.statSync(target);
119
+ if (!stat.isDirectory()) {
120
+ return { ok: false, problem: `${target} exists and is not a directory.` };
121
+ }
122
+
123
+ const entries = fs.readdirSync(target).filter((entry) => entry !== '.git');
124
+ if (entries.length > 0) {
125
+ return { ok: false, problem: `${target} is not empty.` };
126
+ }
127
+
128
+ return { ok: true };
129
+ }
130
+
131
+ /**
132
+ * Maps a template file name to the name it takes in the created project.
133
+ *
134
+ * @param {string} name - File name inside the template
135
+ * @returns {string} Name to write
136
+ */
137
+ export function targetFileName(name) {
138
+ return RENAME_ON_COPY[name] ?? name;
139
+ }
140
+
141
+ /**
142
+ * Recursively copies the template into a target directory.
143
+ *
144
+ * @param {string} from - Template directory
145
+ * @param {string} to - Destination directory
146
+ * @returns {number} Number of files written
147
+ */
148
+ export function copyTemplate(from, to) {
149
+ let written = 0;
150
+
151
+ fs.mkdirSync(to, { recursive: true });
152
+
153
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
154
+ if (SKIP_DIRS.has(entry.name)) continue;
155
+
156
+ const source = path.join(from, entry.name);
157
+ const destination = path.join(to, targetFileName(entry.name));
158
+
159
+ if (entry.isDirectory()) {
160
+ written += copyTemplate(source, destination);
161
+ } else {
162
+ fs.copyFileSync(source, destination);
163
+ written += 1;
164
+ }
165
+ }
166
+
167
+ return written;
168
+ }
169
+
170
+ /**
171
+ * Creates a new eziwiki project from the template.
172
+ *
173
+ * @param {object} options
174
+ * @param {string} options.templateDir - Directory holding the template
175
+ * @param {string} options.targetDir - Directory to create the project in
176
+ * @param {string} options.projectName - Name for the project
177
+ * @returns {{ files: number }} Summary of what was written
178
+ * @throws {Error} If the target is unusable or the template is missing
179
+ */
180
+ export function scaffold({ templateDir, targetDir, projectName }) {
181
+ if (!fs.existsSync(templateDir)) {
182
+ throw new Error(
183
+ `Template not found at ${templateDir}. ` +
184
+ 'If you are running from a clone, build it first with `npm run build:template`.',
185
+ );
186
+ }
187
+
188
+ const target = checkTarget(targetDir);
189
+ if (!target.ok) throw new Error(target.problem);
190
+
191
+ const files = copyTemplate(templateDir, targetDir);
192
+
193
+ const manifestPath = path.join(targetDir, 'package.json');
194
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
195
+
196
+ fs.writeFileSync(
197
+ manifestPath,
198
+ `${JSON.stringify(buildProjectPackageJson(manifest, projectName), null, 2)}\n`,
199
+ 'utf-8',
200
+ );
201
+
202
+ renameLockfileRoot(targetDir, projectName);
203
+
204
+ return { files };
205
+ }
206
+
207
+ /**
208
+ * Points the shipped lockfile at the new project's name.
209
+ *
210
+ * npm would otherwise notice the mismatch on first install and rewrite the
211
+ * lockfile, producing a spurious diff in the user's very first commit.
212
+ *
213
+ * @param {string} targetDir - Project directory
214
+ * @param {string} projectName - Name the project was created with
215
+ */
216
+ export function renameLockfileRoot(targetDir, projectName) {
217
+ const lockPath = path.join(targetDir, 'package-lock.json');
218
+ if (!fs.existsSync(lockPath)) return;
219
+
220
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
221
+
222
+ lock.name = projectName;
223
+ lock.version = '0.1.0';
224
+ if (lock.packages?.['']) {
225
+ lock.packages[''].name = projectName;
226
+ lock.packages[''].version = '0.1.0';
227
+ }
228
+
229
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`, 'utf-8');
230
+ }
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import {
6
+ buildProjectPackageJson,
7
+ checkTarget,
8
+ copyTemplate,
9
+ scaffold,
10
+ targetFileName,
11
+ validateProjectName,
12
+ } from './scaffold.mjs';
13
+
14
+ let workdir;
15
+
16
+ beforeEach(() => {
17
+ workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'eziwiki-scaffold-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(workdir, { recursive: true, force: true });
22
+ });
23
+
24
+ /** Writes a minimal fake template into a directory. */
25
+ function makeTemplate(dir) {
26
+ fs.mkdirSync(path.join(dir, 'content'), { recursive: true });
27
+ fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true });
28
+
29
+ fs.writeFileSync(
30
+ path.join(dir, 'package.json'),
31
+ JSON.stringify({
32
+ name: 'my-wiki',
33
+ version: '9.9.9',
34
+ description: 'template',
35
+ repository: 'github:someone/eziwiki',
36
+ bin: { thing: './x.js' },
37
+ files: ['bin'],
38
+ dependencies: { next: '^14.0.0' },
39
+ }),
40
+ );
41
+ fs.writeFileSync(path.join(dir, 'gitignore'), 'node_modules\n');
42
+ fs.writeFileSync(path.join(dir, 'content', 'intro.md'), '# Hi\n');
43
+ fs.writeFileSync(path.join(dir, 'node_modules', 'junk.js'), 'nope');
44
+
45
+ return dir;
46
+ }
47
+
48
+ describe('validateProjectName', () => {
49
+ it('accepts ordinary names', () => {
50
+ for (const name of ['my-wiki', 'docs', 'a1', 'my.wiki', 'my_wiki']) {
51
+ expect(validateProjectName(name).valid, name).toBe(true);
52
+ }
53
+ });
54
+
55
+ it('rejects an empty name', () => {
56
+ expect(validateProjectName('').valid).toBe(false);
57
+ expect(validateProjectName(' ').valid).toBe(false);
58
+ expect(validateProjectName(undefined).valid).toBe(false);
59
+ });
60
+
61
+ it('rejects uppercase, which npm does not allow', () => {
62
+ expect(validateProjectName('MyWiki').valid).toBe(false);
63
+ });
64
+
65
+ it('rejects spaces and other illegal characters', () => {
66
+ expect(validateProjectName('my wiki').valid).toBe(false);
67
+ expect(validateProjectName('my/wiki').valid).toBe(false);
68
+ });
69
+
70
+ it('rejects names starting with a dot or underscore', () => {
71
+ expect(validateProjectName('.hidden').valid).toBe(false);
72
+ expect(validateProjectName('_private').valid).toBe(false);
73
+ });
74
+
75
+ it('rejects names longer than npm permits', () => {
76
+ expect(validateProjectName('a'.repeat(215)).valid).toBe(false);
77
+ });
78
+
79
+ it('explains why it refused', () => {
80
+ expect(validateProjectName('My Wiki').problem).toBeTruthy();
81
+ });
82
+ });
83
+
84
+ describe('buildProjectPackageJson', () => {
85
+ const template = {
86
+ name: 'my-wiki',
87
+ version: '9.9.9',
88
+ repository: 'github:someone/eziwiki',
89
+ homepage: 'https://example.com',
90
+ bugs: 'https://example.com/issues',
91
+ bin: { x: './x.js' },
92
+ files: ['bin'],
93
+ publishConfig: { access: 'public' },
94
+ scripts: { build: 'next build' },
95
+ dependencies: { next: '^14.0.0' },
96
+ };
97
+
98
+ it('uses the requested project name and a fresh version', () => {
99
+ const pkg = buildProjectPackageJson(template, 'my-docs');
100
+
101
+ expect(pkg.name).toBe('my-docs');
102
+ expect(pkg.version).toBe('0.1.0');
103
+ });
104
+
105
+ it('marks the project private so it is never published by accident', () => {
106
+ expect(buildProjectPackageJson(template, 'my-docs').private).toBe(true);
107
+ });
108
+
109
+ it('drops fields belonging to the source repository', () => {
110
+ const pkg = buildProjectPackageJson(template, 'my-docs');
111
+
112
+ for (const field of ['repository', 'homepage', 'bugs', 'bin', 'files', 'publishConfig']) {
113
+ expect(pkg, field).not.toHaveProperty(field);
114
+ }
115
+ });
116
+
117
+ it('keeps scripts and dependencies', () => {
118
+ const pkg = buildProjectPackageJson(template, 'my-docs');
119
+
120
+ expect(pkg.scripts).toEqual({ build: 'next build' });
121
+ expect(pkg.dependencies).toEqual({ next: '^14.0.0' });
122
+ });
123
+
124
+ it('does not mutate the input', () => {
125
+ const before = JSON.stringify(template);
126
+ buildProjectPackageJson(template, 'my-docs');
127
+
128
+ expect(JSON.stringify(template)).toBe(before);
129
+ });
130
+ });
131
+
132
+ describe('checkTarget', () => {
133
+ it('accepts a path that does not exist', () => {
134
+ expect(checkTarget(path.join(workdir, 'new')).ok).toBe(true);
135
+ });
136
+
137
+ it('accepts an existing empty directory', () => {
138
+ const dir = path.join(workdir, 'empty');
139
+ fs.mkdirSync(dir);
140
+
141
+ expect(checkTarget(dir).ok).toBe(true);
142
+ });
143
+
144
+ it('accepts a directory containing only a git repo', () => {
145
+ const dir = path.join(workdir, 'gitonly');
146
+ fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
147
+
148
+ expect(checkTarget(dir).ok).toBe(true);
149
+ });
150
+
151
+ it('refuses a directory with files in it', () => {
152
+ const dir = path.join(workdir, 'busy');
153
+ fs.mkdirSync(dir);
154
+ fs.writeFileSync(path.join(dir, 'keep.txt'), 'mine');
155
+
156
+ const result = checkTarget(dir);
157
+ expect(result.ok).toBe(false);
158
+ expect(result.problem).toContain('not empty');
159
+ });
160
+
161
+ it('refuses a path that is a file', () => {
162
+ const file = path.join(workdir, 'file.txt');
163
+ fs.writeFileSync(file, 'x');
164
+
165
+ expect(checkTarget(file).ok).toBe(false);
166
+ });
167
+ });
168
+
169
+ describe('targetFileName', () => {
170
+ it('restores names npm strips from a tarball', () => {
171
+ expect(targetFileName('gitignore')).toBe('.gitignore');
172
+ expect(targetFileName('npmrc')).toBe('.npmrc');
173
+ expect(targetFileName('prettierrc')).toBe('.prettierrc');
174
+ });
175
+
176
+ it('leaves ordinary names alone', () => {
177
+ expect(targetFileName('package.json')).toBe('package.json');
178
+ });
179
+ });
180
+
181
+ describe('copyTemplate', () => {
182
+ it('copies files and directories', () => {
183
+ const template = makeTemplate(path.join(workdir, 'template'));
184
+ const target = path.join(workdir, 'out');
185
+
186
+ copyTemplate(template, target);
187
+
188
+ expect(fs.existsSync(path.join(target, 'package.json'))).toBe(true);
189
+ expect(fs.existsSync(path.join(target, 'content', 'intro.md'))).toBe(true);
190
+ });
191
+
192
+ it('renames dotfiles on the way out', () => {
193
+ const template = makeTemplate(path.join(workdir, 'template'));
194
+ const target = path.join(workdir, 'out');
195
+
196
+ copyTemplate(template, target);
197
+
198
+ expect(fs.existsSync(path.join(target, '.gitignore'))).toBe(true);
199
+ expect(fs.existsSync(path.join(target, 'gitignore'))).toBe(false);
200
+ });
201
+
202
+ it('never copies node_modules', () => {
203
+ const template = makeTemplate(path.join(workdir, 'template'));
204
+ const target = path.join(workdir, 'out');
205
+
206
+ copyTemplate(template, target);
207
+
208
+ expect(fs.existsSync(path.join(target, 'node_modules'))).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('scaffold', () => {
213
+ it('creates a working project directory', () => {
214
+ const templateDir = makeTemplate(path.join(workdir, 'template'));
215
+ const targetDir = path.join(workdir, 'my-docs');
216
+
217
+ const result = scaffold({ templateDir, targetDir, projectName: 'my-docs' });
218
+
219
+ expect(result.files).toBeGreaterThan(0);
220
+ expect(fs.existsSync(path.join(targetDir, 'content', 'intro.md'))).toBe(true);
221
+ });
222
+
223
+ it('rewrites the manifest for the new project', () => {
224
+ const templateDir = makeTemplate(path.join(workdir, 'template'));
225
+ const targetDir = path.join(workdir, 'my-docs');
226
+
227
+ scaffold({ templateDir, targetDir, projectName: 'my-docs' });
228
+
229
+ const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8'));
230
+ expect(pkg.name).toBe('my-docs');
231
+ expect(pkg.private).toBe(true);
232
+ expect(pkg).not.toHaveProperty('repository');
233
+ });
234
+
235
+ it('points a shipped lockfile at the new name', () => {
236
+ const templateDir = makeTemplate(path.join(workdir, 'template'));
237
+ fs.writeFileSync(
238
+ path.join(templateDir, 'package-lock.json'),
239
+ JSON.stringify({ name: 'my-wiki', version: '9.9.9', packages: { '': { name: 'my-wiki' } } }),
240
+ );
241
+
242
+ const targetDir = path.join(workdir, 'my-docs');
243
+ scaffold({ templateDir, targetDir, projectName: 'my-docs' });
244
+
245
+ const lock = JSON.parse(fs.readFileSync(path.join(targetDir, 'package-lock.json'), 'utf-8'));
246
+ expect(lock.name).toBe('my-docs');
247
+ expect(lock.packages[''].name).toBe('my-docs');
248
+ });
249
+
250
+ it('refuses to overwrite a non-empty directory', () => {
251
+ const templateDir = makeTemplate(path.join(workdir, 'template'));
252
+ const targetDir = path.join(workdir, 'busy');
253
+
254
+ fs.mkdirSync(targetDir);
255
+ fs.writeFileSync(path.join(targetDir, 'important.txt'), 'do not lose me');
256
+
257
+ expect(() => scaffold({ templateDir, targetDir, projectName: 'busy' })).toThrow(/not empty/);
258
+ expect(fs.readFileSync(path.join(targetDir, 'important.txt'), 'utf-8')).toBe('do not lose me');
259
+ });
260
+
261
+ it('explains what to do when the template is missing', () => {
262
+ expect(() =>
263
+ scaffold({
264
+ templateDir: path.join(workdir, 'nope'),
265
+ targetDir: path.join(workdir, 'out'),
266
+ projectName: 'out',
267
+ }),
268
+ ).toThrow(/build:template/);
269
+ });
270
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "create-eziwiki",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a new eziwiki documentation site",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-eziwiki": "./bin/create-eziwiki.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "vitest --run lib",
11
+ "//prepack": "The template is generated from the repository source rather than committed, so it must be rebuilt before packing. npm runs prepack for both `npm pack` and `npm publish`, which is what stops a stale engine being shipped to users.",
12
+ "prepack": "cd ../.. && npm run build:template"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "lib",
17
+ "template",
18
+ "README.md"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/i3months/eziwiki.git",
23
+ "directory": "packages/create-eziwiki"
24
+ },
25
+ "homepage": "https://github.com/i3months/eziwiki#readme",
26
+ "bugs": "https://github.com/i3months/eziwiki/issues",
27
+ "keywords": [
28
+ "wiki",
29
+ "documentation",
30
+ "static-site-generator",
31
+ "markdown",
32
+ "nextjs"
33
+ ],
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }