@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.
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/components",
3
- "version": "1.0.0-next.37",
3
+ "version": "1.0.0-next.39",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {