@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate the portfolio QA engine's deterministic guide registry.
|
|
4
|
+
*
|
|
5
|
+
* This module deliberately owns only guide_qa.yaml generation. The scaffold
|
|
6
|
+
* QA command does not execute the generated shell commands, and this script
|
|
7
|
+
* never spawns npm or performs network work itself.
|
|
8
|
+
*/
|
|
9
|
+
import { access, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { constants as fsConstants } from 'node:fs';
|
|
11
|
+
import { relative, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
14
|
+
|
|
15
|
+
export const GUIDE_QA_FILENAME = 'guide_qa.yaml';
|
|
16
|
+
export const GUIDE_QA_HEADER =
|
|
17
|
+
'# Generated by book-scaffold init-qa.\n' +
|
|
18
|
+
'# Regenerate with: book-scaffold init-qa --force\n';
|
|
19
|
+
|
|
20
|
+
export const INIT_QA_USAGE = `Usage: book-scaffold init-qa [--force]
|
|
21
|
+
|
|
22
|
+
Create a deterministic guide_qa.yaml for the implicit single book or every
|
|
23
|
+
registered corpus book. Generated checks use the locally installed scaffold
|
|
24
|
+
binary in npm offline mode.
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--force Replace an existing guide_qa.yaml. No other file is changed.
|
|
28
|
+
--help, -h Print this message and exit without writing a file.
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const BOOK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
|
+
const COMMAND_PREFIX = 'npm --offline exec -- book-scaffold qa';
|
|
33
|
+
|
|
34
|
+
export class InitQaUsageError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'InitQaUsageError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class GuideQaExistsError extends Error {
|
|
42
|
+
constructor(path) {
|
|
43
|
+
super(`${GUIDE_QA_FILENAME} already exists; rerun with --force to replace it.`);
|
|
44
|
+
this.name = 'GuideQaExistsError';
|
|
45
|
+
this.code = 'EEXIST';
|
|
46
|
+
this.path = path;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse only the flags owned by init-qa. */
|
|
51
|
+
export function parseInitQaArgs(argv) {
|
|
52
|
+
if (!Array.isArray(argv)) {
|
|
53
|
+
throw new TypeError('init-qa arguments must be an array.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let force = false;
|
|
57
|
+
let help = false;
|
|
58
|
+
for (const arg of argv) {
|
|
59
|
+
if (arg === '--force') {
|
|
60
|
+
if (force) throw new InitQaUsageError('--force may be specified only once.');
|
|
61
|
+
force = true;
|
|
62
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
63
|
+
help = true;
|
|
64
|
+
} else {
|
|
65
|
+
throw new InitQaUsageError(`unknown argument ${JSON.stringify(arg)}.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({ force, help });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function guideIds(toolingConfig) {
|
|
72
|
+
if (toolingConfig === null || typeof toolingConfig !== 'object' || Array.isArray(toolingConfig)) {
|
|
73
|
+
throw new TypeError('init-qa requires resolved book tooling configuration.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (toolingConfig.corpus == null) return Object.freeze(['book']);
|
|
77
|
+
const books = toolingConfig.corpus.books;
|
|
78
|
+
if (!Array.isArray(books) || books.length === 0) {
|
|
79
|
+
throw new TypeError('init-qa requires a non-empty resolved corpus book list.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ids = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const [index, book] of books.entries()) {
|
|
85
|
+
const id = book?.id;
|
|
86
|
+
if (typeof id !== 'string' || !BOOK_ID_PATTERN.test(id)) {
|
|
87
|
+
throw new TypeError(`init-qa received an invalid corpus book id at index ${index}.`);
|
|
88
|
+
}
|
|
89
|
+
if (seen.has(id)) {
|
|
90
|
+
throw new TypeError(`init-qa received duplicate corpus book id ${JSON.stringify(id)}.`);
|
|
91
|
+
}
|
|
92
|
+
seen.add(id);
|
|
93
|
+
ids.push(id);
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze(ids);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Render the exact, stable interoperability document for resolved config. */
|
|
99
|
+
export function renderGuideQa(toolingConfig) {
|
|
100
|
+
const ids = guideIds(toolingConfig);
|
|
101
|
+
const corpus = toolingConfig.corpus != null;
|
|
102
|
+
const lines = [GUIDE_QA_HEADER.trimEnd(), 'version: 1', 'guides:'];
|
|
103
|
+
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
lines.push(` - id: ${id}`);
|
|
106
|
+
const selector = corpus ? ` --book ${id}` : '';
|
|
107
|
+
lines.push(` check_cmd: ${COMMAND_PREFIX}${selector} --format json`);
|
|
108
|
+
}
|
|
109
|
+
return `${lines.join('\n')}\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function pathExists(path) {
|
|
113
|
+
try {
|
|
114
|
+
await access(path, fsConstants.F_OK);
|
|
115
|
+
return true;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error?.code === 'ENOENT') return false;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the consumer config and write only guide_qa.yaml.
|
|
124
|
+
*
|
|
125
|
+
* `loadConfig` is injectable so callers and tests can reuse this filesystem
|
|
126
|
+
* boundary without evaluating an Astro config more than once.
|
|
127
|
+
*/
|
|
128
|
+
export async function initGuideQa({
|
|
129
|
+
projectRoot = process.cwd(),
|
|
130
|
+
force = false,
|
|
131
|
+
loadConfig = loadResolvedBookConfig,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
134
|
+
throw new TypeError('init-qa projectRoot must be a non-empty string.');
|
|
135
|
+
}
|
|
136
|
+
if (typeof force !== 'boolean') {
|
|
137
|
+
throw new TypeError('init-qa force must be a boolean.');
|
|
138
|
+
}
|
|
139
|
+
if (typeof loadConfig !== 'function') {
|
|
140
|
+
throw new TypeError('init-qa loadConfig must be a function.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const root = resolve(projectRoot);
|
|
144
|
+
const outputPath = resolve(root, GUIDE_QA_FILENAME);
|
|
145
|
+
if (!force && (await pathExists(outputPath))) {
|
|
146
|
+
throw new GuideQaExistsError(outputPath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const toolingConfig = await loadConfig(root);
|
|
150
|
+
const contents = renderGuideQa(toolingConfig);
|
|
151
|
+
try {
|
|
152
|
+
await writeFile(outputPath, contents, {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
flag: force ? 'w' : 'wx',
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!force && error?.code === 'EEXIST') throw new GuideQaExistsError(outputPath);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
path: outputPath,
|
|
163
|
+
contents,
|
|
164
|
+
guideIds: guideIds(toolingConfig),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Run the command with injectable streams and return its contract exit code. */
|
|
169
|
+
export async function runInitQaCli({
|
|
170
|
+
argv = process.argv.slice(2),
|
|
171
|
+
projectRoot = process.cwd(),
|
|
172
|
+
stdout = process.stdout,
|
|
173
|
+
stderr = process.stderr,
|
|
174
|
+
loadConfig = loadResolvedBookConfig,
|
|
175
|
+
} = {}) {
|
|
176
|
+
let options;
|
|
177
|
+
try {
|
|
178
|
+
options = parseInitQaArgs(argv);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
181
|
+
stderr.write(`init-qa: ${detail}\n\n${INIT_QA_USAGE}`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (options.help) {
|
|
186
|
+
stdout.write(INIT_QA_USAGE);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const result = await initGuideQa({ projectRoot, force: options.force, loadConfig });
|
|
192
|
+
const label = relative(resolve(projectRoot), result.path) || GUIDE_QA_FILENAME;
|
|
193
|
+
stdout.write(`init-qa: wrote ${label}\n`);
|
|
194
|
+
return 0;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
197
|
+
if (error instanceof GuideQaExistsError || error?.code === 'EEXIST') {
|
|
198
|
+
stderr.write(`init-qa: ${detail}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
stderr.write(`init-qa: fatal: ${detail}\n`);
|
|
202
|
+
return 2;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
207
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
208
|
+
process.exitCode = await runInitQaCli();
|
|
209
|
+
}
|