@elia97/officina 0.1.0 → 0.3.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.
Files changed (96) hide show
  1. package/README.md +57 -3
  2. package/dist/bin.d.ts +8 -0
  3. package/dist/bin.js +28 -6
  4. package/dist/checks/analytics.d.ts +2 -0
  5. package/dist/checks/analytics.js +48 -0
  6. package/dist/checks/bundle.d.ts +2 -0
  7. package/dist/checks/bundle.js +98 -0
  8. package/dist/checks/comments.d.ts +2 -0
  9. package/dist/checks/doctor.d.ts +2 -0
  10. package/dist/checks/doctor.js +15 -0
  11. package/dist/checks/language.d.ts +2 -0
  12. package/dist/checks/lighthouse.d.ts +2 -0
  13. package/dist/checks/lighthouse.js +37 -0
  14. package/dist/checks/placeholders.d.ts +2 -0
  15. package/dist/checks/roadmap.d.ts +2 -0
  16. package/dist/checks/routes.d.ts +2 -0
  17. package/dist/checks/smoke.d.ts +2 -0
  18. package/dist/checks/smoke.js +57 -0
  19. package/dist/checks/vercel-cli.d.ts +2 -0
  20. package/dist/gen/collection.d.mts +1 -0
  21. package/dist/gen/collection.mjs +76 -0
  22. package/dist/gen/component.d.mts +1 -0
  23. package/dist/gen/component.mjs +41 -0
  24. package/dist/gen/icons.d.ts +2 -0
  25. package/dist/gen/icons.js +27 -0
  26. package/dist/gen/identifier.d.mts +1 -0
  27. package/dist/gen/identifier.mjs +52 -0
  28. package/dist/gen/inject-config.d.mts +12 -0
  29. package/dist/gen/inject-config.mjs +73 -0
  30. package/dist/gen/inject-section.d.mts +16 -0
  31. package/dist/gen/inject-section.mjs +140 -0
  32. package/dist/gen/inject-strings.d.mts +8 -0
  33. package/dist/gen/inject-strings.mjs +56 -0
  34. package/dist/gen/page.d.mts +10 -0
  35. package/dist/gen/page.mjs +68 -0
  36. package/dist/gen/plopfile.d.mts +2 -0
  37. package/dist/gen/plopfile.mjs +30 -0
  38. package/dist/gen/post-gen.d.mts +1 -0
  39. package/dist/gen/post-gen.mjs +16 -0
  40. package/dist/gen/run.d.ts +2 -0
  41. package/dist/gen/run.js +17 -0
  42. package/dist/gen/section-targets.d.mts +23 -0
  43. package/dist/gen/section-targets.mjs +46 -0
  44. package/dist/gen/section.d.mts +1 -0
  45. package/dist/gen/section.mjs +68 -0
  46. package/dist/gen/sectioned-collection.d.mts +26 -0
  47. package/dist/gen/sectioned-collection.mjs +81 -0
  48. package/dist/gen/templates/collection/data.yml.hbs +2 -0
  49. package/dist/gen/templates/collection/doc.md.hbs +8 -0
  50. package/dist/gen/templates/collection/schema.ts.hbs +11 -0
  51. package/dist/gen/templates/component/component.astro.hbs +21 -0
  52. package/dist/gen/templates/page/dynamic.astro.hbs +24 -0
  53. package/dist/gen/templates/page/static.astro.hbs +20 -0
  54. package/dist/gen/templates/section/component.astro.hbs +45 -0
  55. package/dist/gen/templates/section/content.yml.hbs +10 -0
  56. package/dist/gen/templates/section/schema.ts.hbs +20 -0
  57. package/dist/gen/templates/sections/data-layer.ts.hbs +13 -0
  58. package/dist/gen/templates/sections/index.ts.hbs +12 -0
  59. package/dist/gen/templates/sections/page.astro.hbs +30 -0
  60. package/dist/gen/ts-morph-utils.d.mts +1 -0
  61. package/dist/gen/ts-morph-utils.mjs +8 -0
  62. package/dist/index.d.ts +10 -0
  63. package/dist/index.js +5 -0
  64. package/dist/lib/analytics-coverage.d.ts +12 -0
  65. package/dist/lib/analytics-coverage.js +15 -0
  66. package/dist/lib/bundle-budget.d.ts +24 -0
  67. package/dist/lib/bundle-budget.js +50 -0
  68. package/dist/lib/check-comments.d.ts +10 -0
  69. package/dist/lib/check-language.d.ts +11 -0
  70. package/dist/lib/check-placeholders.d.ts +6 -0
  71. package/dist/lib/check-roadmap.d.ts +30 -0
  72. package/dist/lib/check-routes.d.ts +6 -0
  73. package/dist/lib/cli.d.ts +26 -0
  74. package/dist/lib/comment-syntax.d.ts +11 -0
  75. package/dist/lib/config.d.ts +24 -0
  76. package/dist/lib/config.js +21 -0
  77. package/dist/lib/contract.d.ts +22 -0
  78. package/dist/lib/contract.js +59 -0
  79. package/dist/lib/git.d.ts +6 -0
  80. package/dist/lib/gtm-container.d.ts +41 -0
  81. package/dist/lib/gtm-container.js +145 -0
  82. package/dist/lib/icon-specs.d.ts +6 -0
  83. package/dist/lib/icon-specs.js +6 -0
  84. package/dist/lib/icons.d.ts +6 -0
  85. package/dist/lib/icons.js +32 -0
  86. package/dist/lib/language-words.d.ts +2 -0
  87. package/dist/lib/lighthouse.d.ts +17 -0
  88. package/dist/lib/lighthouse.js +23 -0
  89. package/dist/lib/past-tense.d.ts +1 -0
  90. package/dist/lib/routes.d.ts +31 -0
  91. package/dist/lib/routes.js +97 -0
  92. package/dist/lib/smoke-production.d.ts +35 -0
  93. package/dist/lib/smoke-production.js +132 -0
  94. package/dist/lib/vercel-cli-pin.d.ts +2 -0
  95. package/dist/sh/lhci-local.sh +106 -0
  96. package/package.json +21 -3
