simple-site-generator 0.1.2 → 0.2.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 (57) hide show
  1. package/dist/cli.js +165 -2
  2. package/dist/commands/add-page.js +50 -0
  3. package/dist/commands/build.js +20 -0
  4. package/dist/commands/check.js +53 -0
  5. package/dist/commands/init.js +229 -0
  6. package/dist/commands/verify.js +150 -0
  7. package/dist/pages/page-schema.js +161 -0
  8. package/dist/pages/render-page.js +46 -0
  9. package/dist/paths.js +27 -0
  10. package/dist/types/cli.d.ts +1 -0
  11. package/dist/types/commands/add-page.d.ts +26 -0
  12. package/dist/types/commands/build.d.ts +18 -0
  13. package/dist/types/commands/check.d.ts +20 -0
  14. package/dist/types/commands/init.d.ts +40 -0
  15. package/dist/types/commands/verify.d.ts +47 -0
  16. package/dist/types/pages/page-schema.d.ts +81 -0
  17. package/dist/types/pages/render-page.d.ts +26 -0
  18. package/dist/types/paths.d.ts +16 -0
  19. package/dist/types/verify/budgets.d.ts +114 -0
  20. package/dist/types/verify/class-coverage.d.ts +65 -0
  21. package/dist/types/verify/ignore.d.ts +8 -0
  22. package/dist/types/verify/lighthouse.d.ts +27 -0
  23. package/dist/types/verify/seo-surface.d.ts +23 -0
  24. package/dist/types/verify/serve.d.ts +6 -0
  25. package/dist/verify/budgets.js +180 -0
  26. package/dist/verify/class-coverage.js +114 -0
  27. package/dist/verify/ignore.js +11 -0
  28. package/dist/verify/lighthouse.js +98 -0
  29. package/dist/verify/seo-surface.js +97 -0
  30. package/dist/verify/serve.js +123 -0
  31. package/package.json +16 -10
  32. package/templates/app/.github/workflows/deploy-rsync.yaml.hbs +58 -0
  33. package/templates/app/.oxfmtrc.json +32 -0
  34. package/templates/app/.oxlintrc.json +10 -0
  35. package/templates/app/.vscode/extensions.json +3 -0
  36. package/templates/app/build/Caddyfile.hbs +24 -0
  37. package/templates/app/build/Caddyfile.preview +34 -0
  38. package/templates/app/index.html +35 -0
  39. package/templates/app/logo.svg +143 -0
  40. package/templates/app/pnpm-workspace.yaml +10 -0
  41. package/templates/app/public/favicon-128.png +0 -0
  42. package/templates/app/public/favicon-48.png +0 -0
  43. package/templates/app/public/favicon-96.png +0 -0
  44. package/templates/app/public/favicon.ico +0 -0
  45. package/templates/app/public/images/landing/landing.png +0 -0
  46. package/templates/app/public/robots.txt +5 -0
  47. package/templates/app/src/App.vue +22 -0
  48. package/templates/app/src/pages/Contact.md +25 -0
  49. package/templates/app/src/pages/Error404Page.vue +31 -0
  50. package/templates/app/src/pages/GettingStarted/GettingStarted.md +52 -0
  51. package/templates/app/src/pages/Index.md.hbs +46 -0
  52. package/templates/app/src/router/index.ts +21 -0
  53. package/templates/app/src/router/routes.ts +218 -0
  54. package/templates/app/src/setup.ts +8 -0
  55. package/templates/app/src/shims-virtual.d.ts +67 -0
  56. package/templates/app/src/shims-vue.d.ts +6 -0
  57. package/templates/app/vitrify.config.ts.hbs +249 -0
package/dist/cli.js CHANGED
@@ -1,3 +1,166 @@
1
1
  #!/usr/bin/env node
