@rblabs-dev/axiom 0.0.0-stage → 1.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.
@@ -0,0 +1,105 @@
1
+ // What every `axiom` command needs to know about the app it runs on (docs/design-system.md,
2
+ // section 14): the Angular project's folders, read from angular.json in the current directory,
3
+ // and the kit files shipped next to this CLI.
4
+
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ // The package root: dist/axiom (or node_modules/@rblabs-dev/axiom) when installed, projects/axiom
10
+ // in the Axiom workspace.
11
+ export const kitDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ export const kitInventoryPath = path.join(kitDir, 'material-symbols-icons.txt');
13
+ export const brandDir = path.join(kitDir, 'brand');
14
+
15
+ // Parses `--name value` pairs. Options listed in `repeatable` collect every value in an array.
16
+ export function parseOptions(argv, { repeatable = [] } = {}) {
17
+ const options = Object.fromEntries(repeatable.map((name) => [name, []]));
18
+ for (let index = 0; index < argv.length; index += 2) {
19
+ const flag = argv[index];
20
+ const value = argv[index + 1];
21
+ if (!flag.startsWith('--') || value === undefined || value.startsWith('--')) {
22
+ throw new Error(`Expected "--<option> <value>", got "${flag}".`);
23
+ }
24
+ const name = flag.slice(2);
25
+ if (repeatable.includes(name)) options[name].push(value);
26
+ else options[name] = value;
27
+ }
28
+ return options;
29
+ }
30
+
31
+ // The Angular application in ./angular.json: `--project <name>`, or the only application there.
32
+ export function resolveProject(projectName, cwd = process.cwd()) {
33
+ const angularPath = path.join(cwd, 'angular.json');
34
+ if (!existsSync(angularPath)) {
35
+ throw new Error(`No angular.json in ${cwd}. Run axiom from the folder that holds it.`);
36
+ }
37
+ const projects = JSON.parse(readFileSync(angularPath, 'utf8')).projects ?? {};
38
+ let name = projectName;
39
+ if (!name) {
40
+ const applications = Object.keys(projects).filter(
41
+ (key) => projects[key].projectType === 'application',
42
+ );
43
+ if (applications.length !== 1) {
44
+ throw new Error(
45
+ `angular.json has ${applications.length} applications; pass --project <name>.`,
46
+ );
47
+ }
48
+ [name] = applications;
49
+ }
50
+ const config = projects[name];
51
+ if (!config) throw new Error(`angular.json has no project "${name}".`);
52
+
53
+ const root = config.root ?? '';
54
+ const buildOptions = (config.architect ?? config.targets)?.build?.options ?? {};
55
+ const { outputPath } = buildOptions;
56
+ const outputBase =
57
+ typeof outputPath === 'string' ? outputPath : (outputPath?.base ?? `dist/${name}`);
58
+ const browser =
59
+ typeof outputPath === 'object' && outputPath?.browser !== undefined
60
+ ? outputPath.browser
61
+ : 'browser';
62
+ // The folder served at the site root, as in `{ "glob": "**/*", "input": "public" }`.
63
+ const publicAsset = (buildOptions.assets ?? []).find(
64
+ (asset) => typeof asset === 'object' && asset.glob === '**/*' && !asset.output,
65
+ );
66
+
67
+ return {
68
+ name,
69
+ rootDir: path.join(cwd, root),
70
+ sourceDir: path.join(cwd, config.sourceRoot ?? path.join(root, 'src')),
71
+ publicDir: path.join(cwd, publicAsset?.input ?? path.join(root, 'public')),
72
+ browserDir: path.join(cwd, outputBase, browser),
73
+ // The app's own icons; the kit's icons come from kitInventoryPath.
74
+ iconInventoryPath: path.join(cwd, root, 'scripts/material-symbols-icons.txt'),
75
+ allowlistPath: path.join(cwd, root, 'style-literals.allowlist.json'),
76
+ };
77
+ }
78
+
79
+ export function readInventory(file) {
80
+ const names = readFileSync(file, 'utf8')
81
+ .split(/\r?\n/u)
82
+ .map((line) => line.trim())
83
+ .filter(Boolean);
84
+ const invalid = names.filter((name) => !/^[a-z0-9_]+$/u.test(name));
85
+ if (invalid.length > 0) {
86
+ throw new Error(`Invalid icon names in ${file}: ${invalid.join(', ')}`);
87
+ }
88
+ if (new Set(names).size !== names.length) {
89
+ throw new Error(`${file} contains duplicate icon names.`);
90
+ }
91
+ return names;
92
+ }
93
+
94
+ // Every icon the app's font must hold: the kit's inventory plus the app's own, when it has one. An
95
+ // app may also list kit icons; they are counted once.
96
+ export function readIconInventories(project) {
97
+ const files = [kitInventoryPath];
98
+ if (existsSync(project.iconInventoryPath)) files.push(project.iconInventoryPath);
99
+ const names = new Set(files.flatMap((file) => readInventory(file)));
100
+ return { files, names: [...names].sort() };
101
+ }
102
+
103
+ export function relativeToCwd(file) {
104
+ return path.relative(process.cwd(), file) || '.';
105
+ }
@@ -0,0 +1,146 @@
1
+ // Post-build check of an app's self-hosted assets (docs/design-system.md, sections 10 and 12).
2
+ //
3
+ // axiom verify-production-assets [--project <name>] [--scan <dir>]...
4
+ // (npm run verify:production-assets, after the production build)
5
+ //
6
+ // Fails the build when:
7
+ //
8
+ // - the built index.html references Google Fonts;
9
+ // - the favicon is missing or not referenced;
10
+ // - the app's public/fonts/ does not hold exactly one Material Symbols subset, the built
11
+ // stylesheet does not reference it, its name's content hash does not match its bytes, or it
12
+ // exceeds 16 KB;
13
+ // - a template or component under the app's source root (src/), or under a `--scan` folder, uses
14
+ // an icon missing from both the kit's inventory and the app's scripts/material-symbols-icons.txt.
15
+ //
16
+ // Icon names are read from `icon="…"` attributes, `icon: '…'` properties and `<mat-icon>` content:
17
+ // a literal name, or the quoted results of a conditional or fallback in an interpolation
18
+ // (`{{ starred ? 'star' : 'star_border' }}`). Names computed any other way (a method call, a
19
+ // property) are not detected and must be added to the inventory by hand.
20
+
21
+ import { createHash } from 'node:crypto';
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ import { parseOptions, readIconInventories, relativeToCwd, resolveProject } from './project.mjs';
26
+
27
+ const fontPattern = /^material-symbols-outlined-.+\.woff2$/u;
28
+ const hashedFontPattern = /\.([0-9a-f]{8})\.woff2$/u;
29
+ const maxFontBytes = 16_000;
30
+
31
+ const iconNamePattern = /^[a-z][a-z0-9_]*$/u;
32
+ const iconAttributePatterns = [/icon="([a-z][a-z0-9_]*)"/gu, /icon:\s*'([a-z][a-z0-9_]*)'/gu];
33
+ // Prettier may split the closing tag of a long element across lines (`</mat-icon\n>`).
34
+ const matIconPattern = /<mat-icon\b[^>]*>([^<]*)<\/mat-icon\s*>/gu;
35
+ const interpolationPattern = /\{\{([\s\S]*?)\}\}/gu;
36
+ // A quoted name that is a result of `?:`, `??` or `||`: it follows one of those operators and ends
37
+ // the expression or a ternary branch. Function arguments such as `sortIcon('status')` are skipped.
38
+ const resultNamePattern = /(?:\?\??|:|\|\|)\s*(['"])([a-z][a-z0-9_]*)\1(?=\s*(?::|$))/gu;
39
+
40
+ function fail(message) {
41
+ throw new Error(message);
42
+ }
43
+
44
+ function listFiles(directory, predicate) {
45
+ const files = [];
46
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
47
+ const entryPath = path.join(directory, entry.name);
48
+ if (entry.isDirectory()) files.push(...listFiles(entryPath, predicate));
49
+ else if (entry.isFile() && predicate(entry.name)) files.push(entryPath);
50
+ }
51
+ return files;
52
+ }
53
+
54
+ // Icon names used by one template or component source.
55
+ export function iconNamesInSource(source) {
56
+ const names = new Set();
57
+ for (const pattern of iconAttributePatterns) {
58
+ for (const match of source.matchAll(pattern)) names.add(match[1]);
59
+ }
60
+ for (const [, content] of source.matchAll(matIconPattern)) {
61
+ const text = content.trim();
62
+ if (iconNamePattern.test(text)) names.add(text);
63
+ for (const [, interpolation] of text.matchAll(interpolationPattern)) {
64
+ // Pipe arguments (`value | pipe: 'arg'`) are not results; drop everything after a pipe.
65
+ const [expression] = interpolation.split(/(?<!\|)\|(?!\|)/u);
66
+ for (const match of expression.trim().matchAll(resultNamePattern)) names.add(match[2]);
67
+ }
68
+ }
69
+ return names;
70
+ }
71
+
72
+ function usedIconNames(directories) {
73
+ const names = new Set();
74
+ const sources = directories.flatMap((directory) =>
75
+ listFiles(directory, (name) => /\.(html|ts)$/u.test(name) && !name.endsWith('.spec.ts')),
76
+ );
77
+ for (const file of sources) {
78
+ for (const name of iconNamesInSource(readFileSync(file, 'utf8'))) names.add(name);
79
+ }
80
+ return names;
81
+ }
82
+
83
+ export function run(argv) {
84
+ const options = parseOptions(argv, { repeatable: ['scan'] });
85
+ const project = resolveProject(options.project);
86
+ const { browserDir } = project;
87
+ const indexPath = path.join(browserDir, 'index.html');
88
+ if (!existsSync(indexPath))
89
+ fail(`Missing production index: ${relativeToCwd(indexPath)}; build the app first.`);
90
+ const indexHtml = readFileSync(indexPath, 'utf8');
91
+
92
+ for (const host of ['fonts.googleapis.com', 'fonts.gstatic.com']) {
93
+ if (indexHtml.includes(host)) fail(`Production index still references ${host}.`);
94
+ }
95
+
96
+ if (!indexHtml.includes('favicon.svg')) fail('Production index does not reference favicon.svg.');
97
+ if (!existsSync(path.join(browserDir, 'favicon.svg')))
98
+ fail(`Missing favicon asset in ${relativeToCwd(browserDir)}.`);
99
+
100
+ const publicFontsDir = path.join(project.publicDir, 'fonts');
101
+ if (!existsSync(publicFontsDir))
102
+ fail(`Missing ${relativeToCwd(publicFontsDir)}; run axiom build-icon-font.`);
103
+ const fonts = readdirSync(publicFontsDir).filter((name) => fontPattern.test(name));
104
+ if (fonts.length !== 1) {
105
+ fail(
106
+ `Expected exactly one Material Symbols subset in ${relativeToCwd(publicFontsDir)}, found ${fonts.length}: ${fonts.join(', ')}`,
107
+ );
108
+ }
109
+ const [fontName] = fonts;
110
+ const fontPath = path.join(browserDir, 'fonts', fontName);
111
+ if (!existsSync(fontPath)) fail(`Missing self-hosted icon font: ${relativeToCwd(fontPath)}`);
112
+
113
+ const fontBytes = readFileSync(fontPath);
114
+ const declaredHash = hashedFontPattern.exec(fontName)?.[1];
115
+ if (declaredHash) {
116
+ const actualHash = createHash('sha256').update(fontBytes).digest('hex').slice(0, 8);
117
+ if (actualHash !== declaredHash) {
118
+ fail(
119
+ `${fontName} was edited after it was built (content hash ${actualHash}). Rebuild it with axiom build-icon-font.`,
120
+ );
121
+ }
122
+ }
123
+ if (fontBytes.length > maxFontBytes) fail(`Icon font is too large: ${fontBytes.length} bytes.`);
124
+
125
+ const stylesheets = readdirSync(browserDir).filter((name) => /^styles-[^/]+\.css$/u.test(name));
126
+ if (stylesheets.length === 0) fail('Missing hashed production stylesheet.');
127
+ const referenced = stylesheets.some((name) =>
128
+ readFileSync(path.join(browserDir, name), 'utf8').includes(`/fonts/${fontName}`),
129
+ );
130
+ if (!referenced) fail(`Production stylesheet does not reference /fonts/${fontName}.`);
131
+
132
+ const inventory = new Set(readIconInventories(project).names);
133
+ const scanned = [project.sourceDir, ...options.scan.map((directory) => path.resolve(directory))];
134
+ const missing = [...usedIconNames(scanned)].filter((name) => !inventory.has(name));
135
+ if (missing.length > 0) {
136
+ fail(
137
+ `Material Symbols missing from the inventory: ${missing.join(', ')}. Add them to ` +
138
+ `${relativeToCwd(project.iconInventoryPath)} and run axiom build-icon-font.`,
139
+ );
140
+ }
141
+
142
+ console.log(
143
+ `Production asset verification passed: favicon present, ${inventory.size} icon names tracked, ` +
144
+ `icon font ${fontName} is ${statSync(fontPath).size} bytes.`,
145
+ );
146
+ }