@@ -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,12 @@
1
+ export declare function assertInjectable({ root, camel, kebab }: {
2
+ camel: any;
3
+ kebab: any;
4
+ root: any;
5
+ }): void;
6
+ export declare function injectCollection({ root, camel, kebab, document, sections }: {
7
+ camel: any;
8
+ document: any;
9
+ kebab: any;
10
+ root: any;
11
+ sections?: boolean | undefined;
12
+ }): void;
@@ -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,16 @@
1
+ export declare function assertSectionInjectable({ root, collection, camel, kebab, pascal, image }: {
2
+ camel: any;
3
+ collection: any;
4
+ image?: boolean | undefined;
5
+ kebab: any;
6
+ pascal: any;
7
+ root: any;
8
+ }): void;
9
+ export declare function injectSection({ root, collection, camel, kebab, pascal, image }: {
10
+ camel: any;
11
+ collection: any;
12
+ image?: boolean | undefined;
13
+ kebab: any;
14
+ pascal: any;
15
+ root: any;
16
+ }): void;
@@ -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,8 @@
1
+ export declare function assertStringsInjectable({ root, keys }: {
2
+ keys: any;
3
+ root: any;
4
+ }): void;
5
+ export declare function injectStrings({ root, entries }: {
6
+ entries: any;
7
+ root: any;
8
+ }): void;
@@ -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,10 @@
1
+ export declare function pathSegments(plop: any, value: any): any[];
2
+ export declare function pageStrings({ pageKey, pageTitle, dynamic }: {
3
+ dynamic: any;
4
+ pageKey: any;
5
+ pageTitle: any;
6
+ }): {
7
+ key: string;
8
+ value: any;
9
+ }[];
10
+ export default function pageGenerator(plop: any): void;
@@ -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,2 @@
1
+ export declare const PROJECT_GENERATORS = "officina.generators.mjs";
2
+ export default function (plop: any): Promise<void>;
@@ -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 @@
1
+ export declare function postGenAction(root: any, recoveryHint: any): () => string;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export declare function plopCommand(args: string[], cwd: string): string[];
2
+ export declare function main(args?: string[]): number;
@@ -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,23 @@
1
+ export declare function casings(plop: any, value: any): {
2
+ camel: any;
3
+ kebab: any;
4
+ pascal: any;
5
+ };
6
+ export declare function sectionTargets({ camel, kebab, pascal }: {
7
+ camel: any;
8
+ kebab: any;
9
+ pascal: any;
10
+ }): {
11
+ barrel: string;
12
+ schemaFunction: string;
13
+ dataLayer: string;
14
+ dataFunction: string;
15
+ schemaDir: string;
16
+ contentDir: string;
17
+ componentDir: string;
18
+ importsMarker: string;
19
+ sectionsMarker: string;
20
+ };
21
+ export declare const sectionFiles: (targets: any, kebab: any) => string[];
22
+ export declare function findSectionedPages(root: any, marker: any): string[];
23
+ export declare function validateSectionName(plop: any): (value: any) => string | true;
@@ -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 @@
1
+ export default function sectionGenerator(plop: any): void;
@@ -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
+ }