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.
- package/README.md +46 -0
- package/bin/create-eziwiki.mjs +83 -0
- package/lib/scaffold.mjs +230 -0
- package/lib/scaffold.test.mjs +270 -0
- package/package.json +38 -0
- package/template/README.md +39 -0
- package/template/app/[...slug]/page.tsx +162 -0
- package/template/app/error.tsx +77 -0
- package/template/app/global-error.tsx +76 -0
- package/template/app/globals.css +44 -0
- package/template/app/graph/page.tsx +62 -0
- package/template/app/layout.tsx +164 -0
- package/template/app/not-found.tsx +48 -0
- package/template/app/page.tsx +34 -0
- package/template/app/robots.ts +20 -0
- package/template/app/sitemap.ts +48 -0
- package/template/components/ThemeToggle.tsx +77 -0
- package/template/components/graph/GraphView.tsx +156 -0
- package/template/components/layout/Backlinks.tsx +42 -0
- package/template/components/layout/Breadcrumb.tsx +88 -0
- package/template/components/layout/MobileMenu.tsx +299 -0
- package/template/components/layout/NavigationButtons.tsx +87 -0
- package/template/components/layout/PageLayout.tsx +89 -0
- package/template/components/layout/Sidebar.tsx +376 -0
- package/template/components/layout/TabBar.tsx +312 -0
- package/template/components/layout/TabBarSkeleton.tsx +12 -0
- package/template/components/layout/TabInitializer.tsx +99 -0
- package/template/components/layout/TableOfContents.tsx +138 -0
- package/template/components/markdown/CodeCopy.tsx +65 -0
- package/template/components/markdown/MarkdownContent.tsx +38 -0
- package/template/components/markdown/PageTransition.tsx +56 -0
- package/template/components/providers/UrlMapProvider.tsx +68 -0
- package/template/components/search/SearchDialog.tsx +286 -0
- package/template/components/search/SearchTrigger.tsx +41 -0
- package/template/content/guides/_meta.json +4 -0
- package/template/content/guides/writing.md +82 -0
- package/template/content/intro.md +29 -0
- package/template/eslintignore +7 -0
- package/template/eslintrc.js +40 -0
- package/template/gitignore +40 -0
- package/template/lib/basePath.test.ts +120 -0
- package/template/lib/basePath.ts +108 -0
- package/template/lib/cache.ts +36 -0
- package/template/lib/content/registry.ts +311 -0
- package/template/lib/content/resolver.ts +109 -0
- package/template/lib/graph/build.ts +214 -0
- package/template/lib/graph/layout.test.ts +189 -0
- package/template/lib/graph/layout.ts +247 -0
- package/template/lib/markdown/languages.test.ts +85 -0
- package/template/lib/markdown/languages.ts +103 -0
- package/template/lib/markdown/rehype-plugins.ts +240 -0
- package/template/lib/markdown/remark-wikilink.ts +141 -0
- package/template/lib/markdown/render.ts +175 -0
- package/template/lib/markdown/wikilink.test.ts +91 -0
- package/template/lib/markdown/wikilink.ts +85 -0
- package/template/lib/navigation/auto.ts +227 -0
- package/template/lib/navigation/builder.test.ts +129 -0
- package/template/lib/navigation/builder.ts +122 -0
- package/template/lib/navigation/hash.ts +32 -0
- package/template/lib/navigation/url.test.ts +88 -0
- package/template/lib/navigation/url.ts +108 -0
- package/template/lib/navigation/urlMap.ts +81 -0
- package/template/lib/payload/schema.ts +81 -0
- package/template/lib/payload/types.ts +105 -0
- package/template/lib/payload/validator.ts +56 -0
- package/template/lib/search/build.ts +204 -0
- package/template/lib/search/client.ts +190 -0
- package/template/lib/search/tokenizer.test.ts +60 -0
- package/template/lib/search/tokenizer.ts +83 -0
- package/template/lib/search/types.ts +40 -0
- package/template/lib/site.ts +86 -0
- package/template/lib/store/searchStore.ts +26 -0
- package/template/lib/store/tabStore.ts +313 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.js +43 -0
- package/template/package-lock.json +9933 -0
- package/template/package.json +69 -0
- package/template/payload/config.ts +34 -0
- package/template/postcss.config.js +6 -0
- package/template/prettierignore +7 -0
- package/template/prettierrc +8 -0
- package/template/public/favicon.svg +10 -0
- package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
- package/template/public/images/.gitkeep +0 -0
- package/template/scripts/build-search-index.ts +36 -0
- package/template/scripts/check-links.ts +40 -0
- package/template/scripts/show-urls.ts +48 -0
- package/template/scripts/validate-payload.ts +30 -0
- package/template/styles/markdown.css +167 -0
- package/template/styles/theme.css +65 -0
- package/template/tailwind.config.ts +156 -0
- package/template/tsconfig.json +32 -0
- 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();
|
package/lib/scaffold.mjs
ADDED
|
@@ -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
|
+
}
|