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.
- package/dist/cli.js +165 -2
- package/dist/commands/add-page.js +50 -0
- package/dist/commands/build.js +20 -0
- package/dist/commands/check.js +53 -0
- package/dist/commands/init.js +229 -0
- package/dist/commands/verify.js +150 -0
- package/dist/pages/page-schema.js +161 -0
- package/dist/pages/render-page.js +46 -0
- package/dist/paths.js +27 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/commands/add-page.d.ts +26 -0
- package/dist/types/commands/build.d.ts +18 -0
- package/dist/types/commands/check.d.ts +20 -0
- package/dist/types/commands/init.d.ts +40 -0
- package/dist/types/commands/verify.d.ts +47 -0
- package/dist/types/pages/page-schema.d.ts +81 -0
- package/dist/types/pages/render-page.d.ts +26 -0
- package/dist/types/paths.d.ts +16 -0
- package/dist/types/verify/budgets.d.ts +114 -0
- package/dist/types/verify/class-coverage.d.ts +65 -0
- package/dist/types/verify/ignore.d.ts +8 -0
- package/dist/types/verify/lighthouse.d.ts +27 -0
- package/dist/types/verify/seo-surface.d.ts +23 -0
- package/dist/types/verify/serve.d.ts +6 -0
- package/dist/verify/budgets.js +180 -0
- package/dist/verify/class-coverage.js +114 -0
- package/dist/verify/ignore.js +11 -0
- package/dist/verify/lighthouse.js +98 -0
- package/dist/verify/seo-surface.js +97 -0
- package/dist/verify/serve.js +123 -0
- package/package.json +16 -10
- package/templates/app/.github/workflows/deploy-rsync.yaml.hbs +58 -0
- package/templates/app/.oxfmtrc.json +32 -0
- package/templates/app/.oxlintrc.json +10 -0
- package/templates/app/.vscode/extensions.json +3 -0
- package/templates/app/build/Caddyfile.hbs +24 -0
- package/templates/app/build/Caddyfile.preview +34 -0
- package/templates/app/index.html +35 -0
- package/templates/app/logo.svg +143 -0
- package/templates/app/pnpm-workspace.yaml +10 -0
- package/templates/app/public/favicon-128.png +0 -0
- package/templates/app/public/favicon-48.png +0 -0
- package/templates/app/public/favicon-96.png +0 -0
- package/templates/app/public/favicon.ico +0 -0
- package/templates/app/public/images/landing/landing.png +0 -0
- package/templates/app/public/robots.txt +5 -0
- package/templates/app/src/App.vue +22 -0
- package/templates/app/src/pages/Contact.md +25 -0
- package/templates/app/src/pages/Error404Page.vue +31 -0
- package/templates/app/src/pages/GettingStarted/GettingStarted.md +52 -0
- package/templates/app/src/pages/Index.md.hbs +46 -0
- package/templates/app/src/router/index.ts +21 -0
- package/templates/app/src/router/routes.ts +218 -0
- package/templates/app/src/setup.ts +8 -0
- package/templates/app/src/shims-virtual.d.ts +67 -0
- package/templates/app/src/shims-vue.d.ts +6 -0
- 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
|
-
|
|
3
|
-
|
|
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
|
+
}
|