2
- "use strict";
3
- console.log('Placeholder for future CLI');
2
+ import { basename, resolve } from 'node:path';
3
+ import parseArgs from 'minimist';
4
+ import { addPage } from './commands/add-page.js';
5
+ import { build } from './commands/build.js';
6
+ import { check } from './commands/check.js';
7
+ import { TEMPLATES, confirmOverwrite, init } from './commands/init.js';
8
+ import { verify } from './commands/verify.js';
9
+ import { DEFAULT_BUDGETS } from './verify/budgets.js';
10
+ /**
11
+ * `ssg` — the command surface the agent instructions point at.
12
+ *
13
+ * Every command prints a machine-readable result on failure and exits non-zero,
14
+ * so an agent (or CI) can act on the outcome without parsing prose.
15
+ */
16
+ const USAGE = `ssg — simple site generator
17
+
18
+ Usage:
19
+ ssg init [directory] Scaffold a new site
20
+ ssg add-page <id> Create a page with valid frontmatter
21
+ ssg check Validate every page's frontmatter
22
+ ssg build Build the static site (vitrify build -m ssg)
23
+ ssg verify Verify the built site (SEO surface, budgets, Lighthouse)
24
+
25
+ Options:
26
+ --template <name> ${TEMPLATES.join(' | ')} (default: docs)
27
+ --site-name <name> Displayed site name
28
+ --url <origin> Absolute site origin, e.g. https://example.com
29
+ --title <title> add-page: page title
30
+ --description <text> add-page: meta description
31
+ --tags <a,b> add-page: comma-separated tags (${['menu', 'footer', 'contact'].join(', ')})
32
+ --page <component> add-page: template component, e.g. Landing
33
+ --yes Never prompt; use defaults
34
+ --no-lighthouse verify: skip Lighthouse (deterministic checks only)
35
+ --ignore <glob> verify: skip HTML files matching a glob (repeatable)
36
+ --js-budget <kb> verify: JavaScript budget in KB (default 250)
37
+ --css-budget <kb> verify: CSS budget in KB (default 60)
38
+ -h, --help Show this message
39
+ `;
40
+ async function main(argv) {
41
+ const [command, positional] = argv;
42
+ if (!command || command === '--help' || command === '-h') {
43
+ process.stdout.write(USAGE);
44
+ return command ? 0 : 1;
45
+ }
46
+ const args = parseArgs(argv.slice(1), {
47
+ string: [
48
+ 'template',
49
+ 'site-name',
50
+ 'url',
51
+ 'title',
52
+ 'description',
53
+ 'tags',
54
+ 'page',
55
+ 'ignore'
56
+ ],
57
+ boolean: ['yes', 'lighthouse', 'help'],
58
+ default: { lighthouse: true, ignore: [] },
59
+ alias: { h: 'help' }
60
+ });
61
+ switch (command) {
62
+ case 'init': {
63
+ const directory = resolve(positional ?? args._[0] ?? '.');
64
+ if (!(await confirmOverwrite(directory))) {
65
+ process.stdout.write('Aborted.\n');
66
+ return 1;
67
+ }
68
+ const result = await init({
69
+ directory,
70
+ template: args.template,
71
+ name: args['site-name'],
72
+ productName: args['site-name'],
73
+ url: args.url,
74
+ yes: args.yes
75
+ });
76
+ process.stdout.write(`Created ${result.template} site in ${result.directory}\n` +
77
+ `Next: cd ${basename(result.directory)} && pnpm install && pnpm check\n`);
78
+ return 0;
79
+ }
80
+ case 'add-page': {
81
+ const id = positional;
82
+ if (!id) {
83
+ process.stderr.write('add-page requires a page id, e.g. `ssg add-page about`\n');
84
+ return 1;
85
+ }
86
+ const tags = args.tags
87
+ ? args.tags.split(',').map((tag) => tag.trim())
88
+ : undefined;
89
+ const result = await addPage({
90
+ id,
91
+ title: args.title,
92
+ description: args.description,
93
+ tags,
94
+ page: args.page
95
+ });
96
+ process.stdout.write(`Created ${result.file}\n`);
97
+ return 0;
98
+ }
99
+ case 'check': {
100
+ const result = await check();
101
+ if (result.pages === 0) {
102
+ process.stdout.write('No pages found in src/pages — add one with `ssg add-page home`.\n');
103
+ return 0;
104
+ }
105
+ if (result.ok) {
106
+ process.stdout.write(`Checked ${result.pages} page(s): OK\n`);
107
+ return 0;
108
+ }
109
+ process.stderr.write(`Checked ${result.pages} page(s): ${result.issues.length} issue(s)\n`);
110
+ for (const issue of result.issues) {
111
+ process.stderr.write(` ${issue.file}: ${issue.message}\n`);
112
+ }
113
+ return 1;
114
+ }
115
+ case 'build': {
116
+ const result = await build();
117
+ if (result.code !== 0) {
118
+ process.stderr.write(`Build failed (exit ${result.code})\n`);
119
+ return result.code;
120
+ }
121
+ process.stdout.write(`Built to ${result.outputDir}\n`);
122
+ process.stdout.write('Next: ssg verify\n');
123
+ return 0;
124
+ }
125
+ case 'verify': {
126
+ const result = await verify({
127
+ skipLighthouse: args.lighthouse === false,
128
+ siteUrl: args.url,
129
+ ignore: [args.ignore].flat().filter(Boolean),
130
+ budgets: args['js-budget'] || args['css-budget']
131
+ ? {
132
+ jsBytes: Number(args['js-budget'] ?? DEFAULT_BUDGETS.jsBytes / 1024) *
133
+ 1024,
134
+ cssBytes: Number(args['css-budget'] ?? DEFAULT_BUDGETS.cssBytes / 1024) * 1024
135
+ }
136
+ : undefined
137
+ });
138
+ for (const warning of result.warnings) {
139
+ process.stdout.write(` note: ${warning}\n`);
140
+ }
141
+ if (!result.ok) {
142
+ process.stderr.write(`Verification failed: ${result.issues.length} issue(s)\n`);
143
+ for (const issue of result.issues) {
144
+ process.stderr.write(` ${issue}\n`);
145
+ }
146
+ return 1;
147
+ }
148
+ process.stdout.write(`Verified ${result.report.pages} page(s): OK${result.report.scores
149
+ ? ` (${Object.entries(result.report.scores)
150
+ .map(([key, value]) => `${key} ${value}`)
151
+ .join(', ')})`
152
+ : ''}\n`);
153
+ return 0;
154
+ }
155
+ default:
156
+ process.stderr.write(`Unknown command "${command}"\n\n${USAGE}`);
157
+ return 1;
158
+ }
159
+ }
160
+ try {
161
+ process.exitCode = await main(process.argv.slice(2));
162
+ }
163
+ catch (error) {
164
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
165
+ process.exitCode = 1;
166
+ }
@@ -0,0 +1,50 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { appDir, pagesDir } from '../paths.js';
4
+ import { PAGE_TAGS } from '../pages/page-schema.js';
5
+ import { renderPage } from '../pages/render-page.js';
6
+ function titleFromId(id) {
7
+ const last = id.split('/').filter(Boolean).pop() ?? id;
8
+ return last
9
+ .replace(/[-_]+/g, ' ')
10
+ .replace(/\b\w/g, (character) => character.toUpperCase());
11
+ }
12
+ /**
13
+ * `ssg add-page` — create a page with valid frontmatter.
14
+ *
15
+ * Generates the file through the same `renderPage` the schema validates, so a
16
+ * page created by the CLI cannot fail `ssg check`. It refuses to overwrite: an
17
+ * agent re-running a command must never silently destroy content.
18
+ */
19
+ export async function addPage(options) {
20
+ const app = options.app ?? appDir();
21
+ const id = options.id.replace(/^\/+/, '').replace(/\.md$/, '');
22
+ if (!id) {
23
+ throw new Error('add-page requires a page id, e.g. `ssg add-page about`');
24
+ }
25
+ const file = join(pagesDir(app), `${id}.md`);
26
+ try {
27
+ await fs.access(file);
28
+ throw new Error(`Refusing to overwrite ${file} — a page with id "${id}" already exists.`);
29
+ }
30
+ catch (error) {
31
+ // `fs.access` throwing ENOENT is the expected path; anything else is real.
32
+ if (error instanceof Error && !/ENOENT/.test(error.message))
33
+ throw error;
34
+ }
35
+ const title = options.title ?? titleFromId(id);
36
+ const source = renderPage({
37
+ id,
38
+ title,
39
+ description: options.description ??
40
+ `${title} — replace this with a one-sentence summary of the page.`,
41
+ label: options.label,
42
+ order: options.order,
43
+ tags: options.tags,
44
+ page: options.page
45
+ });
46
+ await fs.mkdir(dirname(file), { recursive: true });
47
+ await fs.writeFile(file, source, 'utf8');
48
+ return { file, id };
49
+ }
50
+ export { PAGE_TAGS };
@@ -0,0 +1,20 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { appDir, distDir } from '../paths.js';
3
+ /**
4
+ * `ssg build` — delegate to `vitrify build -m ssg`.
5
+ *
6
+ * Deliberately a thin wrapper: the build belongs to vitrify, and reimplementing
7
+ * it here would fork the build pipeline from the one the dev server uses. The
8
+ * value this command adds is the fixed SSG mode, the resolved output path, and a
9
+ * single place for the agent-facing instructions to point at.
10
+ */
11
+ export async function build(options = {}) {
12
+ const app = options.app ?? appDir();
13
+ const args = ['vitrify', 'build', '-m', 'ssg', ...(options.passthrough ?? [])];
14
+ const code = await new Promise((done, fail) => {
15
+ const child = spawn('npx', args, { cwd: app, stdio: 'inherit' });
16
+ child.once('error', fail);
17
+ child.once('close', (exitCode) => done(exitCode ?? 1));
18
+ });
19
+ return { code, outputDir: distDir(app) };
20
+ }
@@ -0,0 +1,53 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { appDir, pagesDir } from '../paths.js';
5
+ import { validateCollection, validatePage } from '../pages/page-schema.js';
6
+ import { idFromPath } from '../pages/render-page.js';
7
+ async function collectMarkdown(dir) {
8
+ let entries;
9
+ try {
10
+ entries = await fs.readdir(dir, { withFileTypes: true });
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ const files = [];
16
+ for (const entry of entries) {
17
+ const full = join(dir, entry.name);
18
+ if (entry.isDirectory())
19
+ files.push(...(await collectMarkdown(full)));
20
+ else if (entry.name.endsWith('.md'))
21
+ files.push(full);
22
+ }
23
+ return files;
24
+ }
25
+ /**
26
+ * `ssg check` — validate every page against the frontmatter contract.
27
+ *
28
+ * This is the guard that stops the defect class the old template suffered from:
29
+ * frontmatter that is subtly wrong still builds, and the damage only shows on the
30
+ * live site. Running it before a build turns that into a terminal error.
31
+ *
32
+ * An empty pages directory is a warning, not a failure — a brand new project has
33
+ * no pages yet, and failing there would be hostile.
34
+ */
35
+ export async function check(options = {}) {
36
+ const app = options.app ?? appDir();
37
+ const root = pagesDir(app);
38
+ const files = await collectMarkdown(root);
39
+ const issues = [];
40
+ const pages = [];
41
+ for (const file of [...files].sort((a, b) => a.localeCompare(b))) {
42
+ const relativePath = relative(root, file);
43
+ const source = await fs.readFile(file, 'utf8');
44
+ const { data } = matter(source);
45
+ // `id` defaults to the path so a page that omits it still gets a useful
46
+ // error against the id the router would actually generate.
47
+ const dataWithId = { id: idFromPath(relativePath), ...data };
48
+ pages.push({ file: relativePath, data: dataWithId });
49
+ issues.push(...validatePage(relativePath, dataWithId));
50
+ }
51
+ issues.push(...validateCollection(pages));
52
+ return { ok: issues.length === 0, pages: pages.length, issues };
53
+ }
@@ -0,0 +1,229 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { basename, resolve } from 'node:path';
3
+ import { renderDirectory, renderPackageJson, renderTsconfigJson } from '@vitrify/tools/render';
4
+ import { confirm, input, rawlist } from '@inquirer/prompts';
5
+ /**
6
+ * `ssg init` — scaffold a new site from the app template.
7
+ *
8
+ * Replaces the old `create-simple-site-generator` package. Prompts only when the
9
+ * process is attached to a TTY **and** `--yes` was not passed, so an agent can
10
+ * run it non-interactively and get the same project every time.
11
+ */
12
+ export const TEMPLATES = ['docs', 'landing', 'business', 'plain'];
13
+ /** The template tree, shipped inside this package (`files: ["dist","templates"]`). */
14
+ const APP_TEMPLATE_URL = new URL('../../templates/app/', import.meta.url);
15
+ /**
16
+ * Versions are pinned rather than looked up over the network.
17
+ *
18
+ * The old CLI called `latest-version` for every dependency, which made
19
+ * scaffolding slow, network-dependent and non-reproducible. A static map means
20
+ * `ssg init` works offline and two runs a month apart produce the same project.
21
+ */
22
+ const DEPENDENCIES = {
23
+ '@fastify/middie': '^9.0.1',
24
+ '@fastify/static': '^8.0.3',
25
+ '@quasar/extras': '^1.16.13',
26
+ // No `@quasar/quasar-ui-qmarkdown`: the templates render the HTML that
27
+ // `vite-plugin-markdown` produced at build time, so the application never
28
+ // needs a Markdown parser in the browser. The prose styles still come from
29
+ // the preset's ported selectors, which `appExtensions: ['qmarkdown']` in
30
+ // `vitrify.config.ts` declares — that entry is a style name, not a package.
31
+ '@quasar/quasar-ui-qmediaplayer': '^3.0.1',
32
+ '@simple-site-generator/templates': '^0.1.19',
33
+ quasar: '^2.32.3',
34
+ 'unocss-preset-quasar': '^0.7.0',
35
+ 'vite-plugin-pwa': '^1.0.0',
36
+ vue: '^3.5.42',
37
+ // Must match the ^5 range the templates package is built against, otherwise
38
+ // the app installs a second, incompatible vue-router.
39
+ 'vue-router': '^5.3.1'
40
+ };
41
+ const DEV_DEPENDENCIES = {
42
+ '@iconify-json/mdi': '^1.2.3',
43
+ '@types/node': '^26.5.1',
44
+ '@unocss/preset-icons': '^66.10.2',
45
+ '@unocss/preset-web-fonts': '^66.10.2',
46
+ '@vitejs/plugin-vue': '^6.0.9',
47
+ '@vue/reactivity': '^3.5.42',
48
+ '@vue/runtime-core': '^3.5.42',
49
+ '@vue/runtime-dom': '^3.5.42',
50
+ '@vue/server-renderer': '^3.5.42',
51
+ '@vue/shared': '^3.5.42',
52
+ beasties: '^0.3.0',
53
+ devcert: '^1.2.2',
54
+ fastify: '^5.2.1',
55
+ 'gray-matter': '^4.0.3',
56
+ 'icon-gen': '^5.0.0',
57
+ 'npm-run-all': '^4.1.5',
58
+ oxfmt: '^0.68.0',
59
+ oxlint: '^1.83.0',
60
+ // TypeScript 7 is the native port and dropped the JS API that
61
+ // @vue/compiler-sfc needs to resolve imported types in defineProps().
62
+ typescript: '^6.0.3',
63
+ unocss: '^66.10.2',
64
+ vite: '^8.3.0',
65
+ 'vite-plugin-markdown': '^2.2.0',
66
+ vitrify: '^0.28.1',
67
+ 'workbox-window': '^7.3.0'
68
+ };
69
+ const SCRIPTS = {
70
+ dev: 'vitrify dev',
71
+ 'dev:ssr': 'vitrify dev -m ssr',
72
+ build: 'run-s build:ssg',
73
+ 'build:csr': 'vitrify build',
74
+ 'build:ssr': 'vitrify build -m ssr',
75
+ 'build:ssg': 'vitrify build -m ssg',
76
+ check: 'ssg check',
77
+ // Build, then serve the result the way production does — extensionless URLs
78
+ // and a real 404 — rather than `caddy file-server`, which has no `try_files`
79
+ // and answers 404 for every page URL.
80
+ preview: 'pnpm run build && caddy run --config build/Caddyfile.preview --adapter caddyfile',
81
+ verify: 'ssg verify',
82
+ test: 'vitrify test',
83
+ lint: 'oxlint src',
84
+ 'lint:fix': 'oxlint --fix src',
85
+ 'format:check': 'oxfmt --check .',
86
+ 'format:write': 'oxfmt --write .'
87
+ };
88
+ const TSCONFIG = {
89
+ paths: { src: ['./src'] },
90
+ types: ['vite/client', 'vitrify/client'],
91
+ include: [
92
+ 'vitrify.config.ts',
93
+ 'src/**/*.ts',
94
+ 'src/**/*.d.ts',
95
+ 'src/**/*.tsx',
96
+ 'src/**/*.vue'
97
+ ]
98
+ };
99
+ function assertTemplate(value) {
100
+ if (TEMPLATES.includes(value)) {
101
+ return value;
102
+ }
103
+ throw new Error(`Unknown template "${value}". Valid templates: ${TEMPLATES.join(', ')}.`);
104
+ }
105
+ /**
106
+ * Resolve options, prompting only when it is safe and wanted.
107
+ *
108
+ * A non-interactive run must never hang waiting on stdin, which is why the
109
+ * `isTTY` check comes before any prompt.
110
+ */
111
+ export async function resolveInitOptions(options) {
112
+ const directory = resolve(options.directory ?? '.');
113
+ let template;
114
+ if (options.template) {
115
+ template = assertTemplate(options.template);
116
+ }
117
+ else if (options.yes || !process.stdin.isTTY) {
118
+ template = 'docs';
119
+ }
120
+ else {
121
+ template = assertTemplate(await rawlist({
122
+ message: 'Which template would you like to use?',
123
+ choices: [
124
+ { name: 'Documentation', value: 'docs' },
125
+ { name: 'Landing page', value: 'landing' },
126
+ { name: 'Local business', value: 'business' },
127
+ { name: 'Plain service', value: 'plain' }
128
+ ]
129
+ }));
130
+ }
131
+ const fallbackName = basename(directory) || 'site';
132
+ const interactive = !options.yes && process.stdin.isTTY;
133
+ const name = options.name ??
134
+ (interactive
135
+ ? await input({
136
+ message: 'Package name',
137
+ default: fallbackName,
138
+ validate: (value) => Boolean(value?.length)
139
+ })
140
+ : fallbackName);
141
+ const productName = options.productName ??
142
+ (interactive
143
+ ? await input({
144
+ message: 'Site name',
145
+ default: name,
146
+ validate: (value) => Boolean(value?.length)
147
+ })
148
+ : name);
149
+ const url = options.url ??
150
+ (interactive
151
+ ? await input({
152
+ message: 'Website URL',
153
+ default: 'https://example.com',
154
+ validate: (value) => Boolean(value?.length)
155
+ })
156
+ : 'https://example.com');
157
+ const description = options.description ??
158
+ (interactive
159
+ ? await input({
160
+ message: 'Description',
161
+ default: `${productName} website`
162
+ })
163
+ : `${productName} website`);
164
+ const author = options.author ?? (interactive ? await input({ message: 'Author' }) : '');
165
+ return { directory, template, name, productName, url, description, author };
166
+ }
167
+ export async function init(options = {}) {
168
+ const resolved = await resolveInitOptions(options);
169
+ const outputDir = new URL(`./${basename(resolved.directory)}/`, new URL('', `file://${resolve(resolved.directory, '..')}/`));
170
+ const templateVariables = {
171
+ template: resolved.template,
172
+ // Only the `landing` module exports a `Landing` component, so only a landing
173
+ // site's home page may name it. This used to be hardcoded in the scaffold's
174
+ // `Index.md`, which meant a `business` or `docs` site shipped `page: Landing`
175
+ // too -- and the router, having no such component, fell back to the generic
176
+ // `Page` and rendered the wrong home page with no warning. Once an unknown
177
+ // `page:` became a build failure, that scaffold could not build at all.
178
+ landingPage: resolved.template === 'landing',
179
+ name: resolved.name,
180
+ productName: resolved.productName,
181
+ url: resolved.url,
182
+ description: resolved.description,
183
+ author: resolved.author
184
+ };
185
+ await renderDirectory({
186
+ directoryUrl: APP_TEMPLATE_URL,
187
+ templateVariables,
188
+ outputDir
189
+ });
190
+ await fs.writeFile(new URL('./package.json', outputDir), renderPackageJson({
191
+ ...templateVariables,
192
+ version: '0.1.0',
193
+ license: 'UNLICENSED',
194
+ scripts: SCRIPTS,
195
+ dependencies: DEPENDENCIES,
196
+ devDependencies: DEV_DEPENDENCIES,
197
+ exports: {
198
+ '.': {
199
+ types: './dist/types/index.d.ts',
200
+ import: './dist/index.js'
201
+ }
202
+ }
203
+ }));
204
+ await fs.writeFile(new URL('./tsconfig.json', outputDir),
205
+ // `renderTsconfigJson` emits library defaults, including a `rootDir` of
206
+ // `./src` — which the very same tsconfig includes `vitrify.config.ts`
207
+ // outside of, so `tsc` fails every generated project with TS6059. A
208
+ // generated app has no reason to pin a `rootDir`, so strip it.
209
+ renderTsconfigJson(TSCONFIG).replace(/^\s*"rootDir": "\.\/src",\n/m, ''));
210
+ return { directory: resolved.directory, template: resolved.template };
211
+ }
212
+ /** Confirm overwriting a non-empty directory (interactive runs only). */
213
+ export async function confirmOverwrite(directory) {
214
+ try {
215
+ const entries = await fs.readdir(directory);
216
+ if (entries.length === 0)
217
+ return true;
218
+ }
219
+ catch {
220
+ return true;
221
+ }
222
+ if (!process.stdin.isTTY) {
223
+ throw new Error(`${directory} is not empty. Refusing to overwrite a non-empty directory.`);
224
+ }
225
+ return confirm({
226
+ message: `${directory} is not empty. Continue anyway?`,
227
+ default: false
228
+ });
229
+ }
@@ -0,0 +1,150 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { appDir, distDir } from '../paths.js';
4
+ import { DEFAULT_BUDGETS, budgetHeadroomWarnings, evaluateScores, evaluateTotals, formatBytes, measureBuildAssets } from '../verify/budgets.js';
5
+ import { runLighthouse } from '../verify/lighthouse.js';
6
+ import { isIgnored } from '../verify/ignore.js';
7
+ import { checkSeoSurface } from '../verify/seo-surface.js';
8
+ import { checkClassCoverage } from '../verify/class-coverage.js';
9
+ import { startStaticServer } from '../verify/serve.js';
10
+ async function collectHtml(dir) {
11
+ let entries;
12
+ try {
13
+ entries = await fs.readdir(dir, { withFileTypes: true });
14
+ }
15
+ catch {
16
+ throw new Error(`No build output at ${dir}. Run \`ssg build\` before \`ssg verify\`.`);
17
+ }
18
+ const files = [];
19
+ for (const entry of entries) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory())
22
+ files.push(...(await collectHtml(full)));
23
+ else if (entry.name.endsWith('.html'))
24
+ files.push(full);
25
+ }
26
+ return files;
27
+ }
28
+ /**
29
+ * `ssg verify` — prove the built site is correct and fast.
30
+ *
31
+ * Two tiers by design: the SEO surface and the byte budgets are deterministic
32
+ * and **fail** the run; Lighthouse performance only **warns**, because it varies
33
+ * with the measuring machine and a gate that fails randomly gets switched off.
34
+ *
35
+ * It does not build. `verify` measures what is on disk, so it can be run against
36
+ * a deployed artifact as well as a local build.
37
+ */
38
+ export async function verify(options = {}) {
39
+ const app = options.app ?? appDir();
40
+ const outputDir = distDir(app);
41
+ const budgets = options.budgets ?? DEFAULT_BUDGETS;
42
+ const issues = [];
43
+ const warnings = [];
44
+ /** Measured transferred bytes, filled in below. */
45
+ let budgetTotals = { js: 0, css: 0 };
46
+ // --- SEO surface (deterministic, hard failure) --------------------------
47
+ const ignore = options.ignore ?? [];
48
+ const htmlFiles = (await collectHtml(outputDir)).filter((file) => {
49
+ const page = relative(outputDir, file).replace(/\\/g, '/');
50
+ return !isIgnored(page, ignore);
51
+ });
52
+ if (htmlFiles.length === 0) {
53
+ issues.push(`No HTML files found in ${outputDir}`);
54
+ }
55
+ for (const file of [...htmlFiles].sort((a, b) => a.localeCompare(b))) {
56
+ const page = relative(outputDir, file);
57
+ const html = await fs.readFile(file, 'utf8');
58
+ for (const issue of checkSeoSurface(html, page, {
59
+ siteUrl: options.siteUrl
60
+ })) {
61
+ issues.push(`${issue.page}: ${issue.message}`);
62
+ }
63
+ }
64
+ // --- Byte budgets (deterministic, measured from the built files) -------
65
+ // Measured from disk rather than from Lighthouse: the audit reports zero
66
+ // bytes when its trace captures no network activity, and a budget that
67
+ // measures nothing is a gate that cannot fail.
68
+ const assets = await measureBuildAssets(outputDir);
69
+ // --- Markup ↔ stylesheet coverage (deterministic, hard failure) ----------
70
+ //
71
+ // `ssg-*` classes come from the design system's UnoCSS configuration, and a
72
+ // class that resolves to nothing is invisible everywhere else: the build
73
+ // passes, the class is present in the markup, and the element is unstyled.
74
+ // This is the gate for that.
75
+ const stylesheets = assets.files.filter((file) => file.type === 'css');
76
+ if (stylesheets.length) {
77
+ const css = (await Promise.all(stylesheets.map((file) => fs.readFile(join(outputDir, file.file), 'utf8')))).join('\n');
78
+ const pages = await Promise.all(htmlFiles.map(async (file) => ({
79
+ page: relative(outputDir, file),
80
+ html: await fs.readFile(file, 'utf8')
81
+ })));
82
+ for (const issue of checkClassCoverage(pages, css)) {
83
+ issues.push(`${issue.page}: "${issue.className}" is used in the markup but no stylesheet styles it — either the design system lost it or the class is a typo`);
84
+ }
85
+ }
86
+ const budgetResult = evaluateTotals({ js: assets.js, css: assets.css }, budgets);
87
+ budgetTotals = budgetResult.totals;
88
+ if (!budgetResult.ok) {
89
+ issues.push(...budgetResult.issues);
90
+ }
91
+ else {
92
+ warnings.push(`Byte budgets OK: JS ${formatBytes(budgetTotals.js)} / ${formatBytes(budgets.jsBytes)}, CSS ${formatBytes(budgetTotals.css)} / ${formatBytes(budgets.cssBytes)}`);
93
+ // A budget a site is always near is a cliff (KI-1 in docs/known-issues.md).
94
+ // Inside the budget is not a defect, so this informs rather than blocks.
95
+ warnings.push(...budgetHeadroomWarnings(budgetTotals, budgets));
96
+ }
97
+ if (assets.files.length) {
98
+ const largest = assets.files
99
+ .slice(0, 3)
100
+ .map((file) => `${file.file} ${formatBytes(file.bytes)}`)
101
+ .join(', ');
102
+ warnings.push(`Largest assets: ${largest}`);
103
+ }
104
+ // --- Lighthouse scores (advisory for performance) ---------------------
105
+ let scores;
106
+ if (htmlFiles.length > 0) {
107
+ const server = await startStaticServer(outputDir);
108
+ try {
109
+ if (options.skipLighthouse) {
110
+ warnings.push('Lighthouse skipped (--no-lighthouse): scores not measured');
111
+ }
112
+ else {
113
+ const result = await runLighthouse(server.url, {
114
+ chromePath: options.chromePath,
115
+ formFactor: 'mobile'
116
+ });
117
+ scores = result.scores;
118
+ const scoreResult = evaluateScores(result.scores);
119
+ for (const issue of scoreResult.issues) {
120
+ const category = issue.split(' ')[0];
121
+ const failing = result.failures?.[category];
122
+ const detail = failing
123
+ ?.map((id) => result.details?.[id]?.length
124
+ ? `${id} [${result.details[id].join(' | ')}]`
125
+ : id)
126
+ .join(', ');
127
+ issues.push(detail ? `${issue} (failing audits: ${detail})` : issue);
128
+ }
129
+ warnings.push(...scoreResult.warnings);
130
+ }
131
+ }
132
+ finally {
133
+ await server.close();
134
+ }
135
+ }
136
+ const ok = issues.length === 0;
137
+ // The report is written next to the build so CI can annotate it — a
138
+ // terminal-only result is invisible to a pipeline.
139
+ await fs
140
+ .writeFile(join(outputDir, 'verify-report.json'), `${JSON.stringify({ ok, issues, warnings, pages: htmlFiles.length, budgets: budgetTotals, scores }, null, 2)}\n`, 'utf8')
141
+ .catch(() => {
142
+ // The report is a convenience; failing to write it must not fail verify.
143
+ });
144
+ return {
145
+ ok,
146
+ issues,
147
+ warnings,
148
+ report: { pages: htmlFiles.length, budgets: budgetTotals, scores }
149
+ };
150
+ }