@ethlete/components 1.0.0-next.37 → 1.0.0-next.39
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/CHANGELOG.md +35 -0
- package/fesm2022/ethlete-components.mjs +1953 -124
- package/fesm2022/ethlete-components.mjs.map +1 -1
- package/generators/component/component-names.js +19 -0
- package/generators/component/component-templates.js +211 -0
- package/generators/component/generator.js +144 -0
- package/generators/component/schema.json +74 -0
- package/generators/component/workspace-edits.js +92 -0
- package/generators/generators.json +5 -0
- package/package.json +1 -1
- package/types/ethlete-components.d.ts +970 -70
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { names } from '@nx/devkit';
|
|
2
|
+
const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
3
|
+
export function componentNames(name) {
|
|
4
|
+
const { fileName, className, constantName } = names(name.trim());
|
|
5
|
+
if (!KEBAB.test(fileName)) {
|
|
6
|
+
throw new Error(`"${name}" is not a usable domain name. Use kebab-case, e.g. "stat-tile".`);
|
|
7
|
+
}
|
|
8
|
+
const words = fileName.replace(/-/g, ' ');
|
|
9
|
+
return {
|
|
10
|
+
fileName,
|
|
11
|
+
className,
|
|
12
|
+
constantName,
|
|
13
|
+
elementSelector: `et-${fileName}`,
|
|
14
|
+
attributeSelector: `et${className}`,
|
|
15
|
+
title: words.charAt(0).toUpperCase() + words.slice(1),
|
|
16
|
+
storyIdPrefix: `components-${fileName}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=component-names.js.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export const TIERS = ['both', 'component', 'headless'];
|
|
2
|
+
const TICK = '`';
|
|
3
|
+
export function directiveFile(n) {
|
|
4
|
+
return `import { Directive } from '@angular/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Describe the behavior and state this directive owns, and the markup it expects around it.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <div ${n.attributeSelector}>…</div>
|
|
11
|
+
*/
|
|
12
|
+
@Directive({
|
|
13
|
+
selector: '[${n.attributeSelector}]',
|
|
14
|
+
})
|
|
15
|
+
export class ${n.className}Directive {}
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
export function componentFile(n, options) {
|
|
19
|
+
const headless = options.tier === 'both';
|
|
20
|
+
const importPath = `./headless/${n.fileName}.directive`;
|
|
21
|
+
return `import { Component, ViewEncapsulation } from '@angular/core';
|
|
22
|
+
${headless ? `import { ${n.className}Directive } from '${importPath}';\n` : ''}
|
|
23
|
+
/**
|
|
24
|
+
* Describe what this component renders and when to reach for it.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <${n.elementSelector}>…</${n.elementSelector}>
|
|
28
|
+
*/
|
|
29
|
+
@Component({
|
|
30
|
+
selector: '${n.elementSelector}',
|
|
31
|
+
template: ${TICK}<ng-content />${TICK},
|
|
32
|
+
styleUrl: './${n.fileName}.component.css',
|
|
33
|
+
encapsulation: ViewEncapsulation.None,
|
|
34
|
+
${headless ? ` hostDirectives: [${n.className}Directive],\n` : ''} host: {
|
|
35
|
+
class: '${n.elementSelector}',
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
export class ${n.className}Component {}
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
export function stylesFile(n) {
|
|
42
|
+
return `@layer components {
|
|
43
|
+
.${n.elementSelector} {
|
|
44
|
+
display: block;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
export function errorsFile(n, block) {
|
|
50
|
+
return `// codes ${block}-${block + 99}
|
|
51
|
+
export const ${n.constantName}_ERROR_CODES = {
|
|
52
|
+
/** Describe the misuse this code reports. */
|
|
53
|
+
PLACEHOLDER: ${block},
|
|
54
|
+
} as const;
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
export function importsFile(n, options) {
|
|
58
|
+
const component = `import { ${n.className}Component } from './${n.fileName}.component';`;
|
|
59
|
+
const directive = options.tier === 'both'
|
|
60
|
+
? `import { ${n.className}Directive } from './headless/${n.fileName}.directive';`
|
|
61
|
+
: `import { ${n.className}Directive } from './${n.fileName}.directive';`;
|
|
62
|
+
const imports = options.tier === 'both' ? `${directive}\n${component}` : options.tier === 'component' ? component : directive;
|
|
63
|
+
const members = options.tier === 'both'
|
|
64
|
+
? `${n.className}Component, ${n.className}Directive`
|
|
65
|
+
: options.tier === 'component'
|
|
66
|
+
? `${n.className}Component`
|
|
67
|
+
: `${n.className}Directive`;
|
|
68
|
+
return `${imports}
|
|
69
|
+
|
|
70
|
+
export const ${n.constantName}_IMPORTS = [${members}] as const;
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
export function headlessBarrelFile(n) {
|
|
74
|
+
return `export * from './${n.fileName}.directive';\n`;
|
|
75
|
+
}
|
|
76
|
+
export function barrelFile(n, options) {
|
|
77
|
+
const exports = [];
|
|
78
|
+
if (options.tier === 'both')
|
|
79
|
+
exports.push(`export * from './headless';`);
|
|
80
|
+
if (options.tier === 'headless')
|
|
81
|
+
exports.push(`export * from './${n.fileName}.directive';`);
|
|
82
|
+
if (options.tier !== 'headless')
|
|
83
|
+
exports.push(`export * from './${n.fileName}.component';`);
|
|
84
|
+
if (options.errors)
|
|
85
|
+
exports.push(`export * from './${n.fileName}-errors';`);
|
|
86
|
+
exports.push(`export * from './${n.fileName}.imports';`);
|
|
87
|
+
return `${exports.join('\n')}\n`;
|
|
88
|
+
}
|
|
89
|
+
export function specFile(n, options) {
|
|
90
|
+
if (options.tier === 'headless') {
|
|
91
|
+
return `import { Component } from '@angular/core';
|
|
92
|
+
import { TestBed } from '@angular/core/testing';
|
|
93
|
+
import { By } from '@angular/platform-browser';
|
|
94
|
+
import '../../test-helpers';
|
|
95
|
+
import { ${n.className}Directive } from './${n.fileName}.directive';
|
|
96
|
+
import { ${n.constantName}_IMPORTS } from './${n.fileName}.imports';
|
|
97
|
+
|
|
98
|
+
@Component({
|
|
99
|
+
selector: 'et-test-${n.fileName}-host',
|
|
100
|
+
template: ${TICK}<div ${n.attributeSelector}>Content</div>${TICK},
|
|
101
|
+
imports: [${n.constantName}_IMPORTS],
|
|
102
|
+
})
|
|
103
|
+
class ${n.className}HostComponent {}
|
|
104
|
+
|
|
105
|
+
describe('${n.className}Directive', () => {
|
|
106
|
+
it('applies to its host', () => {
|
|
107
|
+
const fixture = TestBed.createComponent(${n.className}HostComponent);
|
|
108
|
+
fixture.detectChanges();
|
|
109
|
+
|
|
110
|
+
expect(fixture.debugElement.query(By.directive(${n.className}Directive))).not.toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
return `import { Component } from '@angular/core';
|
|
116
|
+
import { TestBed } from '@angular/core/testing';
|
|
117
|
+
import '../../test-helpers';
|
|
118
|
+
import { ${n.constantName}_IMPORTS } from './${n.fileName}.imports';
|
|
119
|
+
|
|
120
|
+
@Component({
|
|
121
|
+
selector: 'et-test-${n.fileName}-host',
|
|
122
|
+
template: ${TICK}<${n.elementSelector}>Content</${n.elementSelector}>${TICK},
|
|
123
|
+
imports: [${n.constantName}_IMPORTS],
|
|
124
|
+
})
|
|
125
|
+
class ${n.className}HostComponent {}
|
|
126
|
+
|
|
127
|
+
describe('${n.className}Component', () => {
|
|
128
|
+
it('renders its projected content', () => {
|
|
129
|
+
const fixture = TestBed.createComponent(${n.className}HostComponent);
|
|
130
|
+
fixture.detectChanges();
|
|
131
|
+
|
|
132
|
+
const host = fixture.nativeElement.querySelector('${n.elementSelector}') as HTMLElement;
|
|
133
|
+
|
|
134
|
+
expect(host.classList.contains('${n.elementSelector}')).toBe(true);
|
|
135
|
+
expect(host.textContent?.trim()).toBe('Content');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
export function storybookComponentFile(n, options) {
|
|
141
|
+
const markup = options.tier === 'headless'
|
|
142
|
+
? `<div ${n.attributeSelector}>${n.title}</div>`
|
|
143
|
+
: `<${n.elementSelector}>${n.title}</${n.elementSelector}>`;
|
|
144
|
+
return `import { Component, ViewEncapsulation } from '@angular/core';
|
|
145
|
+
import { ${n.constantName}_IMPORTS } from '../${n.fileName}.imports';
|
|
146
|
+
|
|
147
|
+
@Component({
|
|
148
|
+
selector: 'et-sb-${n.fileName}',
|
|
149
|
+
template: ${TICK}
|
|
150
|
+
<div class="p-8 font-sans">
|
|
151
|
+
${markup}
|
|
152
|
+
</div>
|
|
153
|
+
${TICK},
|
|
154
|
+
encapsulation: ViewEncapsulation.None,
|
|
155
|
+
imports: [${n.constantName}_IMPORTS],
|
|
156
|
+
})
|
|
157
|
+
export class ${n.className}StorybookComponent {}
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
export function storiesFile(n) {
|
|
161
|
+
return `import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
|
|
162
|
+
import { ${n.className}StorybookComponent } from './${n.fileName}-storybook.component';
|
|
163
|
+
|
|
164
|
+
export default {
|
|
165
|
+
title: 'Components/${n.title}',
|
|
166
|
+
component: ${n.className}StorybookComponent,
|
|
167
|
+
decorators: [moduleMetadata({ imports: [${n.className}StorybookComponent] })],
|
|
168
|
+
} as Meta<${n.className}StorybookComponent>;
|
|
169
|
+
|
|
170
|
+
type Story = StoryObj<${n.className}StorybookComponent>;
|
|
171
|
+
|
|
172
|
+
export const Default: Story = {};
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
export function docsFile(n, options) {
|
|
176
|
+
const subject = options.tier === 'headless' ? `${TICK}[${n.attributeSelector}]${TICK}` : `${TICK}${n.elementSelector}${TICK}`;
|
|
177
|
+
const markup = options.tier === 'headless'
|
|
178
|
+
? `<div ${n.attributeSelector}>…</div>`
|
|
179
|
+
: `<${n.elementSelector}>…</${n.elementSelector}>`;
|
|
180
|
+
return `# ${n.title}
|
|
181
|
+
|
|
182
|
+
${subject} is … Say what it is in one sentence, then when to reach for it over the alternatives. Import ${TICK}${n.constantName}_IMPORTS${TICK}.
|
|
183
|
+
|
|
184
|
+
${TICK}${TICK}${TICK}ts
|
|
185
|
+
import { ${n.constantName}_IMPORTS } from '@ethlete/components';
|
|
186
|
+
${TICK}${TICK}${TICK}
|
|
187
|
+
|
|
188
|
+
${TICK}${TICK}${TICK}html
|
|
189
|
+
${markup}
|
|
190
|
+
${TICK}${TICK}${TICK}
|
|
191
|
+
|
|
192
|
+
## Live demo
|
|
193
|
+
|
|
194
|
+
<StoryEmbed id="${n.storyIdPrefix}--default" height="240px" />
|
|
195
|
+
|
|
196
|
+
## Options
|
|
197
|
+
|
|
198
|
+
| Input | Type | Default | Description |
|
|
199
|
+
| ----- | ---- | ------- | ----------- |
|
|
200
|
+
|
|
201
|
+
## Accessibility
|
|
202
|
+
|
|
203
|
+
Describe the roles, the keyboard model and what a screen reader announces.
|
|
204
|
+
|
|
205
|
+
## Theming
|
|
206
|
+
|
|
207
|
+
List the public ${TICK}--et-${n.fileName}-*${TICK} design tokens and their defaults, and which theming tokens the
|
|
208
|
+
colors resolve from. See [theming](/core/theming).
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=component-templates.js.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { formatFiles, logger, readProjectConfiguration, visitNotIgnoredFiles } from '@nx/devkit';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { componentNames } from './component-names';
|
|
4
|
+
import { barrelFile, componentFile, docsFile, errorsFile, headlessBarrelFile, importsFile, specFile, storiesFile, storybookComponentFile, stylesFile, TIERS, directiveFile, } from './component-templates';
|
|
5
|
+
import { claimErrorCodeBlock as claimErrorCodeBlockRow, insertBarrelExport, insertDocsSidebarEntry, nextErrorCodeBlock, } from './workspace-edits';
|
|
6
|
+
const DEFAULT_PROJECT = 'components';
|
|
7
|
+
const DEFAULT_TIER = 'both';
|
|
8
|
+
const DEFAULT_DOCS_ROOT = 'apps/docs';
|
|
9
|
+
const DEFAULT_DOCS_GROUP = 'Layout & structure';
|
|
10
|
+
const ARCHITECTURE_DOC = 'docs/COMPONENT-ARCHITECTURE.md';
|
|
11
|
+
export default async function generate(tree, schema) {
|
|
12
|
+
logger.log('\n🧱 Scaffolding an Ethlete component domain...\n');
|
|
13
|
+
const tier = schema.tier ?? DEFAULT_TIER;
|
|
14
|
+
if (!TIERS.includes(tier)) {
|
|
15
|
+
logger.error(`❌ Unknown tier "${tier}". Use one of: ${TIERS.join(', ')}.`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let names;
|
|
19
|
+
try {
|
|
20
|
+
names = componentNames(schema.name);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const projectName = schema.project || DEFAULT_PROJECT;
|
|
27
|
+
let sourceRoot;
|
|
28
|
+
try {
|
|
29
|
+
const project = readProjectConfiguration(tree, projectName);
|
|
30
|
+
sourceRoot = project.sourceRoot ?? join(project.root, 'src');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
logger.error(`❌ No project named "${projectName}" in this workspace. Pass --project.`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const domainDir = join(sourceRoot, 'lib', names.fileName);
|
|
37
|
+
if (tree.exists(domainDir)) {
|
|
38
|
+
logger.error(`❌ ${domainDir} already exists. Pick another name or delete it first.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const options = { tier, errors: schema.errors ?? false };
|
|
42
|
+
const written = [];
|
|
43
|
+
const write = (path, content) => {
|
|
44
|
+
tree.write(path, content);
|
|
45
|
+
written.push(path);
|
|
46
|
+
};
|
|
47
|
+
if (tier === 'both') {
|
|
48
|
+
write(join(domainDir, 'headless', `${names.fileName}.directive.ts`), directiveFile(names));
|
|
49
|
+
write(join(domainDir, 'headless', 'index.ts'), headlessBarrelFile(names));
|
|
50
|
+
}
|
|
51
|
+
else if (tier === 'headless') {
|
|
52
|
+
write(join(domainDir, `${names.fileName}.directive.ts`), directiveFile(names));
|
|
53
|
+
}
|
|
54
|
+
if (tier !== 'headless') {
|
|
55
|
+
write(join(domainDir, `${names.fileName}.component.ts`), componentFile(names, options));
|
|
56
|
+
write(join(domainDir, `${names.fileName}.component.css`), stylesFile(names));
|
|
57
|
+
}
|
|
58
|
+
if (options.errors) {
|
|
59
|
+
const block = allocateErrorBlock(tree, sourceRoot);
|
|
60
|
+
write(join(domainDir, `${names.fileName}-errors.ts`), errorsFile(names, block));
|
|
61
|
+
const claimed = claimErrorCodeBlock(tree, names, block);
|
|
62
|
+
if (claimed)
|
|
63
|
+
written.push(claimed);
|
|
64
|
+
}
|
|
65
|
+
write(join(domainDir, `${names.fileName}.imports.ts`), importsFile(names, options));
|
|
66
|
+
write(join(domainDir, 'index.ts'), barrelFile(names, options));
|
|
67
|
+
if (schema.spec ?? true) {
|
|
68
|
+
const suffix = tier === 'headless' ? 'directive' : 'component';
|
|
69
|
+
write(join(domainDir, `${names.fileName}.${suffix}.spec.ts`), specFile(names, options));
|
|
70
|
+
}
|
|
71
|
+
if (schema.stories ?? true) {
|
|
72
|
+
write(join(domainDir, 'stories', `${names.fileName}-storybook.component.ts`), storybookComponentFile(names, options));
|
|
73
|
+
write(join(domainDir, 'stories', `${names.fileName}.stories.ts`), storiesFile(names));
|
|
74
|
+
}
|
|
75
|
+
const rootBarrel = join(sourceRoot, 'index.ts');
|
|
76
|
+
if (tree.exists(rootBarrel)) {
|
|
77
|
+
tree.write(rootBarrel, insertBarrelExport(tree.read(rootBarrel, 'utf-8') ?? '', names.fileName));
|
|
78
|
+
written.push(rootBarrel);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
logger.warn(`⚠️ No barrel at ${rootBarrel} - export the domain yourself.`);
|
|
82
|
+
}
|
|
83
|
+
if (schema.docs ?? true) {
|
|
84
|
+
written.push(...writeDocs(tree, names, options, schema));
|
|
85
|
+
}
|
|
86
|
+
if (!schema.skipFormat) {
|
|
87
|
+
await formatFiles(tree);
|
|
88
|
+
}
|
|
89
|
+
logger.log(`✅ ${written.length} file(s) written:\n - ${written.join('\n - ')}`);
|
|
90
|
+
logger.log(`\nNext:
|
|
91
|
+
- fill in the JSDoc and docs prose the scaffold left as placeholders
|
|
92
|
+
- add a changeset (.changeset/*.md, minor for @ethlete/components)
|
|
93
|
+
- nx lint ${projectName} --fix && nx test ${projectName}\n`);
|
|
94
|
+
}
|
|
95
|
+
/** Records the claimed block in the architecture doc, when the workspace has one. */
|
|
96
|
+
function claimErrorCodeBlock(tree, names, block) {
|
|
97
|
+
if (!tree.exists(ARCHITECTURE_DOC)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const before = tree.read(ARCHITECTURE_DOC, 'utf-8') ?? '';
|
|
101
|
+
const after = claimErrorCodeBlockRow(before, block, names.title);
|
|
102
|
+
if (after === before) {
|
|
103
|
+
logger.warn(`⚠️ Could not update the code range table in ${ARCHITECTURE_DOC} - claim ${block} yourself.`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
tree.write(ARCHITECTURE_DOC, after);
|
|
107
|
+
return ARCHITECTURE_DOC;
|
|
108
|
+
}
|
|
109
|
+
/** The next unused 100-code block across every `*-errors.ts` in the lib. */
|
|
110
|
+
function allocateErrorBlock(tree, sourceRoot) {
|
|
111
|
+
const contents = [];
|
|
112
|
+
visitNotIgnoredFiles(tree, join(sourceRoot, 'lib'), (path) => {
|
|
113
|
+
if (path.endsWith('-errors.ts')) {
|
|
114
|
+
contents.push(tree.read(path, 'utf-8') ?? '');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return nextErrorCodeBlock(contents);
|
|
118
|
+
}
|
|
119
|
+
function writeDocs(tree, names, options, schema) {
|
|
120
|
+
const docsRoot = schema.docsRoot || DEFAULT_DOCS_ROOT;
|
|
121
|
+
const page = join(docsRoot, 'components', `${names.fileName}.md`);
|
|
122
|
+
if (!tree.exists(join(docsRoot, 'components'))) {
|
|
123
|
+
logger.warn(`⚠️ No docs site at ${docsRoot} - skipping the guide page.`);
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const written = [page];
|
|
127
|
+
tree.write(page, docsFile(names, options));
|
|
128
|
+
const config = join(docsRoot, '.vitepress', 'config.mts');
|
|
129
|
+
if (!tree.exists(config)) {
|
|
130
|
+
logger.warn(`⚠️ No ${config} - add the sidebar entry yourself.`);
|
|
131
|
+
return written;
|
|
132
|
+
}
|
|
133
|
+
const group = schema.docsGroup || DEFAULT_DOCS_GROUP;
|
|
134
|
+
const before = tree.read(config, 'utf-8') ?? '';
|
|
135
|
+
const after = insertDocsSidebarEntry(before, group, names.title, `/components/${names.fileName}`);
|
|
136
|
+
if (after === before) {
|
|
137
|
+
logger.warn(`⚠️ Could not find the "${group}" sidebar group in ${config} - add the entry yourself.`);
|
|
138
|
+
return written;
|
|
139
|
+
}
|
|
140
|
+
tree.write(config, after);
|
|
141
|
+
written.push(config);
|
|
142
|
+
return written;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=generator.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "et-component",
|
|
4
|
+
"title": "Ethlete Component Generator",
|
|
5
|
+
"description": "Scaffolds a component domain that follows the three-tier architecture: folder layout, headless + default split, imports barrel, styles, spec, stories, docs page and barrel/sidebar wiring.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Domain name in kebab-case, e.g. 'stat-tile'.",
|
|
11
|
+
"$default": {
|
|
12
|
+
"$source": "argv",
|
|
13
|
+
"index": 0
|
|
14
|
+
},
|
|
15
|
+
"x-prompt": "What is the domain called (kebab-case)?"
|
|
16
|
+
},
|
|
17
|
+
"tier": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Which tiers to scaffold: 'both' is a headless directive plus the default component applying it, 'component' a presentational component only, 'headless' a behavior directive only.",
|
|
20
|
+
"enum": ["both", "component", "headless"],
|
|
21
|
+
"default": "both",
|
|
22
|
+
"x-prompt": {
|
|
23
|
+
"message": "Which tiers does the domain need?",
|
|
24
|
+
"type": "list",
|
|
25
|
+
"items": [
|
|
26
|
+
{ "value": "both", "label": "Headless directive + default component" },
|
|
27
|
+
{ "value": "component", "label": "Default component only" },
|
|
28
|
+
{ "value": "headless", "label": "Headless directive only" }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"project": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Nx project to scaffold into. The domain lands in its '<sourceRoot>/lib/<name>'.",
|
|
35
|
+
"default": "components"
|
|
36
|
+
},
|
|
37
|
+
"errors": {
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"description": "Add a '<name>-errors.ts' with the next free 100-code block.",
|
|
40
|
+
"default": false
|
|
41
|
+
},
|
|
42
|
+
"spec": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"description": "Add a spec file.",
|
|
45
|
+
"default": true
|
|
46
|
+
},
|
|
47
|
+
"stories": {
|
|
48
|
+
"type": "boolean",
|
|
49
|
+
"description": "Add a Storybook story and its demo component.",
|
|
50
|
+
"default": true
|
|
51
|
+
},
|
|
52
|
+
"docs": {
|
|
53
|
+
"type": "boolean",
|
|
54
|
+
"description": "Add a VitePress guide page and its sidebar entry.",
|
|
55
|
+
"default": true
|
|
56
|
+
},
|
|
57
|
+
"docsGroup": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Sidebar group the guide page is listed under.",
|
|
60
|
+
"default": "Layout & structure"
|
|
61
|
+
},
|
|
62
|
+
"docsRoot": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": "Path to the VitePress site (relative to workspace root).",
|
|
65
|
+
"default": "apps/docs"
|
|
66
|
+
},
|
|
67
|
+
"skipFormat": {
|
|
68
|
+
"type": "boolean",
|
|
69
|
+
"description": "Skip formatting files after generation.",
|
|
70
|
+
"default": false
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"required": ["name"]
|
|
74
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const LIB_EXPORT = /^export \* from '(\.\/lib\/[^']+)';$/;
|
|
2
|
+
const SIDEBAR_ENTRY = /^(\s*)\{ text: '([^']+)', link: '([^']+)' \},$/;
|
|
3
|
+
const ERROR_BLOCK = /\/\/ codes \d+-(\d+)/;
|
|
4
|
+
const CODE_RANGE_ROW = /^\| \d+ – \d+ \|.*\|$/;
|
|
5
|
+
const NEXT_FREE_BLOCK = /next free: \*\*\d+\*\*/;
|
|
6
|
+
/** Adds `export * from './lib/<domain>';` to the lib's root barrel, in alphabetical order. */
|
|
7
|
+
export function insertBarrelExport(content, domain) {
|
|
8
|
+
const path = `./lib/${domain}`;
|
|
9
|
+
const line = `export * from '${path}';`;
|
|
10
|
+
const lines = content.split('\n');
|
|
11
|
+
if (lines.includes(line)) {
|
|
12
|
+
return content;
|
|
13
|
+
}
|
|
14
|
+
const exportIndexes = lines.map((l, i) => (LIB_EXPORT.test(l) ? i : -1)).filter((i) => i !== -1);
|
|
15
|
+
if (!exportIndexes.length) {
|
|
16
|
+
return content;
|
|
17
|
+
}
|
|
18
|
+
const before = exportIndexes.find((i) => (lines[i]?.match(LIB_EXPORT)?.[1] ?? '') > path);
|
|
19
|
+
const at = before ?? (exportIndexes[exportIndexes.length - 1] ?? 0) + 1;
|
|
20
|
+
lines.splice(at, 0, line);
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Adds `{ text, link }` to the named VitePress sidebar group. An alphabetical group keeps that
|
|
25
|
+
* order - a stray pair out of place (a group naming a recipe page, say) still counts as
|
|
26
|
+
* alphabetical - while a hand-ordered group (the forms one) gets the entry appended instead.
|
|
27
|
+
*/
|
|
28
|
+
export function insertDocsSidebarEntry(content, group, text, link) {
|
|
29
|
+
if (content.includes(`link: '${link}' }`)) {
|
|
30
|
+
return content;
|
|
31
|
+
}
|
|
32
|
+
const lines = content.split('\n');
|
|
33
|
+
const groupIndex = lines.findIndex((l) => l.trim() === `text: '${group}',`);
|
|
34
|
+
if (groupIndex === -1) {
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
const itemsIndex = lines.findIndex((l, i) => i > groupIndex && l.trim() === 'items: [');
|
|
38
|
+
if (itemsIndex === -1) {
|
|
39
|
+
return content;
|
|
40
|
+
}
|
|
41
|
+
const end = lines.findIndex((l, i) => i > itemsIndex && l.trim() === '],');
|
|
42
|
+
if (end === -1) {
|
|
43
|
+
return content;
|
|
44
|
+
}
|
|
45
|
+
const entries = lines
|
|
46
|
+
.slice(itemsIndex + 1, end)
|
|
47
|
+
.map((l, i) => ({ index: itemsIndex + 1 + i, match: l.match(SIDEBAR_ENTRY) }))
|
|
48
|
+
.filter((e) => e.match !== null);
|
|
49
|
+
if (!entries.length) {
|
|
50
|
+
return content;
|
|
51
|
+
}
|
|
52
|
+
const indent = entries[0]?.match[1] ?? '';
|
|
53
|
+
const texts = entries.map((e) => e.match[2] ?? '');
|
|
54
|
+
const alphabetical = isMostlyAlphabetical(texts);
|
|
55
|
+
const at = (alphabetical ? entries.find((e) => (e.match[2] ?? '').localeCompare(text) > 0)?.index : undefined) ?? end;
|
|
56
|
+
lines.splice(at, 0, `${indent}{ text: '${text}', link: '${link}' },`);
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Claims a block in the architecture doc's error-code table: appends the row and moves the
|
|
61
|
+
* "next free" pointer on, so the table stays append-only the way the doc requires.
|
|
62
|
+
*/
|
|
63
|
+
export function claimErrorCodeBlock(content, block, domain) {
|
|
64
|
+
const lines = content.split('\n');
|
|
65
|
+
const lastRow = lines.reduce((last, line, i) => (CODE_RANGE_ROW.test(line) ? i : last), -1);
|
|
66
|
+
if (lastRow === -1) {
|
|
67
|
+
return content;
|
|
68
|
+
}
|
|
69
|
+
lines.splice(lastRow + 1, 0, `| ${block} – ${block + 99} | ${domain} |`);
|
|
70
|
+
return lines.join('\n').replace(NEXT_FREE_BLOCK, `next free: **${block + 100}**`);
|
|
71
|
+
}
|
|
72
|
+
const MAX_OUT_OF_ORDER_RATIO = 0.2;
|
|
73
|
+
function isMostlyAlphabetical(texts) {
|
|
74
|
+
const pairs = texts.length - 1;
|
|
75
|
+
if (pairs < 1) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
const outOfOrder = texts.filter((t, i) => i > 0 && (texts[i - 1] ?? '').localeCompare(t) > 0).length;
|
|
79
|
+
return outOfOrder / pairs <= MAX_OUT_OF_ORDER_RATIO;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The next free 100-code block, read from the `// codes <from>-<to>` header every `*-errors.ts`
|
|
83
|
+
* file carries. Domains never reuse a block, so this only ever moves forward.
|
|
84
|
+
*/
|
|
85
|
+
export function nextErrorCodeBlock(errorFileContents) {
|
|
86
|
+
const highest = errorFileContents.reduce((max, content) => {
|
|
87
|
+
const to = Number(content.match(ERROR_BLOCK)?.[1] ?? 0);
|
|
88
|
+
return to > max ? to : max;
|
|
89
|
+
}, 0);
|
|
90
|
+
return highest ? Math.floor(highest / 100) * 100 + 100 : 1000;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=workspace-edits.js.map
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"generators": {
|
|
3
|
+
"component": {
|
|
4
|
+
"factory": "./component/generator",
|
|
5
|
+
"schema": "./component/schema.json",
|
|
6
|
+
"description": "Scaffold a component domain following the three-tier architecture (folder layout, headless + default split, styles, spec, stories, docs page, barrel wiring)"
|
|
7
|
+
},
|
|
3
8
|
"migrate-query-error-labels": {
|
|
4
9
|
"factory": "./migrate-query-error-labels/migration",
|
|
5
10
|
"schema": "./migrate-query-error-labels/schema.json",
|