@brandon_m_behring/book-scaffold-astro 5.0.0 → 5.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/CLAUDE.md +46 -3
- package/README.md +55 -0
- package/assets/og-fonts/Inter-Bold.ttf +0 -0
- package/assets/og-fonts/Inter-Regular.ttf +0 -0
- package/assets/og-fonts/LICENSE.txt +89 -0
- package/assets/og-fonts/SOURCE.md +13 -0
- package/bin/book-scaffold.mjs +4 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +894 -8
- package/dist/schemas.d.ts +1 -1
- package/dist/{types-D1QZgKMO.d.ts → types-DjqMnwHw.d.ts} +22 -4
- package/layouts/Base.astro +101 -3
- package/package.json +5 -1
- package/recipes/25-qa-readiness.md +197 -0
- package/recipes/26-generated-og-cards.md +205 -0
- package/recipes/README.md +2 -0
- package/scripts/init-qa.mjs +209 -0
- package/scripts/qa-core.mjs +1511 -0
- package/scripts/qa.mjs +293 -0
- package/scripts/resolve-book-config.mjs +39 -0
- package/scripts/validate-core.mjs +1675 -0
- package/scripts/validate.mjs +18 -1492
- package/scripts/validation-artifacts.mjs +23 -0
- package/src/lib/og-cards.ts +1135 -0
- package/src/types.ts +22 -3
package/scripts/qa.mjs
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** CLI adapter for the deterministic scaffold QA engine. */
|
|
3
|
+
import Ajv from 'ajv';
|
|
4
|
+
import Ajv2019 from 'ajv/dist/2019.js';
|
|
5
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
6
|
+
import { realpathSync } from 'node:fs';
|
|
7
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
qaExitCode,
|
|
12
|
+
renderQaHuman,
|
|
13
|
+
renderQaJson,
|
|
14
|
+
runQa,
|
|
15
|
+
} from './qa-core.mjs';
|
|
16
|
+
import { runValidation } from './validate-core.mjs';
|
|
17
|
+
import { regenerateValidationArtifact } from './validation-artifacts.mjs';
|
|
18
|
+
|
|
19
|
+
export const QA_USAGE = `Usage: book-scaffold qa [--book <id> | --all] [--format human|json]
|
|
20
|
+
|
|
21
|
+
Run scaffold content validation and deterministic readiness checks.
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--book <id> Check exactly one registered corpus book.
|
|
25
|
+
--all Check every corpus book (the default).
|
|
26
|
+
--format <format> human (default) or json.
|
|
27
|
+
--json Alias for --format json.
|
|
28
|
+
--help, -h Print this message without inspecting the project.
|
|
29
|
+
|
|
30
|
+
Exit codes:
|
|
31
|
+
0 No blocking failures (green or amber).
|
|
32
|
+
1 At least one selected content/shared check is red.
|
|
33
|
+
2 Invalid invocation, config, manifest, or internal execution failure.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export class QaUsageError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'QaUsageError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse and sanitize only the public QA flags. */
|
|
44
|
+
export function parseQaArgs(argv) {
|
|
45
|
+
if (!Array.isArray(argv)) throw new TypeError('qa arguments must be an array.');
|
|
46
|
+
let book = null;
|
|
47
|
+
let all = false;
|
|
48
|
+
let format = null;
|
|
49
|
+
let jsonAlias = false;
|
|
50
|
+
let help = false;
|
|
51
|
+
|
|
52
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
53
|
+
const arg = argv[index];
|
|
54
|
+
if (arg === '--book') {
|
|
55
|
+
if (book !== null) throw new QaUsageError('--book may be provided only once.');
|
|
56
|
+
const value = argv[index + 1];
|
|
57
|
+
if (!value || value.startsWith('--')) {
|
|
58
|
+
throw new QaUsageError('--book requires a registered book id.');
|
|
59
|
+
}
|
|
60
|
+
book = value;
|
|
61
|
+
index += 1;
|
|
62
|
+
} else if (arg === '--all') {
|
|
63
|
+
if (all) throw new QaUsageError('--all may be provided only once.');
|
|
64
|
+
all = true;
|
|
65
|
+
} else if (arg === '--format') {
|
|
66
|
+
if (format !== null) throw new QaUsageError('--format may be provided only once.');
|
|
67
|
+
const value = argv[index + 1];
|
|
68
|
+
if (!value || value.startsWith('--')) {
|
|
69
|
+
throw new QaUsageError('--format requires human or json.');
|
|
70
|
+
}
|
|
71
|
+
if (value !== 'human' && value !== 'json') {
|
|
72
|
+
throw new QaUsageError(`--format must be human or json (got ${JSON.stringify(value)}).`);
|
|
73
|
+
}
|
|
74
|
+
format = value;
|
|
75
|
+
index += 1;
|
|
76
|
+
} else if (arg === '--json') {
|
|
77
|
+
if (jsonAlias) throw new QaUsageError('--json may be provided only once.');
|
|
78
|
+
jsonAlias = true;
|
|
79
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
80
|
+
help = true;
|
|
81
|
+
} else {
|
|
82
|
+
throw new QaUsageError(`unknown argument ${JSON.stringify(arg)}.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (book !== null && all) {
|
|
87
|
+
throw new QaUsageError('--book and --all are mutually exclusive.');
|
|
88
|
+
}
|
|
89
|
+
if (jsonAlias && format !== null) {
|
|
90
|
+
throw new QaUsageError('--json and --format may not be combined.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const outputFormat = jsonAlias ? 'json' : format ?? 'human';
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
book,
|
|
96
|
+
all,
|
|
97
|
+
format: outputFormat,
|
|
98
|
+
help,
|
|
99
|
+
validationArgv: Object.freeze(book === null ? [] : ['--book', book]),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const JSON_SCHEMA_DIALECTS = new Map([
|
|
104
|
+
['http://json-schema.org/draft-07/schema', { name: 'draft-07', Constructor: Ajv }],
|
|
105
|
+
['https://json-schema.org/draft/2019-09/schema', { name: '2019-09', Constructor: Ajv2019 }],
|
|
106
|
+
['https://json-schema.org/draft/2020-12/schema', { name: '2020-12', Constructor: Ajv2020 }],
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
function schemaDialect(schema, inherited = null) {
|
|
110
|
+
if (schema?.$schema === undefined) {
|
|
111
|
+
return inherited ?? JSON_SCHEMA_DIALECTS.get('http://json-schema.org/draft-07/schema');
|
|
112
|
+
}
|
|
113
|
+
if (typeof schema.$schema !== 'string') {
|
|
114
|
+
throw new Error('JSON Schema $schema must be a supported dialect URI string.');
|
|
115
|
+
}
|
|
116
|
+
const normalized = schema.$schema.endsWith('#') ? schema.$schema.slice(0, -1) : schema.$schema;
|
|
117
|
+
const dialect = JSON_SCHEMA_DIALECTS.get(normalized);
|
|
118
|
+
if (!dialect) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Unsupported JSON Schema dialect ${JSON.stringify(schema.$schema)}; ` +
|
|
121
|
+
'supported dialects are draft-07, 2019-09, and 2020-12.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (inherited && dialect.name !== inherited.name) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Local JSON Schema resource uses ${dialect.name}, but the root schema uses ` +
|
|
127
|
+
`${inherited.name}; mixed dialects are not supported.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return dialect;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function insideRoot(root, path) {
|
|
134
|
+
const local = relative(root, path);
|
|
135
|
+
return local === '' || (!local.startsWith('..') && !isAbsolute(local));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Validate a fixture against one already-loaded local JSON Schema. */
|
|
139
|
+
export async function validateLocalJsonSchema({
|
|
140
|
+
value,
|
|
141
|
+
schema,
|
|
142
|
+
schemaPath,
|
|
143
|
+
schemaFragment = '',
|
|
144
|
+
root = dirname(schemaPath),
|
|
145
|
+
}) {
|
|
146
|
+
const dialect = schemaDialect(schema);
|
|
147
|
+
const projectRoot = await realpath(root);
|
|
148
|
+
const canonicalSchemaPath = await realpath(schemaPath);
|
|
149
|
+
if (!insideRoot(projectRoot, canonicalSchemaPath)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`JSON Schema root ${JSON.stringify(schemaPath)} escapes the project root.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const loadSchema = async (uri) => {
|
|
155
|
+
let url;
|
|
156
|
+
try {
|
|
157
|
+
url = new URL(uri);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`JSON Schema reference ${JSON.stringify(uri)} is not a project-local file URL.`);
|
|
160
|
+
}
|
|
161
|
+
if (url.protocol !== 'file:') {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`JSON Schema reference ${JSON.stringify(uri)} is not project-local; network schemas are disabled.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const requestedPath = fileURLToPath(url);
|
|
167
|
+
let canonicalPath;
|
|
168
|
+
try {
|
|
169
|
+
canonicalPath = await realpath(requestedPath);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Project-local JSON Schema ${JSON.stringify(requestedPath)} could not be read: ` +
|
|
173
|
+
`${error?.message ?? error}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (!insideRoot(projectRoot, canonicalPath)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`JSON Schema reference ${JSON.stringify(uri)} escapes the project root.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const loaded = JSON.parse(await readFile(canonicalPath, 'utf8'));
|
|
182
|
+
schemaDialect(loaded, dialect);
|
|
183
|
+
return loaded;
|
|
184
|
+
};
|
|
185
|
+
const ajv = new dialect.Constructor({
|
|
186
|
+
allErrors: true,
|
|
187
|
+
strict: false,
|
|
188
|
+
validateFormats: false,
|
|
189
|
+
loadSchema,
|
|
190
|
+
});
|
|
191
|
+
const schemaKey = pathToFileURL(schemaPath).href;
|
|
192
|
+
ajv.addSchema(schema, schemaKey);
|
|
193
|
+
const reference = `${schemaKey}${schemaFragment}`;
|
|
194
|
+
const validate = await ajv.compileAsync({ $ref: reference });
|
|
195
|
+
const valid = await validate(value);
|
|
196
|
+
return { valid: Boolean(valid), errors: validate.errors ?? [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function withCliStdoutGuard(enabled, stderr, execute) {
|
|
200
|
+
if (!enabled) return execute();
|
|
201
|
+
|
|
202
|
+
const originalWrite = process.stdout.write;
|
|
203
|
+
const diverted = [];
|
|
204
|
+
process.stdout.write = function guardedStdoutWrite(chunk, encoding, callback) {
|
|
205
|
+
diverted.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
|
|
206
|
+
const done = typeof encoding === 'function' ? encoding : callback;
|
|
207
|
+
if (typeof done === 'function') queueMicrotask(done);
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
try {
|
|
211
|
+
return await execute();
|
|
212
|
+
} finally {
|
|
213
|
+
process.stdout.write = originalWrite;
|
|
214
|
+
if (diverted.length > 0) {
|
|
215
|
+
const content = diverted.join('');
|
|
216
|
+
stderr.write('qa: consumer stdout redirected to stderr:\n');
|
|
217
|
+
stderr.write(content.endsWith('\n') ? content : `${content}\n`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Execute QA with injectable boundaries and return the public exit code. */
|
|
223
|
+
export async function runQaCli({
|
|
224
|
+
argv = process.argv.slice(2),
|
|
225
|
+
projectRoot = process.cwd(),
|
|
226
|
+
env = process.env,
|
|
227
|
+
stdout = process.stdout,
|
|
228
|
+
stderr = process.stderr,
|
|
229
|
+
executeQa = runQa,
|
|
230
|
+
runValidationImpl = runValidation,
|
|
231
|
+
regenerateArtifact = regenerateValidationArtifact,
|
|
232
|
+
schemaValidator = validateLocalJsonSchema,
|
|
233
|
+
} = {}) {
|
|
234
|
+
let parsed;
|
|
235
|
+
try {
|
|
236
|
+
parsed = parseQaArgs(argv);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
stderr.write(`qa: ${error?.message ?? error}\n\n${QA_USAGE}`);
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (parsed.help) {
|
|
243
|
+
stdout.write(QA_USAGE);
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const root = resolve(projectRoot);
|
|
248
|
+
const validationAdapter = async (input) => {
|
|
249
|
+
const result = await runValidationImpl(input);
|
|
250
|
+
if (result?.fatal && result.output?.stderr) stderr.write(result.output.stderr);
|
|
251
|
+
return result;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const result = await withCliStdoutGuard(stdout === process.stdout, stderr, () =>
|
|
256
|
+
executeQa({
|
|
257
|
+
root,
|
|
258
|
+
argv: parsed.validationArgv,
|
|
259
|
+
env,
|
|
260
|
+
runValidation: validationAdapter,
|
|
261
|
+
validateJsonSchema: schemaValidator,
|
|
262
|
+
validationOptions: {
|
|
263
|
+
regenerateArtifact,
|
|
264
|
+
onProgress(event) {
|
|
265
|
+
const selected = event.book ? ` for ${event.book}` : '';
|
|
266
|
+
stderr.write(`qa: regenerating ${event.artifact}${selected}\n`);
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
}));
|
|
270
|
+
if (parsed.format === 'json') stdout.write(renderQaJson(result));
|
|
271
|
+
else {
|
|
272
|
+
const color = Boolean(stdout.isTTY && env.NO_COLOR === undefined && env.TERM !== 'dumb');
|
|
273
|
+
stdout.write(renderQaHuman(result, { color }));
|
|
274
|
+
}
|
|
275
|
+
return qaExitCode(result);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
stderr.write(`qa: fatal: ${error?.message ?? error}\n`);
|
|
278
|
+
return 2;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
283
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
284
|
+
function canonicalPath(path) {
|
|
285
|
+
try {
|
|
286
|
+
return realpathSync(path);
|
|
287
|
+
} catch {
|
|
288
|
+
return path;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (invokedPath && canonicalPath(invokedPath) === canonicalPath(modulePath)) {
|
|
292
|
+
process.exitCode = await runQaCli();
|
|
293
|
+
}
|
|
@@ -11,6 +11,8 @@ export const DEFAULT_TOOLING_CONFIG = Object.freeze({
|
|
|
11
11
|
bookField: 'book',
|
|
12
12
|
apparatusRoute: '/:route/',
|
|
13
13
|
apparatusRoutes: Object.freeze([]),
|
|
14
|
+
enabledRoutes: Object.freeze([]),
|
|
15
|
+
frontmatterRoute: '/frontmatter/[slug]',
|
|
14
16
|
base: '/',
|
|
15
17
|
integrationFound: false,
|
|
16
18
|
});
|
|
@@ -33,6 +35,21 @@ const APPARATUS_ROUTES = [
|
|
|
33
35
|
'flashcards',
|
|
34
36
|
'answers',
|
|
35
37
|
];
|
|
38
|
+
const ROUTE_TOGGLES = [
|
|
39
|
+
'references',
|
|
40
|
+
'search',
|
|
41
|
+
'print',
|
|
42
|
+
'chapters',
|
|
43
|
+
'convergence',
|
|
44
|
+
'frontmatter',
|
|
45
|
+
'tips',
|
|
46
|
+
'exercises',
|
|
47
|
+
'practiceExam',
|
|
48
|
+
'glossary',
|
|
49
|
+
'answers',
|
|
50
|
+
'flashcards',
|
|
51
|
+
'landing',
|
|
52
|
+
];
|
|
36
53
|
|
|
37
54
|
function findAstroConfig(projectRoot) {
|
|
38
55
|
for (const name of ASTRO_CONFIG_NAMES) {
|
|
@@ -144,6 +161,21 @@ function resolveApparatusRoutes(value, configPath) {
|
|
|
144
161
|
return Object.freeze([...value]);
|
|
145
162
|
}
|
|
146
163
|
|
|
164
|
+
function resolveEnabledRoutes(value, configPath) {
|
|
165
|
+
if (value == null) return [];
|
|
166
|
+
if (
|
|
167
|
+
!Array.isArray(value) ||
|
|
168
|
+
value.some((route) => typeof route !== 'string' || !ROUTE_TOGGLES.includes(route)) ||
|
|
169
|
+
new Set(value).size !== value.length
|
|
170
|
+
) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`book-scaffold tooling: ${configPath} resolved invalid enabledRoutes; ` +
|
|
173
|
+
`expected unique values from ${ROUTE_TOGGLES.join(' | ')}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return Object.freeze([...value]);
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
function resolveCorpus(value, preset, configPath) {
|
|
148
180
|
if (value == null) return null;
|
|
149
181
|
if (
|
|
@@ -276,6 +308,13 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
|
|
|
276
308
|
configPath,
|
|
277
309
|
),
|
|
278
310
|
apparatusRoutes: resolveApparatusRoutes(metadata.apparatusRoutes, configPath),
|
|
311
|
+
enabledRoutes: resolveEnabledRoutes(metadata.enabledRoutes, configPath),
|
|
312
|
+
frontmatterRoute: resolveNonEmptyString(
|
|
313
|
+
metadata.frontmatterRoute,
|
|
314
|
+
DEFAULT_TOOLING_CONFIG.frontmatterRoute,
|
|
315
|
+
'frontmatterRoute',
|
|
316
|
+
configPath,
|
|
317
|
+
),
|
|
279
318
|
base,
|
|
280
319
|
integrationFound: true,
|
|
281
320
|
};
|