@elia97/officina 0.1.0 → 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/README.md +18 -2
- package/dist/bin.js +18 -5
- package/dist/checks/doctor.js +15 -0
- package/dist/gen/collection.mjs +76 -0
- package/dist/gen/component.mjs +41 -0
- package/dist/gen/identifier.mjs +52 -0
- package/dist/gen/inject-config.mjs +73 -0
- package/dist/gen/inject-section.mjs +140 -0
- package/dist/gen/inject-strings.mjs +56 -0
- package/dist/gen/page.mjs +68 -0
- package/dist/gen/plopfile.mjs +30 -0
- package/dist/gen/post-gen.mjs +16 -0
- package/dist/gen/run.js +17 -0
- package/dist/gen/section-targets.mjs +46 -0
- package/dist/gen/section.mjs +68 -0
- package/dist/gen/sectioned-collection.mjs +81 -0
- package/dist/gen/templates/collection/data.yml.hbs +2 -0
- package/dist/gen/templates/collection/doc.md.hbs +8 -0
- package/dist/gen/templates/collection/schema.ts.hbs +11 -0
- package/dist/gen/templates/component/component.astro.hbs +21 -0
- package/dist/gen/templates/page/dynamic.astro.hbs +24 -0
- package/dist/gen/templates/page/static.astro.hbs +20 -0
- package/dist/gen/templates/section/component.astro.hbs +45 -0
- package/dist/gen/templates/section/content.yml.hbs +10 -0
- package/dist/gen/templates/section/schema.ts.hbs +20 -0
- package/dist/gen/templates/sections/data-layer.ts.hbs +13 -0
- package/dist/gen/templates/sections/index.ts.hbs +12 -0
- package/dist/gen/templates/sections/page.astro.hbs +30 -0
- package/dist/gen/ts-morph-utils.mjs +8 -0
- package/dist/lib/contract.js +59 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -18,7 +18,13 @@ pnpm add -D @elia97/officina
|
|
|
18
18
|
"check:routes": "officina check routes",
|
|
19
19
|
"check:roadmap": "officina check roadmap",
|
|
20
20
|
"check:placeholders": "officina check placeholders",
|
|
21
|
-
"check:vercel-cli": "officina check vercel-cli"
|
|
21
|
+
"check:vercel-cli": "officina check vercel-cli",
|
|
22
|
+
"gen": "officina gen",
|
|
23
|
+
"gen:section": "officina gen section",
|
|
24
|
+
"gen:page": "officina gen page",
|
|
25
|
+
"gen:component": "officina gen component",
|
|
26
|
+
"gen:collection": "officina gen collection",
|
|
27
|
+
"doctor": "officina doctor"
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
```
|
|
@@ -43,6 +49,16 @@ pnpm version <patch|minor|major>
|
|
|
43
49
|
pnpm publish # prepublishOnly lancia typecheck, test e build
|
|
44
50
|
```
|
|
45
51
|
|
|
52
|
+
## Generatori
|
|
53
|
+
|
|
54
|
+
`officina gen` avvolge plop con i generatori e i template del pacchetto, e scrive nel progetto da cui lo lanci: `section`, `page`, `component`, `collection`. `plop` e `ts-morph` sono dipendenze del pacchetto, non del progetto.
|
|
55
|
+
|
|
56
|
+
I generatori scrivono codice che deve incastrarsi nello scaffold, quindi il progetto deve avere i punti di aggancio che si aspettano: i moduli che il codice generato importa e i file in cui iniettano. L'elenco è `src/lib/contract.ts`, e `officina doctor` dice, in qualunque repository, cosa manca. Finché manca qualcosa il pre-volo del generatore si ferma prima di scrivere un solo file.
|
|
57
|
+
|
|
58
|
+
Un progetto aggiunge i propri generatori con un file `officina.generators.mjs` nella radice, con la firma di un plopfile: `export default function (plop)`.
|
|
59
|
+
|
|
60
|
+
I test dei generatori girano sui punti di aggancio del progetto di prova, copiati da `vetrina`. Che un progetto vero li abbia ancora lo verifica `doctor`, non i test.
|
|
61
|
+
|
|
46
62
|
## Cosa arriverà
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
Bundle budget, smoke di produzione, lighthouse, verifica analytics e icone. Poi i preset di `ecommerce` e `monorepo`.
|
package/dist/bin.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
// Un modulo per gate, caricato solo quando serve: `officina check routes` non paga il costo
|
|
4
4
|
// degli altri cinque.
|
|
5
|
-
const CHECKS = {
|
|
5
|
+
export const CHECKS = {
|
|
6
6
|
comments: () => import('./checks/comments.js'),
|
|
7
7
|
language: () => import('./checks/language.js'),
|
|
8
8
|
placeholders: () => import('./checks/placeholders.js'),
|
|
@@ -10,16 +10,29 @@ const CHECKS = {
|
|
|
10
10
|
routes: () => import('./checks/routes.js'),
|
|
11
11
|
'vercel-cli': () => import('./checks/vercel-cli.js'),
|
|
12
12
|
};
|
|
13
|
-
const USAGE = `Uso:
|
|
13
|
+
const USAGE = `Uso:
|
|
14
|
+
officina check <gate> [opzioni] gate: ${Object.keys(CHECKS).join(', ')}
|
|
15
|
+
officina gen [generatore] section, page, component, collection, più quelli del progetto
|
|
16
|
+
officina doctor cosa manca al progetto perché i generatori funzionino
|
|
17
|
+
|
|
18
|
+
Opzioni dei gate: --diff, --base <ref>, --head <ref>, --strict, --format text|github
|
|
19
|
+
`;
|
|
20
|
+
// Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
|
|
21
|
+
const GROUPS = {
|
|
22
|
+
check: ([name = '', ...rest]) => ({ load: CHECKS[name], args: rest }),
|
|
23
|
+
gen: (args) => ({ load: () => import('./gen/run.js'), args }),
|
|
24
|
+
doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
|
|
25
|
+
};
|
|
14
26
|
export async function run(argv) {
|
|
15
|
-
const [group
|
|
16
|
-
const load = group
|
|
27
|
+
const [group = '', ...rest] = argv;
|
|
28
|
+
const { load, args } = GROUPS[group]?.(rest) ?? { load: undefined, args: [] };
|
|
17
29
|
if (!load) {
|
|
18
30
|
console.error(USAGE);
|
|
19
31
|
return 2;
|
|
20
32
|
}
|
|
21
33
|
const { main } = await load();
|
|
22
|
-
return main(
|
|
34
|
+
return main(args);
|
|
23
35
|
}
|
|
36
|
+
/* v8 ignore next -- eseguito solo quando il file è il punto d'ingresso del processo */
|
|
24
37
|
if (import.meta.main)
|
|
25
38
|
process.exit(await run(process.argv.slice(2)));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
4
|
+
import { CONTRACT, contractGaps } from '../lib/contract.js';
|
|
5
|
+
export function main() {
|
|
6
|
+
const findings = contractGaps(process.cwd()).map((gap) => ({ ...gap, severity: 'error' }));
|
|
7
|
+
console.log(`\nofficina doctor — ${CONTRACT.length} punti di aggancio dei generatori\n`);
|
|
8
|
+
printFindings(findings, cliOptions([]).format);
|
|
9
|
+
console.log(findings.length === 0
|
|
10
|
+
? ' Il progetto ha tutto quello che i generatori si aspettano.\n'
|
|
11
|
+
: `\n ${findings.length} su ${CONTRACT.length} da sistemare prima che \`officina gen\` scriva codice che compila.\n`);
|
|
12
|
+
return exitCode(findings, false);
|
|
13
|
+
}
|
|
14
|
+
if (import.meta.main)
|
|
15
|
+
process.exit(main());
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isValidIdentifier } from './identifier.mjs';
|
|
2
|
+
import { assertInjectable, injectCollection } from './inject-config.mjs';
|
|
3
|
+
import { postGenAction } from './post-gen.mjs';
|
|
4
|
+
import { sectionedCollectionActions, sectionedCollectionPrompts } from './sectioned-collection.mjs';
|
|
5
|
+
function flatCollectionActions(root, answers) {
|
|
6
|
+
const tpl = 'templates/collection';
|
|
7
|
+
return [
|
|
8
|
+
(a, _config, api) => {
|
|
9
|
+
assertInjectable({
|
|
10
|
+
root,
|
|
11
|
+
camel: api.getHelper('camelCase')(a.name),
|
|
12
|
+
kebab: api.getHelper('dashCase')(a.name),
|
|
13
|
+
});
|
|
14
|
+
return 'content.config.ts contract checks passed';
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: 'add',
|
|
18
|
+
path: 'src/lib/schemas/{{dashCase name}}.ts',
|
|
19
|
+
templateFile: `${tpl}/schema.ts.hbs`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: 'add',
|
|
23
|
+
path: answers.document ? 'src/content/{{dashCase name}}/example.md' : 'src/content/{{dashCase name}}/example.yml',
|
|
24
|
+
templateFile: answers.document ? `${tpl}/doc.md.hbs` : `${tpl}/data.yml.hbs`,
|
|
25
|
+
},
|
|
26
|
+
(a, _config, api) => {
|
|
27
|
+
injectCollection({
|
|
28
|
+
root,
|
|
29
|
+
camel: api.getHelper('camelCase')(a.name),
|
|
30
|
+
kebab: api.getHelper('dashCase')(a.name),
|
|
31
|
+
document: a.document,
|
|
32
|
+
});
|
|
33
|
+
return `injected collection in content.config.ts: ${a.name}`;
|
|
34
|
+
},
|
|
35
|
+
postGenAction(root),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export default function collectionGenerator(plop) {
|
|
39
|
+
const root = process.cwd();
|
|
40
|
+
plop.setGenerator('collection', {
|
|
41
|
+
description: 'New content collection (YAML data, MD documents, or a page built from sections)',
|
|
42
|
+
prompts: [
|
|
43
|
+
{
|
|
44
|
+
type: 'input',
|
|
45
|
+
name: 'name',
|
|
46
|
+
message: 'Collection name (e.g. services, blog):',
|
|
47
|
+
validate: (value) => {
|
|
48
|
+
const camel = plop.getHelper('camelCase')(String(value));
|
|
49
|
+
if (!camel)
|
|
50
|
+
return 'Collection name is required';
|
|
51
|
+
if (!isValidIdentifier(camel)) {
|
|
52
|
+
return `"${value}" would generate an invalid identifier (const ${camel}) — use ASCII letters/digits, starting with a letter, not a JS reserved word`;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: 'confirm',
|
|
59
|
+
name: 'sections',
|
|
60
|
+
message: 'A page built from sections, like the homepage?',
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: 'confirm',
|
|
65
|
+
name: 'document',
|
|
66
|
+
message: 'MD documents with a renderable body? (No = YAML data)',
|
|
67
|
+
default: false,
|
|
68
|
+
when: (answers) => answers.sections !== true,
|
|
69
|
+
},
|
|
70
|
+
...sectionedCollectionPrompts(plop),
|
|
71
|
+
],
|
|
72
|
+
actions: (answers) => answers.sections === true
|
|
73
|
+
? sectionedCollectionActions(plop, answers, root)
|
|
74
|
+
: flatCollectionActions(root, answers),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { postGenAction } from './post-gen.mjs';
|
|
2
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
3
|
+
export default function componentGenerator(plop) {
|
|
4
|
+
const root = process.cwd();
|
|
5
|
+
const tpl = 'templates/component';
|
|
6
|
+
plop.setGenerator('component', {
|
|
7
|
+
description: 'New native Astro component (cva + cn recipe)',
|
|
8
|
+
prompts: [
|
|
9
|
+
{
|
|
10
|
+
type: 'input',
|
|
11
|
+
name: 'name',
|
|
12
|
+
message: 'Component name (e.g. PriceCard):',
|
|
13
|
+
validate: (value) => {
|
|
14
|
+
const camel = plop.getHelper('camelCase')(String(value));
|
|
15
|
+
if (!camel)
|
|
16
|
+
return 'Component name is required';
|
|
17
|
+
if (!IDENTIFIER.test(camel)) {
|
|
18
|
+
return `"${value}" would generate an invalid identifier (${camel}Variants) — use ASCII letters/digits, starting with a letter`;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: 'input',
|
|
25
|
+
name: 'area',
|
|
26
|
+
message: 'Folder under src/components/ (e.g. ui, marketing):',
|
|
27
|
+
default: 'ui',
|
|
28
|
+
validate: (value) => plop.getHelper('dashCase')(String(value)) !== '' ||
|
|
29
|
+
'Area folder must contain at least one letter or digit (e.g. ui)',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
actions: [
|
|
33
|
+
{
|
|
34
|
+
type: 'add',
|
|
35
|
+
path: 'src/components/{{dashCase area}}/{{dashCase name}}.astro',
|
|
36
|
+
templateFile: `${tpl}/component.astro.hbs`,
|
|
37
|
+
},
|
|
38
|
+
postGenAction(root),
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
2
|
+
const RESERVED = new Set([
|
|
3
|
+
'await',
|
|
4
|
+
'break',
|
|
5
|
+
'case',
|
|
6
|
+
'catch',
|
|
7
|
+
'class',
|
|
8
|
+
'const',
|
|
9
|
+
'continue',
|
|
10
|
+
'debugger',
|
|
11
|
+
'default',
|
|
12
|
+
'delete',
|
|
13
|
+
'do',
|
|
14
|
+
'else',
|
|
15
|
+
'enum',
|
|
16
|
+
'export',
|
|
17
|
+
'extends',
|
|
18
|
+
'false',
|
|
19
|
+
'finally',
|
|
20
|
+
'for',
|
|
21
|
+
'function',
|
|
22
|
+
'if',
|
|
23
|
+
'implements',
|
|
24
|
+
'import',
|
|
25
|
+
'in',
|
|
26
|
+
'instanceof',
|
|
27
|
+
'interface',
|
|
28
|
+
'let',
|
|
29
|
+
'new',
|
|
30
|
+
'null',
|
|
31
|
+
'package',
|
|
32
|
+
'private',
|
|
33
|
+
'protected',
|
|
34
|
+
'public',
|
|
35
|
+
'return',
|
|
36
|
+
'static',
|
|
37
|
+
'super',
|
|
38
|
+
'switch',
|
|
39
|
+
'this',
|
|
40
|
+
'throw',
|
|
41
|
+
'true',
|
|
42
|
+
'try',
|
|
43
|
+
'typeof',
|
|
44
|
+
'var',
|
|
45
|
+
'void',
|
|
46
|
+
'while',
|
|
47
|
+
'with',
|
|
48
|
+
'yield',
|
|
49
|
+
]);
|
|
50
|
+
export function isValidIdentifier(camel) {
|
|
51
|
+
return IDENTIFIER.test(camel) && !RESERVED.has(camel);
|
|
52
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { isNameTaken } from './ts-morph-utils.mjs';
|
|
3
|
+
const CONFIG_PATH = 'src/content.config.ts';
|
|
4
|
+
function loadConfig(root) {
|
|
5
|
+
const project = new Project();
|
|
6
|
+
return project.addSourceFileAtPath(`${root}/${CONFIG_PATH}`);
|
|
7
|
+
}
|
|
8
|
+
function locateContract(cfg, { camel, kebab }) {
|
|
9
|
+
const statement = cfg.getVariableStatement((s) => s.getDeclarations().some((d) => d.getName() === 'collections'));
|
|
10
|
+
if (!statement) {
|
|
11
|
+
throw new Error(`gen:collection injection failed: no \`collections\` variable in ${CONFIG_PATH}. ` +
|
|
12
|
+
`The contract expects \`export const collections = { … }\` — if it was renamed, ` +
|
|
13
|
+
`restore it or update scripts/gen/inject-config.mjs (see docs/guides/content-collections.md).`);
|
|
14
|
+
}
|
|
15
|
+
if (!statement.hasExportKeyword()) {
|
|
16
|
+
throw new Error(`gen:collection injection failed: \`collections\` in ${CONFIG_PATH} is not exported. ` +
|
|
17
|
+
`Astro silently ignores a non-exported collections object — restore \`export const collections\`.`);
|
|
18
|
+
}
|
|
19
|
+
const object = cfg
|
|
20
|
+
.getVariableDeclarationOrThrow('collections')
|
|
21
|
+
.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
22
|
+
if (!object) {
|
|
23
|
+
throw new Error(`gen:collection injection failed: \`collections\` in ${CONFIG_PATH} is not initialized ` +
|
|
24
|
+
`with an object literal, so there is no place to register the new collection.`);
|
|
25
|
+
}
|
|
26
|
+
if (object.getProperty(camel) || object.getProperty(`'${kebab}'`)) {
|
|
27
|
+
throw new Error(`gen:collection injection failed: a collection named "${kebab}" is already registered ` +
|
|
28
|
+
`in ${CONFIG_PATH}. Pick another name, or remove the existing entry first.`);
|
|
29
|
+
}
|
|
30
|
+
if (isNameTaken(cfg, camel)) {
|
|
31
|
+
throw new Error(`gen:collection injection failed: the identifier \`${camel}\` is already taken in ` +
|
|
32
|
+
`${CONFIG_PATH} (import or variable) — the injected const would shadow it. Pick another name.`);
|
|
33
|
+
}
|
|
34
|
+
return { statement, object };
|
|
35
|
+
}
|
|
36
|
+
export function assertInjectable({ root, camel, kebab }) {
|
|
37
|
+
locateContract(loadConfig(root), { camel, kebab });
|
|
38
|
+
}
|
|
39
|
+
export function injectCollection({ root, camel, kebab, document, sections = false }) {
|
|
40
|
+
const cfg = loadConfig(root);
|
|
41
|
+
const { statement, object } = locateContract(cfg, { camel, kebab });
|
|
42
|
+
const schema = sections ? `${camel}CollectionSchema` : `${camel}Schema`;
|
|
43
|
+
if (!cfg.getImportDeclaration((d) => d.getModuleSpecifierValue() === `@/lib/schemas/${kebab}`)) {
|
|
44
|
+
cfg.addImportDeclaration({
|
|
45
|
+
moduleSpecifier: `@/lib/schemas/${kebab}`,
|
|
46
|
+
namedImports: [schema],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const pattern = document ? '**/*.md' : '**/*.{yaml,yml}';
|
|
50
|
+
const loaderId = document
|
|
51
|
+
? ` // Default generateId on purpose: this id becomes the route slug, so it\n` +
|
|
52
|
+
` // should slugify segments and honor a frontmatter \`slug\`.\n`
|
|
53
|
+
: ` // Raw relative path minus extension, like the homepage collection: the\n` +
|
|
54
|
+
` // default generateId slugifies segments (en-US → en-us), honors a top-level\n` +
|
|
55
|
+
` // \`slug\` key before zod strips it, and drops a trailing /index — all three\n` +
|
|
56
|
+
` // break reading entries back by locale folder.\n` +
|
|
57
|
+
` generateId: ({ entry }) => entry.replace(/\\.(yaml|yml)$/, ''),\n`;
|
|
58
|
+
cfg.insertStatements(statement.getChildIndex(), `const ${camel} = defineCollection({\n` +
|
|
59
|
+
` loader: glob({\n` +
|
|
60
|
+
` pattern: '${pattern}',\n` +
|
|
61
|
+
` base: './src/content/${kebab}',\n` +
|
|
62
|
+
loaderId +
|
|
63
|
+
` }),\n` +
|
|
64
|
+
` schema: ${schema},\n` +
|
|
65
|
+
`})\n`);
|
|
66
|
+
if (kebab.includes('-')) {
|
|
67
|
+
object.addPropertyAssignment({ name: `'${kebab}'`, initializer: camel });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
object.addShorthandPropertyAssignment({ name: camel });
|
|
71
|
+
}
|
|
72
|
+
cfg.saveSync();
|
|
73
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// L'inserimento va SOPRA ogni marcatore: sotto, l'organizeImports di Biome adotta il marcatore come
|
|
2
|
+
// trivia iniziale del nuovo import e lo sposta nel blocco ordinato. (.astro non è leggibile da ts-morph.)
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
5
|
+
import { findSectionedPages, sectionFiles, sectionTargets } from './section-targets.mjs';
|
|
6
|
+
import { isNameTaken } from './ts-morph-utils.mjs';
|
|
7
|
+
const GUIDE = 'docs/guides/content-collections.md';
|
|
8
|
+
function fail(where, problem) {
|
|
9
|
+
throw new Error(`gen:section injection failed in ${where}: ${problem} (contract: ${GUIDE})`);
|
|
10
|
+
}
|
|
11
|
+
function addSource(project, root, path, missing) {
|
|
12
|
+
if (!existsSync(`${root}/${path}`))
|
|
13
|
+
fail(path, missing);
|
|
14
|
+
return project.addSourceFileAtPath(`${root}/${path}`);
|
|
15
|
+
}
|
|
16
|
+
function locateUnionArray(project, root, targets, collection) {
|
|
17
|
+
const created = 'a sectioned collection is created by `pnpm gen:collection`';
|
|
18
|
+
const barrel = addSource(project, root, targets.barrel, `the "${collection}" collection has no schema barrel — ${created}`);
|
|
19
|
+
const fn = barrel.getFunction(targets.schemaFunction);
|
|
20
|
+
if (!fn) {
|
|
21
|
+
fail(targets.barrel, `no \`${targets.schemaFunction}\` function — was it renamed?`);
|
|
22
|
+
}
|
|
23
|
+
const call = fn
|
|
24
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
25
|
+
.find((c) => c.getExpression().getText() === 'z.discriminatedUnion');
|
|
26
|
+
if (!call) {
|
|
27
|
+
fail(targets.barrel, `no \`z.discriminatedUnion(…)\` call inside ${targets.schemaFunction}`);
|
|
28
|
+
}
|
|
29
|
+
const union = call.getArguments()[1]?.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
30
|
+
if (!union) {
|
|
31
|
+
fail(targets.barrel, 'the second argument of z.discriminatedUnion is not an array literal');
|
|
32
|
+
}
|
|
33
|
+
return { barrel, fn, union };
|
|
34
|
+
}
|
|
35
|
+
const inUnion = (union, camel) => union.getElements().some((element) => element.getText().startsWith(`${camel}SectionSchema(`));
|
|
36
|
+
function locateReturnObject(project, root, targets, collection) {
|
|
37
|
+
const created = 'a sectioned collection is created by `pnpm gen:collection`';
|
|
38
|
+
const layer = addSource(project, root, targets.dataLayer, `the "${collection}" collection has no data layer — ${created}`);
|
|
39
|
+
const fn = layer.getFunction(targets.dataFunction);
|
|
40
|
+
if (!fn) {
|
|
41
|
+
fail(targets.dataLayer, `no \`${targets.dataFunction}\` function — was it renamed?`);
|
|
42
|
+
}
|
|
43
|
+
const ret = fn
|
|
44
|
+
.getBody()
|
|
45
|
+
?.getStatements()
|
|
46
|
+
.findLast((s) => s.isKind(SyntaxKind.ReturnStatement));
|
|
47
|
+
const obj = ret?.getExpression()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
48
|
+
if (!obj) {
|
|
49
|
+
fail(targets.dataLayer, `${targets.dataFunction} has no top-level \`return { … }\` object literal to register the pick() in`);
|
|
50
|
+
}
|
|
51
|
+
return obj;
|
|
52
|
+
}
|
|
53
|
+
function readSectionedPage(root, targets) {
|
|
54
|
+
const pages = findSectionedPages(root, targets.sectionsMarker);
|
|
55
|
+
if (pages.length === 0) {
|
|
56
|
+
fail('src/pages/', `no page carries the \`${targets.sectionsMarker}\` marker — the component has no anchor`);
|
|
57
|
+
}
|
|
58
|
+
if (pages.length > 1) {
|
|
59
|
+
fail('src/pages/', `${pages.join(' and ')} all carry the \`${targets.sectionsMarker}\` marker — keep it on one page`);
|
|
60
|
+
}
|
|
61
|
+
const [page] = pages;
|
|
62
|
+
const src = readFileSync(`${root}/${page}`, 'utf8');
|
|
63
|
+
if (!src.includes(targets.importsMarker)) {
|
|
64
|
+
fail(page, `the \`${targets.importsMarker}\` marker is missing — the import has no anchor`);
|
|
65
|
+
}
|
|
66
|
+
return { page, src };
|
|
67
|
+
}
|
|
68
|
+
function assertContextForwardable(fn, targets) {
|
|
69
|
+
const [first] = fn.getParameters();
|
|
70
|
+
if (first && !Node.isIdentifier(first.getNameNode())) {
|
|
71
|
+
fail(targets.barrel, `${targets.schemaFunction} destructures its parameter — name it \`context: SchemaContext\`, so an image section can receive it`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function assertSectionInjectable({ root, collection, camel, kebab, pascal, image = false }) {
|
|
75
|
+
const targets = sectionTargets(collection);
|
|
76
|
+
const project = new Project();
|
|
77
|
+
const { barrel, fn, union } = locateUnionArray(project, root, targets, collection.kebab);
|
|
78
|
+
if (inUnion(union, camel)) {
|
|
79
|
+
fail(targets.barrel, `section "${camel}" is already in the union — pick another name`);
|
|
80
|
+
}
|
|
81
|
+
if (image)
|
|
82
|
+
assertContextForwardable(fn, targets);
|
|
83
|
+
if (isNameTaken(barrel, `${camel}SectionSchema`)) {
|
|
84
|
+
fail(targets.barrel, `the identifier \`${camel}SectionSchema\` is already taken — the injected import would collide. Pick another name`);
|
|
85
|
+
}
|
|
86
|
+
const obj = locateReturnObject(project, root, targets, collection.kebab);
|
|
87
|
+
if (obj.getProperty(camel)) {
|
|
88
|
+
fail(targets.dataLayer, `section "${camel}" is already picked in ${targets.dataFunction}`);
|
|
89
|
+
}
|
|
90
|
+
const { page, src } = readSectionedPage(root, targets);
|
|
91
|
+
const frontmatter = src.split('---')[1] ?? '';
|
|
92
|
+
if (new RegExp(`\\b${pascal}\\b`).test(frontmatter)) {
|
|
93
|
+
fail(page, `the identifier \`${pascal}\` is already used in the frontmatter — the component import would collide. Pick another name`);
|
|
94
|
+
}
|
|
95
|
+
for (const target of sectionFiles(targets, kebab)) {
|
|
96
|
+
if (existsSync(`${root}/${target}`)) {
|
|
97
|
+
fail(target, 'the file already exists — remove it first or pick another name');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function contextArgument(barrel, fn) {
|
|
102
|
+
const [first] = fn.getParameters();
|
|
103
|
+
if (first)
|
|
104
|
+
return first.getName();
|
|
105
|
+
fn.addParameter({ name: 'context', type: 'SchemaContext' });
|
|
106
|
+
if (!isNameTaken(barrel, 'SchemaContext')) {
|
|
107
|
+
barrel.addImportDeclaration({ moduleSpecifier: 'astro:content', namedImports: ['SchemaContext'], isTypeOnly: true });
|
|
108
|
+
}
|
|
109
|
+
return 'context';
|
|
110
|
+
}
|
|
111
|
+
export function injectSection({ root, collection, camel, kebab, pascal, image = false }) {
|
|
112
|
+
const targets = sectionTargets(collection);
|
|
113
|
+
const project = new Project();
|
|
114
|
+
const { barrel, fn, union } = locateUnionArray(project, root, targets, collection.kebab);
|
|
115
|
+
if (!barrel.getImportDeclaration((d) => d.getModuleSpecifierValue() === `./${kebab}`)) {
|
|
116
|
+
barrel.addImportDeclaration({
|
|
117
|
+
moduleSpecifier: `./${kebab}`,
|
|
118
|
+
namedImports: [`${camel}SectionSchema`],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (!inUnion(union, camel)) {
|
|
122
|
+
union.addElement(`${camel}SectionSchema(${image ? contextArgument(barrel, fn) : ''})`);
|
|
123
|
+
}
|
|
124
|
+
const obj = locateReturnObject(project, root, targets, collection.kebab);
|
|
125
|
+
if (!obj.getProperty(camel)) {
|
|
126
|
+
obj.addPropertyAssignment({ name: camel, initializer: `pick('${camel}')` });
|
|
127
|
+
}
|
|
128
|
+
project.saveSync();
|
|
129
|
+
const { page, src: original } = readSectionedPage(root, targets);
|
|
130
|
+
let src = original;
|
|
131
|
+
const imp = `import ${pascal} from '@/components/${collection.kebab}/${kebab}.astro'`;
|
|
132
|
+
if (!src.includes(imp)) {
|
|
133
|
+
src = src.replace(targets.importsMarker, `${imp}\n${targets.importsMarker}`);
|
|
134
|
+
}
|
|
135
|
+
const use = `<${pascal} {...content.${camel}} />`;
|
|
136
|
+
if (!src.includes(use)) {
|
|
137
|
+
src = src.replace(targets.sectionsMarker, `${use}\n ${targets.sectionsMarker}`);
|
|
138
|
+
}
|
|
139
|
+
writeFileSync(`${root}/${page}`, src);
|
|
140
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
4
|
+
const DICTIONARIES = 'src/i18n/strings';
|
|
5
|
+
const GUIDE = 'docs/guides/content-collections.md';
|
|
6
|
+
function fail(where, problem) {
|
|
7
|
+
throw new Error(`gen:page injection failed in ${where}: ${problem} (contract: ${GUIDE})`);
|
|
8
|
+
}
|
|
9
|
+
function locateObject(file, path, locale) {
|
|
10
|
+
const declaration = file.getVariableDeclaration(locale);
|
|
11
|
+
if (!declaration) {
|
|
12
|
+
fail(path, `no \`${locale}\` constant — the dictionary must export \`const ${locale} = { … } as const\``);
|
|
13
|
+
}
|
|
14
|
+
if (!declaration.isExported()) {
|
|
15
|
+
fail(path, `\`${locale}\` is not exported, so src/i18n/ui.ts cannot register it`);
|
|
16
|
+
}
|
|
17
|
+
const object = declaration
|
|
18
|
+
.getInitializerIfKind(SyntaxKind.AsExpression)
|
|
19
|
+
?.getExpression()
|
|
20
|
+
.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
21
|
+
if (!object) {
|
|
22
|
+
fail(path, `\`${locale}\` is not an object literal with \`as const\`, so there is no place for the new keys`);
|
|
23
|
+
}
|
|
24
|
+
return object;
|
|
25
|
+
}
|
|
26
|
+
function loadDictionaries(root) {
|
|
27
|
+
const names = readdirSync(join(root, DICTIONARIES))
|
|
28
|
+
.filter((name) => name.endsWith('.ts'))
|
|
29
|
+
.sort();
|
|
30
|
+
if (names.length === 0)
|
|
31
|
+
fail(DICTIONARIES, 'no dictionary to register the page strings in');
|
|
32
|
+
const project = new Project();
|
|
33
|
+
const dictionaries = names.map((name) => {
|
|
34
|
+
const path = `${DICTIONARIES}/${name}`;
|
|
35
|
+
const file = project.addSourceFileAtPath(join(root, path));
|
|
36
|
+
return { path, object: locateObject(file, path, name.slice(0, -3)) };
|
|
37
|
+
});
|
|
38
|
+
return { project, dictionaries };
|
|
39
|
+
}
|
|
40
|
+
export function assertStringsInjectable({ root, keys }) {
|
|
41
|
+
for (const { path, object } of loadDictionaries(root).dictionaries) {
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
if (object.getProperty(`'${key}'`))
|
|
44
|
+
fail(path, `the key '${key}' is already there — pick another page path`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function injectStrings({ root, entries }) {
|
|
49
|
+
const { project, dictionaries } = loadDictionaries(root);
|
|
50
|
+
for (const { object } of dictionaries) {
|
|
51
|
+
for (const { key, value } of entries) {
|
|
52
|
+
object.addPropertyAssignment({ name: `'${key}'`, initializer: `'${value}'` });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
project.saveSync();
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { assertStringsInjectable, injectStrings } from './inject-strings.mjs';
|
|
4
|
+
import { postGenAction } from './post-gen.mjs';
|
|
5
|
+
const ROLLBACK_HINT = 'gen:page also MODIFIED every dictionary in src/i18n/strings/. Review them with `git diff`, then discard ONLY ' +
|
|
6
|
+
'the injected page.* keys with `git checkout -p` on those paths, and delete the generated page. A re-run ' +
|
|
7
|
+
'without rollback fails pre-flight with "is already there".';
|
|
8
|
+
export function pathSegments(plop, value) {
|
|
9
|
+
const dash = plop.getHelper('dashCase');
|
|
10
|
+
return String(value)
|
|
11
|
+
.split('/')
|
|
12
|
+
.map((segment) => dash(segment.trim()))
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
export function pageStrings({ pageKey, pageTitle, dynamic }) {
|
|
16
|
+
const title = { key: `page.${pageKey}.title`, value: pageTitle };
|
|
17
|
+
return dynamic ? [title] : [title, { key: `page.${pageKey}.description`, value: '<PAGE_DESCRIPTION>' }];
|
|
18
|
+
}
|
|
19
|
+
export default function pageGenerator(plop) {
|
|
20
|
+
const root = process.cwd();
|
|
21
|
+
const tpl = 'templates/page';
|
|
22
|
+
plop.setGenerator('page', {
|
|
23
|
+
description: 'New Astro page (static, or dynamic [slug] with getStaticPaths)',
|
|
24
|
+
prompts: [
|
|
25
|
+
{
|
|
26
|
+
type: 'input',
|
|
27
|
+
name: 'name',
|
|
28
|
+
message: 'Page path (e.g. about-us, or nested like legal/privacy):',
|
|
29
|
+
// change-case toglie la punteggiatura, quindi '...' non è vuoto ma in dash-case diventa ''.
|
|
30
|
+
validate: (value) => pathSegments(plop, value).length > 0 || 'Page path must contain at least one letter or digit',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'confirm',
|
|
34
|
+
name: 'dynamic',
|
|
35
|
+
message: 'Dynamic [slug] route with getStaticPaths?',
|
|
36
|
+
default: false,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
actions: (answers) => {
|
|
40
|
+
const segments = pathSegments(plop, answers.name);
|
|
41
|
+
const pagePath = segments.join('/');
|
|
42
|
+
answers.pagePath = pagePath;
|
|
43
|
+
answers.pageTitle = plop.getHelper('sentenceCase')(segments.at(-1));
|
|
44
|
+
answers.pageKey = plop.getHelper('camelCase')(pagePath);
|
|
45
|
+
const target = answers.dynamic ? `src/pages/${pagePath}/[slug].astro` : `src/pages/${pagePath}.astro`;
|
|
46
|
+
const strings = pageStrings(answers);
|
|
47
|
+
return [
|
|
48
|
+
() => {
|
|
49
|
+
if (existsSync(join(root, target))) {
|
|
50
|
+
throw new Error(`gen:page pre-flight failed: ${target} already exists — remove it or pick another path`);
|
|
51
|
+
}
|
|
52
|
+
assertStringsInjectable({ root, keys: strings.map(({ key }) => key) });
|
|
53
|
+
return 'page path and dictionary contract checks passed';
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'add',
|
|
57
|
+
path: target,
|
|
58
|
+
templateFile: answers.dynamic ? `${tpl}/dynamic.astro.hbs` : `${tpl}/static.astro.hbs`,
|
|
59
|
+
},
|
|
60
|
+
() => {
|
|
61
|
+
injectStrings({ root, entries: strings });
|
|
62
|
+
return `injected ${strings.map(({ key }) => key).join(', ')}`;
|
|
63
|
+
},
|
|
64
|
+
postGenAction(root, ROLLBACK_HINT),
|
|
65
|
+
];
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import collectionGenerator from './collection.mjs';
|
|
14
|
+
import componentGenerator from './component.mjs';
|
|
15
|
+
import pageGenerator from './page.mjs';
|
|
16
|
+
import sectionGenerator from './section.mjs';
|
|
17
|
+
// I generatori propri di un progetto stanno qui, nella sua radice, e si aggiungono a quelli del
|
|
18
|
+
// pacchetto: stessa firma di un plopfile, `export default function (plop)`.
|
|
19
|
+
export const PROJECT_GENERATORS = 'officina.generators.mjs';
|
|
20
|
+
export default async function (plop) {
|
|
21
|
+
sectionGenerator(plop);
|
|
22
|
+
pageGenerator(plop);
|
|
23
|
+
componentGenerator(plop);
|
|
24
|
+
collectionGenerator(plop);
|
|
25
|
+
const extension = join(process.cwd(), PROJECT_GENERATORS);
|
|
26
|
+
if (!existsSync(extension))
|
|
27
|
+
return;
|
|
28
|
+
const { default: projectGenerators } = await import(__rewriteRelativeImportExtension(pathToFileURL(extension).href));
|
|
29
|
+
await projectGenerators(plop);
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// `pnpm run check` è un `biome check --write .` su tutto il repo, quindi riformatta anche file sporchi
|
|
2
|
+
// che non c'entrano: i suggerimenti di recupero non devono mai ricorrere a un `git checkout` di file intero.
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
export function postGenAction(root, recoveryHint) {
|
|
5
|
+
return () => {
|
|
6
|
+
try {
|
|
7
|
+
execSync('pnpm exec astro sync', { cwd: root, stdio: 'inherit' });
|
|
8
|
+
execSync('pnpm run check', { cwd: root, stdio: 'inherit' });
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
throw new Error(`Post-generation checks failed. ${recoveryHint ??
|
|
12
|
+
'The generated files were left on disk — inspect, then fix or delete them before re-running (a re-run against leftovers fails with "File already exists").'}`, { cause: error });
|
|
13
|
+
}
|
|
14
|
+
return 'astro sync + biome check passed';
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/gen/run.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
// plop risolve i template dalla cartella del plopfile e scrive in `--dest`: il plopfile sta nel
|
|
7
|
+
// pacchetto, la destinazione è il progetto da cui si lancia il comando.
|
|
8
|
+
export function plopCommand(args, cwd) {
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const plopBin = join(dirname(require.resolve('plop/package.json')), 'bin', 'plop.js');
|
|
11
|
+
const plopfile = fileURLToPath(new URL('./plopfile.mjs', import.meta.url));
|
|
12
|
+
return [plopBin, '--plopfile', plopfile, '--dest', cwd, ...args];
|
|
13
|
+
}
|
|
14
|
+
export function main(args = []) {
|
|
15
|
+
const result = spawnSync(process.execPath, plopCommand(args, process.cwd()), { stdio: 'inherit' });
|
|
16
|
+
return result.status ?? 1;
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { isValidIdentifier } from './identifier.mjs';
|
|
4
|
+
export function casings(plop, value) {
|
|
5
|
+
return {
|
|
6
|
+
camel: plop.getHelper('camelCase')(value),
|
|
7
|
+
kebab: plop.getHelper('dashCase')(value),
|
|
8
|
+
pascal: plop.getHelper('pascalCase')(value),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function sectionTargets({ camel, kebab, pascal }) {
|
|
12
|
+
return {
|
|
13
|
+
barrel: `src/lib/schemas/${kebab}/index.ts`,
|
|
14
|
+
schemaFunction: `${camel}CollectionSchema`,
|
|
15
|
+
dataLayer: `src/lib/${kebab}.ts`,
|
|
16
|
+
dataFunction: `get${pascal}Sections`,
|
|
17
|
+
schemaDir: `src/lib/schemas/${kebab}`,
|
|
18
|
+
contentDir: `src/content/${kebab}`,
|
|
19
|
+
componentDir: `src/components/${kebab}`,
|
|
20
|
+
importsMarker: `// @gen:${kebab}-imports`,
|
|
21
|
+
sectionsMarker: `{/* @gen:${kebab}-sections */}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export const sectionFiles = (targets, kebab) => [
|
|
25
|
+
`${targets.schemaDir}/${kebab}.ts`,
|
|
26
|
+
`${targets.contentDir}/${kebab}.yml`,
|
|
27
|
+
`${targets.componentDir}/${kebab}.astro`,
|
|
28
|
+
];
|
|
29
|
+
export function findSectionedPages(root, marker) {
|
|
30
|
+
return readdirSync(join(root, 'src/pages'), { recursive: true, withFileTypes: true })
|
|
31
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.astro'))
|
|
32
|
+
.map((entry) => relative(root, join(entry.parentPath, entry.name)))
|
|
33
|
+
.filter((page) => readFileSync(join(root, page), 'utf8').includes(marker))
|
|
34
|
+
.sort();
|
|
35
|
+
}
|
|
36
|
+
export function validateSectionName(plop) {
|
|
37
|
+
return (value) => {
|
|
38
|
+
const camel = plop.getHelper('camelCase')(String(value));
|
|
39
|
+
if (!camel)
|
|
40
|
+
return 'Section name is required';
|
|
41
|
+
if (!isValidIdentifier(camel)) {
|
|
42
|
+
return `"${value}" would generate an invalid identifier (${camel}SectionSchema) — use ASCII letters/digits, starting with a letter, not a JS reserved word`;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isValidIdentifier } from './identifier.mjs';
|
|
2
|
+
import { assertSectionInjectable, injectSection } from './inject-section.mjs';
|
|
3
|
+
import { postGenAction } from './post-gen.mjs';
|
|
4
|
+
import { casings, sectionFiles, sectionTargets, validateSectionName } from './section-targets.mjs';
|
|
5
|
+
function rollbackHint(targets) {
|
|
6
|
+
return (`gen:section also MODIFIED three existing files: ${targets.barrel}, ${targets.dataLayer} and the page ` +
|
|
7
|
+
`that carries ${targets.sectionsMarker}. Review them with \`git diff\`, then discard ONLY the injected hunks ` +
|
|
8
|
+
'with `git checkout -p` on those paths. Then delete the generated schema/yml/component files. A re-run ' +
|
|
9
|
+
'without rollback fails pre-flight with "already in the union".');
|
|
10
|
+
}
|
|
11
|
+
export default function sectionGenerator(plop) {
|
|
12
|
+
const root = process.cwd();
|
|
13
|
+
const tpl = 'templates/section';
|
|
14
|
+
plop.setGenerator('section', {
|
|
15
|
+
description: 'New section of a sectioned page (Zod schema + YAML + component + injection)',
|
|
16
|
+
prompts: [
|
|
17
|
+
{
|
|
18
|
+
type: 'input',
|
|
19
|
+
name: 'collection',
|
|
20
|
+
message: 'Sectioned collection (e.g. homepage):',
|
|
21
|
+
default: 'homepage',
|
|
22
|
+
validate: (value) => {
|
|
23
|
+
const camel = plop.getHelper('camelCase')(String(value));
|
|
24
|
+
if (!camel)
|
|
25
|
+
return 'Collection name is required';
|
|
26
|
+
if (!isValidIdentifier(camel)) {
|
|
27
|
+
return `"${value}" would generate an invalid identifier (${camel}CollectionSchema) — use ASCII letters/digits, starting with a letter, not a JS reserved word`;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'input',
|
|
34
|
+
name: 'name',
|
|
35
|
+
message: 'Section name (e.g. features):',
|
|
36
|
+
validate: validateSectionName(plop),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'confirm',
|
|
40
|
+
name: 'image',
|
|
41
|
+
message: 'Does the section carry an image? (its content starts on src/assets/placeholder.jpg)',
|
|
42
|
+
default: false,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
actions: (answers) => {
|
|
46
|
+
const collection = casings(plop, answers.collection);
|
|
47
|
+
const section = casings(plop, answers.name);
|
|
48
|
+
const targets = sectionTargets(collection);
|
|
49
|
+
const request = { root, collection, ...section, image: answers.image === true };
|
|
50
|
+
const [schema, content, component] = sectionFiles(targets, section.kebab);
|
|
51
|
+
const data = { section: answers.name };
|
|
52
|
+
return [
|
|
53
|
+
() => {
|
|
54
|
+
assertSectionInjectable(request);
|
|
55
|
+
return 'hook-point contract checks passed';
|
|
56
|
+
},
|
|
57
|
+
{ type: 'add', path: schema, templateFile: `${tpl}/schema.ts.hbs`, data },
|
|
58
|
+
{ type: 'add', path: content, templateFile: `${tpl}/content.yml.hbs`, data },
|
|
59
|
+
{ type: 'add', path: component, templateFile: `${tpl}/component.astro.hbs`, data },
|
|
60
|
+
() => {
|
|
61
|
+
injectSection(request);
|
|
62
|
+
return `injected union + pick + page: ${section.pascal}`;
|
|
63
|
+
},
|
|
64
|
+
postGenAction(root, rollbackHint(targets)),
|
|
65
|
+
];
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { assertInjectable, injectCollection } from './inject-config.mjs';
|
|
4
|
+
import { assertStringsInjectable, injectStrings } from './inject-strings.mjs';
|
|
5
|
+
import { pageStrings, pathSegments } from './page.mjs';
|
|
6
|
+
import { postGenAction } from './post-gen.mjs';
|
|
7
|
+
import { casings, findSectionedPages, sectionFiles, sectionTargets, validateSectionName } from './section-targets.mjs';
|
|
8
|
+
const SECTIONS = 'templates/sections';
|
|
9
|
+
const SECTION = 'templates/section';
|
|
10
|
+
const ROLLBACK_HINT = 'gen:collection also MODIFIED src/content.config.ts and every dictionary in src/i18n/strings/. Review them ' +
|
|
11
|
+
'with `git diff`, then discard ONLY the injected hunks with `git checkout -p` on those paths, and delete the ' +
|
|
12
|
+
'generated barrel, data layer, page and section files. A re-run without rollback fails pre-flight with ' +
|
|
13
|
+
'"already exists".';
|
|
14
|
+
const forSections = (answers) => answers.sections === true;
|
|
15
|
+
export function sectionedCollectionPrompts(plop) {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
type: 'input',
|
|
19
|
+
name: 'page',
|
|
20
|
+
message: 'Page path of the sectioned page (e.g. about-us):',
|
|
21
|
+
when: forSections,
|
|
22
|
+
validate: (value) => pathSegments(plop, value).length > 0 || 'Page path must contain at least one letter or digit',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'input',
|
|
26
|
+
name: 'section',
|
|
27
|
+
message: 'First section name (e.g. intro):',
|
|
28
|
+
when: forSections,
|
|
29
|
+
validate: validateSectionName(plop),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
type: 'confirm',
|
|
33
|
+
name: 'image',
|
|
34
|
+
message: 'Does the first section carry an image?',
|
|
35
|
+
default: false,
|
|
36
|
+
when: forSections,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
function assertCreatable({ root, targets, page, section, strings }) {
|
|
41
|
+
for (const path of [page, targets.barrel, targets.dataLayer, ...sectionFiles(targets, section.kebab)]) {
|
|
42
|
+
if (existsSync(join(root, path))) {
|
|
43
|
+
throw new Error(`gen:collection pre-flight failed: ${path} already exists — remove it or pick another name`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const carriers = findSectionedPages(root, targets.sectionsMarker);
|
|
47
|
+
if (carriers.length > 0) {
|
|
48
|
+
throw new Error(`gen:collection pre-flight failed: ${carriers.join(', ')} already carries ${targets.sectionsMarker}`);
|
|
49
|
+
}
|
|
50
|
+
assertStringsInjectable({ root, keys: strings.map(({ key }) => key) });
|
|
51
|
+
}
|
|
52
|
+
export function sectionedCollectionActions(plop, answers, root) {
|
|
53
|
+
const collection = casings(plop, answers.name);
|
|
54
|
+
const section = casings(plop, answers.section);
|
|
55
|
+
const targets = sectionTargets(collection);
|
|
56
|
+
const segments = pathSegments(plop, answers.page);
|
|
57
|
+
const pageKey = plop.getHelper('camelCase')(segments.join('/'));
|
|
58
|
+
const page = `src/pages/${segments.join('/')}.astro`;
|
|
59
|
+
const strings = pageStrings({ pageKey, pageTitle: plop.getHelper('sentenceCase')(segments.at(-1)), dynamic: false });
|
|
60
|
+
const data = { collection: answers.name, pageKey };
|
|
61
|
+
const [schema, content, component] = sectionFiles(targets, section.kebab);
|
|
62
|
+
return [
|
|
63
|
+
() => {
|
|
64
|
+
assertInjectable({ root, camel: collection.camel, kebab: collection.kebab });
|
|
65
|
+
assertCreatable({ root, targets, page, section, strings });
|
|
66
|
+
return 'content.config.ts, page and dictionary contract checks passed';
|
|
67
|
+
},
|
|
68
|
+
{ type: 'add', path: targets.barrel, templateFile: `${SECTIONS}/index.ts.hbs`, data },
|
|
69
|
+
{ type: 'add', path: targets.dataLayer, templateFile: `${SECTIONS}/data-layer.ts.hbs`, data },
|
|
70
|
+
{ type: 'add', path: page, templateFile: `${SECTIONS}/page.astro.hbs`, data },
|
|
71
|
+
{ type: 'add', path: schema, templateFile: `${SECTION}/schema.ts.hbs`, data },
|
|
72
|
+
{ type: 'add', path: content, templateFile: `${SECTION}/content.yml.hbs`, data },
|
|
73
|
+
{ type: 'add', path: component, templateFile: `${SECTION}/component.astro.hbs`, data },
|
|
74
|
+
() => {
|
|
75
|
+
injectCollection({ root, camel: collection.camel, kebab: collection.kebab, sections: true });
|
|
76
|
+
injectStrings({ root, entries: strings });
|
|
77
|
+
return `injected sectioned collection ${collection.kebab} and ${strings.map(({ key }) => key).join(', ')}`;
|
|
78
|
+
},
|
|
79
|
+
postGenAction(root, ROLLBACK_HINT),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
3
|
+
export const {{camelCase name}}Schema = z.strictObject({
|
|
4
|
+
title: z.string().min(1),
|
|
5
|
+
description: z.string().optional(),
|
|
6
|
+
{{#if document}}
|
|
7
|
+
pubDate: z.coerce.date(),
|
|
8
|
+
draft: z.boolean().default(false),
|
|
9
|
+
{{/if}}
|
|
10
|
+
})
|
|
11
|
+
export type {{pascalCase name}}Entry = z.infer<typeof {{camelCase name}}Schema>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
export const {{camelCase name}}Variants = cva('', {
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: '',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { variant: 'default' },
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'div'>, VariantProps<typeof {{camelCase name}}Variants> {}
|
|
17
|
+
|
|
18
|
+
const { variant, class: className, ...rest } = Astro.props
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<div class={cn({{camelCase name}}Variants({ variant }), className)} {...rest}><slot /></div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Container from '@/components/ui/container.astro'
|
|
3
|
+
import Heading from '@/components/ui/heading.astro'
|
|
4
|
+
import Section from '@/components/ui/section.astro'
|
|
5
|
+
import MainLayout from '@/layouts/main.astro'
|
|
6
|
+
|
|
7
|
+
import { useTranslations } from '@/i18n/translate'
|
|
8
|
+
|
|
9
|
+
export function getStaticPaths() {
|
|
10
|
+
// TODO: ricava i percorsi da una collection, come nel blueprint content-section del plugin metodo.
|
|
11
|
+
return [{ params: { slug: 'example' } }]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const t = useTranslations(Astro.currentLocale)
|
|
15
|
+
const { slug } = Astro.params
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<MainLayout title={slug ?? t('page.{{pageKey}}.title')}>
|
|
19
|
+
<Section>
|
|
20
|
+
<Container>
|
|
21
|
+
<Heading as="h1" size="h1">{slug}</Heading>
|
|
22
|
+
</Container>
|
|
23
|
+
</Section>
|
|
24
|
+
</MainLayout>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Container from '@/components/ui/container.astro'
|
|
3
|
+
import Heading from '@/components/ui/heading.astro'
|
|
4
|
+
import Section from '@/components/ui/section.astro'
|
|
5
|
+
import MainLayout from '@/layouts/main.astro'
|
|
6
|
+
|
|
7
|
+
import { useTranslations } from '@/i18n/translate'
|
|
8
|
+
|
|
9
|
+
const t = useTranslations(Astro.currentLocale)
|
|
10
|
+
const title = t('page.{{pageKey}}.title')
|
|
11
|
+
const description = t('page.{{pageKey}}.description')
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<MainLayout {title} {description}>
|
|
15
|
+
<Section>
|
|
16
|
+
<Container>
|
|
17
|
+
<Heading as="h1" size="h1">{title}</Heading>
|
|
18
|
+
</Container>
|
|
19
|
+
</Section>
|
|
20
|
+
</MainLayout>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
{{#if image}}
|
|
3
|
+
import { Image } from 'astro:assets'
|
|
4
|
+
|
|
5
|
+
{{/if}}
|
|
6
|
+
import Button from '@/components/ui/button.astro'
|
|
7
|
+
import Container from '@/components/ui/container.astro'
|
|
8
|
+
import Heading from '@/components/ui/heading.astro'
|
|
9
|
+
import Section from '@/components/ui/section.astro'
|
|
10
|
+
import type { {{pascalCase collection}}Sections } from '@/lib/{{dashCase collection}}'
|
|
11
|
+
|
|
12
|
+
import { ctaHref } from '@/i18n/href'
|
|
13
|
+
|
|
14
|
+
type Props = {{pascalCase collection}}Sections['{{camelCase section}}']
|
|
15
|
+
|
|
16
|
+
const { title, body, {{#if image}}image, {{/if}}buttons } = Astro.props
|
|
17
|
+
const href = (url: string) => ctaHref(Astro.currentLocale, url)
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<Section class="border-b border-border bg-background">
|
|
21
|
+
<Container>
|
|
22
|
+
<Heading as="h2">{title}</Heading>
|
|
23
|
+
{body && <p class="mt-4 max-w-2xl text-pretty text-muted-foreground">{body}</p>}
|
|
24
|
+
{{#if image}}
|
|
25
|
+
<Image
|
|
26
|
+
src={image.src}
|
|
27
|
+
alt={image.alt}
|
|
28
|
+
widths={[640, 960, 1280, 1920]}
|
|
29
|
+
sizes="(min-width: 96rem) 96rem, 100vw"
|
|
30
|
+
class="mt-8 h-auto w-full rounded-lg"
|
|
31
|
+
/>
|
|
32
|
+
{{/if}}
|
|
33
|
+
{
|
|
34
|
+
buttons.length > 0 && (
|
|
35
|
+
<div class="mt-8 flex flex-wrap gap-4">
|
|
36
|
+
{buttons.map((button, index) => (
|
|
37
|
+
<Button as="a" href={href(button.url)} variant={index === 0 ? 'default' : 'outline'}>
|
|
38
|
+
{button.label}
|
|
39
|
+
</Button>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
</Container>
|
|
45
|
+
</Section>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
section: {{camelCase section}}
|
|
2
|
+
title: Titolo della sezione
|
|
3
|
+
body: >-
|
|
4
|
+
Contenuto placeholder della sezione. Sostituiscilo col copy reale del progetto.
|
|
5
|
+
{{#if image}}
|
|
6
|
+
image:
|
|
7
|
+
src: ../../assets/placeholder.jpg
|
|
8
|
+
alt: Descrizione dell'immagine
|
|
9
|
+
{{/if}}
|
|
10
|
+
buttons: []
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{{#if image}}
|
|
2
|
+
import type { SchemaContext } from 'astro:content'
|
|
3
|
+
{{/if}}
|
|
4
|
+
import { z } from 'astro/zod'
|
|
5
|
+
|
|
6
|
+
import { ctaSchema{{#if image}}, imageSchema{{/if}} } from '../common'
|
|
7
|
+
|
|
8
|
+
export function {{camelCase section}}SectionSchema({{#if image}}{ image }: SchemaContext{{/if}}) {
|
|
9
|
+
return z.strictObject({
|
|
10
|
+
section: z.literal('{{camelCase section}}'),
|
|
11
|
+
title: z.string().min(1),
|
|
12
|
+
body: z.string().optional(),
|
|
13
|
+
{{#if image}}
|
|
14
|
+
image: imageSchema(image),
|
|
15
|
+
{{/if}}
|
|
16
|
+
buttons: z.array(ctaSchema).default([]),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type {{pascalCase section}}Section = z.infer<ReturnType<typeof {{camelCase section}}SectionSchema>>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { loadLocalizedSections, type SectionedPage } from '@/lib/content/localized-sections'
|
|
2
|
+
|
|
3
|
+
export type {{pascalCase collection}}Sections = SectionedPage<'{{dashCase collection}}'>
|
|
4
|
+
|
|
5
|
+
export async function get{{pascalCase collection}}Sections(locale?: string): Promise<{{pascalCase collection}}Sections> {
|
|
6
|
+
const { pick } = await loadLocalizedSections('{{dashCase collection}}', locale)
|
|
7
|
+
|
|
8
|
+
// INJECTION POINT per `pnpm gen:section` (ts-morph): le sezioni nuove aggiungono qui il
|
|
9
|
+
// loro pick() — il generatore verifica questo letterale di ritorno.
|
|
10
|
+
return {
|
|
11
|
+
{{camelCase section}}: pick('{{camelCase section}}'),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{{#if image}}
|
|
2
|
+
import type { SchemaContext } from 'astro:content'
|
|
3
|
+
{{/if}}
|
|
4
|
+
import { z } from 'astro/zod'
|
|
5
|
+
|
|
6
|
+
import { {{camelCase section}}SectionSchema } from './{{dashCase section}}'
|
|
7
|
+
|
|
8
|
+
// INJECTION POINT per `pnpm gen:section` (ts-morph): il generatore verifica questa funzione
|
|
9
|
+
// e la sua chiamata a z.discriminatedUnion.
|
|
10
|
+
export function {{camelCase collection}}CollectionSchema({{#if image}}context: SchemaContext{{/if}}) {
|
|
11
|
+
return z.discriminatedUnion('section', [{{camelCase section}}SectionSchema({{#if image}}context{{/if}})])
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {{pascalCase section}} from '@/components/{{dashCase collection}}/{{dashCase section}}.astro'
|
|
3
|
+
import Container from '@/components/ui/container.astro'
|
|
4
|
+
import Heading from '@/components/ui/heading.astro'
|
|
5
|
+
import Section from '@/components/ui/section.astro'
|
|
6
|
+
|
|
7
|
+
import MainLayout from '@/layouts/main.astro'
|
|
8
|
+
|
|
9
|
+
import { get{{pascalCase collection}}Sections } from '@/lib/{{dashCase collection}}'
|
|
10
|
+
|
|
11
|
+
import { useTranslations } from '@/i18n/translate'
|
|
12
|
+
|
|
13
|
+
// @gen:{{dashCase collection}}-imports (i generatori inseriscono i nuovi import SOPRA questa riga)
|
|
14
|
+
|
|
15
|
+
const t = useTranslations(Astro.currentLocale)
|
|
16
|
+
const title = t('page.{{pageKey}}.title')
|
|
17
|
+
const description = t('page.{{pageKey}}.description')
|
|
18
|
+
const content = await get{{pascalCase collection}}Sections(Astro.currentLocale)
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<MainLayout {title} {description}>
|
|
22
|
+
<Section>
|
|
23
|
+
<Container>
|
|
24
|
+
<Heading as="h1" size="h1">{title}</Heading>
|
|
25
|
+
</Container>
|
|
26
|
+
</Section>
|
|
27
|
+
{{!-- Handlebars legge "}}}" come chiusura di un'espressione non escapata: il tilde chiude il mustache prima della graffa di Astro. --}}
|
|
28
|
+
<{{pascalCase section}} {...content.{{camelCase section~}}} />
|
|
29
|
+
{/* @gen:{{dashCase collection}}-sections */}
|
|
30
|
+
</MainLayout>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function isNameTaken(sourceFile, name) {
|
|
2
|
+
if (sourceFile.getVariableDeclaration(name) || sourceFile.getFunction(name))
|
|
3
|
+
return true;
|
|
4
|
+
return sourceFile
|
|
5
|
+
.getImportDeclarations()
|
|
6
|
+
.some((d) => d.getNamedImports().some((n) => (n.getAliasNode()?.getText() ?? n.getName()) === name) ||
|
|
7
|
+
d.getDefaultImport()?.getText() === name);
|
|
8
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const CONTRACT = [
|
|
4
|
+
{ kind: 'file', path: 'src/components/ui/section.astro', reason: 'le sezioni generate la importano' },
|
|
5
|
+
{ kind: 'file', path: 'src/components/ui/container.astro', reason: 'le sezioni e le pagine generate lo importano' },
|
|
6
|
+
{ kind: 'file', path: 'src/components/ui/heading.astro', reason: 'le sezioni e le pagine generate lo importano' },
|
|
7
|
+
{ kind: 'file', path: 'src/components/ui/button.astro', reason: 'le sezioni generate lo importano' },
|
|
8
|
+
{ kind: 'file', path: 'src/layouts/main.astro', reason: 'le pagine generate lo usano come layout' },
|
|
9
|
+
{ kind: 'file', path: 'src/i18n/translate.ts', reason: 'le pagine generate traducono titolo e descrizione' },
|
|
10
|
+
{ kind: 'file', path: 'src/i18n/href.ts', reason: 'le sezioni generate localizzano i link delle CTA' },
|
|
11
|
+
{ kind: 'file', path: 'src/i18n/ui.ts', reason: 'gen:page registra lì le chiavi della pagina' },
|
|
12
|
+
{
|
|
13
|
+
kind: 'directory',
|
|
14
|
+
path: 'src/i18n/strings',
|
|
15
|
+
extension: '.ts',
|
|
16
|
+
reason: 'gen:page scrive le stringhe in ogni dizionario',
|
|
17
|
+
},
|
|
18
|
+
{ kind: 'file', path: 'src/lib/utils.ts', reason: 'i componenti generati usano cn()' },
|
|
19
|
+
{
|
|
20
|
+
kind: 'file',
|
|
21
|
+
path: 'src/lib/content/localized-sections.ts',
|
|
22
|
+
reason: 'lo strato dati di una pagina a sezioni lo usa',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
kind: 'directory',
|
|
26
|
+
path: 'src/lib/schemas',
|
|
27
|
+
extension: '.ts',
|
|
28
|
+
reason: 'gen:collection e gen:section scrivono lì gli schemi',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: 'anchor',
|
|
32
|
+
path: 'src/content.config.ts',
|
|
33
|
+
text: 'export const collections',
|
|
34
|
+
reason: 'gen:collection e gen:section registrano lì la collection',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
function hasFileWith(directory, extension) {
|
|
38
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory())
|
|
39
|
+
return false;
|
|
40
|
+
return readdirSync(directory, { recursive: true }).some((entry) => String(entry).endsWith(extension));
|
|
41
|
+
}
|
|
42
|
+
function gapFor(root, requirement) {
|
|
43
|
+
const target = join(root, requirement.path);
|
|
44
|
+
if (requirement.kind === 'directory') {
|
|
45
|
+
return hasFileWith(target, requirement.extension) ? undefined : `manca, o è vuota: ${requirement.reason}`;
|
|
46
|
+
}
|
|
47
|
+
if (!existsSync(target))
|
|
48
|
+
return `manca: ${requirement.reason}`;
|
|
49
|
+
if (requirement.kind === 'anchor' && !readFileSync(target, 'utf8').includes(requirement.text)) {
|
|
50
|
+
return `manca l'ancoraggio \`${requirement.text}\`: ${requirement.reason}`;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
export function contractGaps(root) {
|
|
55
|
+
return CONTRACT.flatMap((requirement) => {
|
|
56
|
+
const message = gapFor(root, requirement);
|
|
57
|
+
return message === undefined ? [] : [{ path: requirement.path, message }];
|
|
58
|
+
});
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elia97/officina",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità e, più avanti, generatori.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
"typescript": "^7.0.2",
|
|
30
30
|
"vitest": "^4.1.11"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"plop": "^4.0.5",
|
|
34
|
+
"ts-morph": "^28.0.0"
|
|
35
|
+
},
|
|
32
36
|
"scripts": {
|
|
33
|
-
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates",
|
|
34
38
|
"typecheck": "tsc --noEmit",
|
|
35
39
|
"test": "vitest run --coverage",
|
|
36
40
|
"format": "biome check --write .",
|