@starci/hfs 4.9.6 → 4.9.8
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 +19 -0
- package/README.md +24 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/scaffold/add-fe-layout.mjs +46 -0
- package/scaffold/add-fe-overlay.mjs +108 -0
- package/scaffold/add-fe-page.mjs +137 -0
- package/scaffold/add-fe-unit.mjs +288 -0
- package/scaffold/add.mjs +20 -1
- package/src/main.mjs +11 -5
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +7 -5
- package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/component.tsx +7 -6
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +4 -5
- package/templates/fe/units/block/component.tsx.tpl +35 -0
- package/templates/fe/units/block/index.tsx.tpl +29 -0
- package/templates/fe/units/block/stories.tsx.tpl +29 -0
- package/templates/fe/units/branch/index.tsx.tpl +23 -0
- package/templates/fe/units/composite/index.tsx.tpl +18 -0
- package/templates/fe/units/layout/index.tsx.tpl +11 -0
- package/templates/fe/units/layout/route-layout.tsx.tpl +9 -0
- package/templates/fe/units/leaf/index.tsx.tpl +10 -0
- package/templates/fe/units/overlay/component.tsx.tpl +27 -0
- package/templates/fe/units/overlay/index.tsx.tpl +23 -0
- package/templates/fe/units/overlay/stories.tsx.tpl +29 -0
- package/templates/fe/units/page/component.tsx.tpl +35 -0
- package/templates/fe/units/page/error-component.tsx.tpl +18 -0
- package/templates/fe/units/page/error-index.tsx.tpl +11 -0
- package/templates/fe/units/page/index.tsx.tpl +29 -0
- package/templates/fe/units/page/route-error.tsx.tpl +10 -0
- package/templates/fe/units/page/route-loading.tsx.tpl +6 -0
- package/templates/fe/units/page/route-page.tsx.tpl +9 -0
- package/templates/fe/units/page/stories.tsx.tpl +34 -0
- package/templates/fe/units/slot/SlotView.tsx.tpl +28 -0
- package/templates/fe/units/slot/data-slot.ts.tpl +18 -0
- package/templates/fe/units/slot/slot.shared.ts.tpl +35 -0
- package/templates/fe/units/slot/useSlotLabels.ts.tpl +8 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// starci app add <block|composite|branch|leaf> <name> - one front-end unit, already in the shape-slot pattern.
|
|
2
|
+
//
|
|
3
|
+
// The bodies are templates/fe/units/<tier>/; this module only fills them and places them (contexts/code/fe function.md
|
|
4
|
+
// FE-FUNCTION-2..4 and FE-FUNCTION-7, README.md FE-ERROR-1).
|
|
5
|
+
//
|
|
6
|
+
// starci app add block handoff-block HandoffBlock: one read folded into a DataSlot, no shape
|
|
7
|
+
// starci app add block handoff-block --shapes prepared,sent,returned plus HANDOFF_BLOCK_SHAPES, HandoffBlockShape and an exhaustive switch
|
|
8
|
+
// starci app add composite term-value | branch disclosure | leaf status-label
|
|
9
|
+
//
|
|
10
|
+
// A block reads `useQuery<Read>Swr` of hooks/swr (`--read`, default: the block name without its `Block` suffix; `add table --fe`
|
|
11
|
+
// writes that hook). A block with shapes takes its shape from the read's `status` (the placeholder's own while the read loads), so
|
|
12
|
+
// the read names its shape in that field. The first block of an app also writes the app's one copy of the status owners
|
|
13
|
+
// (FE-ERROR-1): DataSlot and SlotLabels in modules/types, slotOfSwr/mapSlot/loadingSlot in hooks/slot/slot.shared.ts,
|
|
14
|
+
// useSlotLabels, the SlotView composite and the shared `status` namespace of every catalog. Nothing that exists is overwritten.
|
|
15
|
+
// A composite, branch or leaf, SlotView among them, is vocabulary that knows no feature: in a front end of two or more apps with
|
|
16
|
+
// the shared ui package (slot fe.package.ui) it lives in that package and its barrel, DataSlot and SlotLabels are declared beside
|
|
17
|
+
// SlotView there and modules/types re-exports them (R91 `monorepo-tier-belongs-to-its-side`); else it lives in the app's components.
|
|
18
|
+
// A shape that names a data status is refused by the lint (R92 `no-data-status-shape`), which owns that word list.
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { ScaffoldError, camelOf, pascalOf } from './service.mjs';
|
|
23
|
+
import { coreFeApps } from './app.mjs';
|
|
24
|
+
|
|
25
|
+
const UNITS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates', 'fe', 'units');
|
|
26
|
+
|
|
27
|
+
/** The nouns this module answers, and the components layer each one writes into. */
|
|
28
|
+
export const FE_UNIT_LAYERS = Object.freeze({ block: 'blocks', composite: 'composites', branch: 'branches', leaf: 'leaves' });
|
|
29
|
+
|
|
30
|
+
/** The shared status copy of FE-ERROR-1, by catalog locale; any other locale takes the English sentences. */
|
|
31
|
+
const STATUS_COPY = Object.freeze({
|
|
32
|
+
en: { forbidden: 'You do not have access to this.', error: 'This could not be loaded.', retry: 'Try again' },
|
|
33
|
+
vi: { forbidden: 'Bạn không có quyền xem phần này.', error: 'Không tải được phần này.', retry: 'Thử lại' },
|
|
34
|
+
});
|
|
35
|
+
/** The empty sentence of a new block, by catalog locale. */
|
|
36
|
+
const EMPTY_COPY = Object.freeze({ en: 'Nothing to show yet.', vi: 'Chưa có gì để hiển thị.' });
|
|
37
|
+
|
|
38
|
+
export const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/).filter(Boolean).map((word) => word.toLowerCase());
|
|
39
|
+
export const humanOf = (name) => { const text = words(name).join(' '); return text[0].toUpperCase() + text.slice(1); };
|
|
40
|
+
|
|
41
|
+
/** A template body with its `{{name}}` variables filled; a variable without a value is a template error, never left in place. */
|
|
42
|
+
export function fill(file, vars) {
|
|
43
|
+
const body = fs.readFileSync(path.join(UNITS_DIR, ...file.split('/')), 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
return body.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
45
|
+
if (!Object.hasOwn(vars, key)) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `templates/fe/units/${file} names {{${key}}}, which has no value`);
|
|
46
|
+
return vars[key];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The parsed hfs.json of the app root. */
|
|
51
|
+
export function declarationOf(repoRoot) {
|
|
52
|
+
const declarationFile = path.join(repoRoot, 'hfs.json');
|
|
53
|
+
if (!fs.existsSync(declarationFile)) throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'starci app add needs the app root hfs.json');
|
|
54
|
+
return JSON.parse(fs.readFileSync(declarationFile, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The one product app the unit belongs to: `--app`, else the only product Next app of hfs.json. */
|
|
58
|
+
export function appOf(declaration, requested) {
|
|
59
|
+
const apps = coreFeApps(declaration.sides?.fe?.apps ?? []);
|
|
60
|
+
if (requested !== undefined) {
|
|
61
|
+
if (!apps.some((entry) => entry.name === requested)) throw new ScaffoldError('HFS_ADD_NO_FRONT_END', `${requested} is not a product Next app of hfs.json`);
|
|
62
|
+
return requested;
|
|
63
|
+
}
|
|
64
|
+
if (apps.length !== 1) throw new ScaffoldError('HFS_ADD_NO_FRONT_END', `a front-end unit names its app with --app when hfs.json declares ${apps.length} product Next apps`);
|
|
65
|
+
return apps[0].name;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the vocabulary tiers go: the shared ui package (`fe/packages/<project>-ui`: its folder and its import name) when the front
|
|
70
|
+
* end has two or more apps and the package exists, else null (the app's own components).
|
|
71
|
+
*/
|
|
72
|
+
function sharedUiOf(repoRoot, declaration) {
|
|
73
|
+
if ((declaration.sides?.fe?.apps ?? []).length < 2) return null;
|
|
74
|
+
const relative = `fe/packages/${declaration.project}-ui`;
|
|
75
|
+
const manifest = path.join(repoRoot, ...relative.split('/'), 'package.json');
|
|
76
|
+
if (!fs.existsSync(manifest)) return null;
|
|
77
|
+
return { relative, name: JSON.parse(fs.readFileSync(manifest, 'utf8')).name };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The shape members of `--shapes a,b,c`: none, or two or more distinct camelCase words, in the order given. */
|
|
81
|
+
function shapesOf(option) {
|
|
82
|
+
if (option === undefined || option === false) return [];
|
|
83
|
+
const shapes = (Array.isArray(option) ? option : String(option).split(',')).map((value) => value.trim()).filter(Boolean);
|
|
84
|
+
if (shapes.length < 2) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', '--shapes names two or more drawn shapes; a unit with one shape takes none');
|
|
85
|
+
if (new Set(shapes).size !== shapes.length) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', `--shapes repeats a member: ${shapes.join(',')}`);
|
|
86
|
+
const unworded = shapes.find((shape) => !/^[a-z][a-zA-Z0-9]*$/.test(shape));
|
|
87
|
+
if (unworded !== undefined) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', `the shape "${unworded}" is not a camelCase word`);
|
|
88
|
+
return shapes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Writes one file the unit owns (`relative` to the repository root); the unit's own files must not exist yet. */
|
|
92
|
+
export function writeNew(created, repoRoot, relative, body) {
|
|
93
|
+
const file = path.join(repoRoot, ...relative.split('/'));
|
|
94
|
+
if (fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_EXISTS', `${relative} already exists; starci app add never overwrites a unit`);
|
|
95
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
96
|
+
fs.writeFileSync(file, body);
|
|
97
|
+
created.push(relative);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Writes one shared file only when it is missing (the status owners have one copy per app). */
|
|
101
|
+
function writeOnce(created, repoRoot, relative, body) {
|
|
102
|
+
const file = path.join(repoRoot, ...relative.split('/'));
|
|
103
|
+
if (fs.existsSync(file)) return;
|
|
104
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
fs.writeFileSync(file, body);
|
|
106
|
+
created.push(relative);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Adds `line` to a barrel, creating it, unless the barrel already has it. */
|
|
110
|
+
export function exportFrom(created, repoRoot, relative, line) {
|
|
111
|
+
const file = path.join(repoRoot, ...relative.split('/'));
|
|
112
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
113
|
+
if (before.split(/\r?\n/).includes(line)) return;
|
|
114
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
115
|
+
fs.writeFileSync(file, `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}${line}\n`);
|
|
116
|
+
if (!created.includes(relative)) created.push(relative);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The app's one copy of the FE-ERROR-1 owners, written by the first block that needs them; SlotView is shared vocabulary. */
|
|
120
|
+
function ensureSlotOwners(created, { repoRoot, appBase, ui }) {
|
|
121
|
+
const types = fill('slot/data-slot.ts.tpl', {});
|
|
122
|
+
if (ui === null) {
|
|
123
|
+
writeOnce(created, repoRoot, `${appBase}/src/modules/types/data-slot.ts`, types);
|
|
124
|
+
exportFrom(created, repoRoot, `${appBase}/src/modules/types/index.ts`, 'export type { DataSlot, SlotLabels } from "./data-slot"');
|
|
125
|
+
writeOnce(created, repoRoot, `${appBase}/src/components/composites/SlotView/index.tsx`, fill('slot/SlotView.tsx.tpl', { slotTypes: 'import type { DataSlot, SlotLabels } from "@/modules/types"\n' }));
|
|
126
|
+
} else {
|
|
127
|
+
writeOnce(created, repoRoot, `${ui.relative}/src/composites/SlotView/index.tsx`, fill('slot/SlotView.tsx.tpl', { slotTypes: `\n${types}` }));
|
|
128
|
+
exportFrom(created, repoRoot, `${ui.relative}/src/index.ts`, 'export { SlotView } from "./composites/SlotView"');
|
|
129
|
+
exportFrom(created, repoRoot, `${ui.relative}/src/index.ts`, 'export type { DataSlot, SlotLabels } from "./composites/SlotView"');
|
|
130
|
+
exportFrom(created, repoRoot, `${appBase}/src/modules/types/index.ts`, `export type { DataSlot, SlotLabels } from "${ui.name}"`);
|
|
131
|
+
}
|
|
132
|
+
writeOnce(created, repoRoot, `${appBase}/src/hooks/slot/slot.shared.ts`, fill('slot/slot.shared.ts.tpl', {}));
|
|
133
|
+
writeOnce(created, repoRoot, `${appBase}/src/hooks/slot/useSlotLabels.ts`, fill('slot/useSlotLabels.ts.tpl', {}));
|
|
134
|
+
exportFrom(created, repoRoot, `${appBase}/src/hooks/slot/index.ts`, 'export { loadingSlot, mapSlot, slotOfSwr } from "./slot.shared"');
|
|
135
|
+
exportFrom(created, repoRoot, `${appBase}/src/hooks/slot/index.ts`, 'export { useSlotLabels } from "./useSlotLabels"');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Adds namespaces to every catalog of the app, keeping what is there: `namespacesOf(locale)` gives `{ namespace: copy }`, and a
|
|
140
|
+
* namespace the catalog already has is left as it is.
|
|
141
|
+
*/
|
|
142
|
+
export function addCatalogNamespaces(created, { repoRoot, appBase }, namespacesOf) {
|
|
143
|
+
const dir = path.join(repoRoot, ...appBase.split('/'), 'src', 'modules', 'i18n', 'messages');
|
|
144
|
+
if (!fs.existsSync(dir)) throw new ScaffoldError('HFS_ADD_WIRE_MISSING', 'a front-end unit reads its copy from modules/i18n/messages, which does not exist');
|
|
145
|
+
for (const entry of fs.readdirSync(dir).filter((file) => file.endsWith('.json')).sort()) {
|
|
146
|
+
const file = path.join(dir, entry);
|
|
147
|
+
const catalog = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
148
|
+
const before = JSON.stringify(catalog);
|
|
149
|
+
for (const [namespace, copy] of Object.entries(namespacesOf(entry.slice(0, -'.json'.length)))) catalog[namespace] ??= copy;
|
|
150
|
+
if (JSON.stringify(catalog) === before) continue;
|
|
151
|
+
fs.writeFileSync(file, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
152
|
+
created.push(`${appBase}/src/modules/i18n/messages/${entry}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The shared `status` namespace and the block's own namespace. */
|
|
157
|
+
function addCatalogKeys(created, place, { nameCamel, title, shapes }) {
|
|
158
|
+
addCatalogNamespaces(created, place, (locale) => ({
|
|
159
|
+
status: { ...(STATUS_COPY[locale] ?? STATUS_COPY.en) },
|
|
160
|
+
[nameCamel]: {
|
|
161
|
+
title,
|
|
162
|
+
empty: EMPTY_COPY[locale] ?? EMPTY_COPY.en,
|
|
163
|
+
...(shapes.length ? { shapes: Object.fromEntries(shapes.map((shape) => [shape, humanOf(shape)])) } : {}),
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The pure half's shape inventory, union and exhaustive switch (FE-FUNCTION-7): one case per member, in the given order. */
|
|
169
|
+
function shapeParts({ Name, NAME_UPPER, shapes }) {
|
|
170
|
+
const cases = shapes.map((shape) => [
|
|
171
|
+
` case "${shape}":`,
|
|
172
|
+
' return (',
|
|
173
|
+
' <>',
|
|
174
|
+
` <Text isSkeleton={isSkeleton}>{shapeLabels.${shape}}</Text>`,
|
|
175
|
+
' <Text isSkeleton={isSkeleton}>{item.id}</Text>',
|
|
176
|
+
' </>',
|
|
177
|
+
' )',
|
|
178
|
+
].join('\n')).join('\n');
|
|
179
|
+
return {
|
|
180
|
+
shapeDeclarations: [
|
|
181
|
+
'',
|
|
182
|
+
`/** The drawn shapes of ${Name}, in the order the owner approved them: one drawing and one story each, never a data status. */`,
|
|
183
|
+
`export const ${NAME_UPPER}_SHAPES = [${shapes.map((shape) => `"${shape}"`).join(', ')}] as const`,
|
|
184
|
+
'',
|
|
185
|
+
`/** One drawn shape of ${Name}. */`,
|
|
186
|
+
`export type ${Name}Shape = (typeof ${NAME_UPPER}_SHAPES)[number]`,
|
|
187
|
+
'',
|
|
188
|
+
].join('\n'),
|
|
189
|
+
shapeBody: [
|
|
190
|
+
'',
|
|
191
|
+
`/** The ready tree of one drawn shape: one case per member of ${Name}Shape, so a member nobody draws fails the lint. */`,
|
|
192
|
+
`const readyTreeOf = (shape: ${Name}Shape, shapeLabels: ${Name}BaseProps["props"]["shapeLabels"], item: ${Name}Item, isSkeleton: boolean) => {`,
|
|
193
|
+
' switch (shape) {',
|
|
194
|
+
cases,
|
|
195
|
+
' }',
|
|
196
|
+
'}',
|
|
197
|
+
'',
|
|
198
|
+
].join('\n'),
|
|
199
|
+
shapeOfHelper: `\n/** The drawn shape a read's \`status\` names; a status no shape draws takes the placeholder's. */\nconst shapeOf = (status: string): ${Name}Shape => ${NAME_UPPER}_SHAPES.find((shape) => shape === status) ?? ${NAME_UPPER}_PLACEHOLDER.shape\n`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The variables of the block templates; the shape parts are empty when the block has one shape. */
|
|
204
|
+
function blockVars({ Name, nameCamel, nameKebab, Read, shapes, title, ui }) {
|
|
205
|
+
const NAME_UPPER = words(Name).join('_').toUpperCase();
|
|
206
|
+
const shaped = shapes.length > 0;
|
|
207
|
+
const parts = shaped ? shapeParts({ Name, NAME_UPPER, shapes }) : { shapeDeclarations: '', shapeBody: '', shapeOfHelper: '' };
|
|
208
|
+
const story = (shape) => pascalOf(shape);
|
|
209
|
+
const readyStories = (shaped ? shapes : ['ready']).map((shape) => [
|
|
210
|
+
'',
|
|
211
|
+
`/** The ${shaped ? `${shape} shape` : 'ready read'}, settled. */`,
|
|
212
|
+
`export const ${story(shape)}: Story = {`,
|
|
213
|
+
...(shaped ? [` args: { shape: "${shape}" },`] : []),
|
|
214
|
+
` parameters: { dna: { pair: "${story(shape)}", phase: "ready" } },`,
|
|
215
|
+
'}',
|
|
216
|
+
].join('\n')).join('\n');
|
|
217
|
+
return {
|
|
218
|
+
Name, nameCamel, nameKebab, Read, title, NAME_UPPER, ...parts,
|
|
219
|
+
slotViewFrom: ui === null ? '@/components/composites/SlotView' : ui.name,
|
|
220
|
+
itemShapeMember: shaped ? ` readonly shape: ${Name}Shape\n` : '',
|
|
221
|
+
shapeMember: shaped ? ` readonly shape: ${Name}Shape\n` : '',
|
|
222
|
+
shapeLabelsMember: shaped ? ` readonly shapeLabels: { ${shapes.map((shape) => `readonly ${shape}: string`).join('; ')} }\n` : '',
|
|
223
|
+
placeholderShapeDoc: shaped ? ', and its shape is the block\'s while the read loads' : '',
|
|
224
|
+
placeholderShape: shaped ? `, shape: "${shapes[0]}"` : '',
|
|
225
|
+
readyTree: shaped ? 'readyTreeOf(props.shape, props.props.shapeLabels, item, isSkeleton)' : '<Text isSkeleton={isSkeleton}>{item.id}</Text>',
|
|
226
|
+
componentImports: shaped ? `${NAME_UPPER}_PLACEHOLDER, ${NAME_UPPER}_SHAPES, ${Name}Base` : `${Name}Base`,
|
|
227
|
+
componentTypeImport: shaped ? `import type { ${Name}Shape } from "./component"\n` : '',
|
|
228
|
+
connectedDoc: shaped ? 'takes its shape from the read (the placeholder\'s while it loads), ' : '',
|
|
229
|
+
itemShapeMapped: shaped ? ', shape: shapeOf(value.status)' : '',
|
|
230
|
+
shapeAttribute: shaped ? ` shape={(item.items ?? ${NAME_UPPER}_PLACEHOLDER).shape}\n` : '',
|
|
231
|
+
shapeLabelsResolved: shaped ? ` shapeLabels: { ${shapes.map((shape) => `${shape}: t("shapes.${shape}")`).join(', ')} },\n` : '',
|
|
232
|
+
shapeLabelsFixture: shaped ? ` shapeLabels: { ${shapes.map((shape) => `${shape}: "${humanOf(shape)}"`).join(', ')} },\n` : '',
|
|
233
|
+
fixtureShape: shaped ? `, shape: "${shapes[0]}"` : '',
|
|
234
|
+
shapeArg: shaped ? `shape: "${shapes[0]}", ` : '',
|
|
235
|
+
storiesDoc: shaped ? 'one story per drawn shape' : 'the settled read',
|
|
236
|
+
readyStories,
|
|
237
|
+
firstStory: story(shaped ? shapes[0] : 'ready'),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Writes one block: its two halves (the pure one with its shapes, when it has them), its stories and its copy. */
|
|
242
|
+
function addBlock({ created, place, names, options }) {
|
|
243
|
+
const shapes = shapesOf(options.shapes);
|
|
244
|
+
const nameWords = words(names.Name);
|
|
245
|
+
const read = options.read ?? (nameWords.at(-1) === 'block' && nameWords.length > 1 ? nameWords.slice(0, -1).join('-') : names.nameKebab);
|
|
246
|
+
const vars = blockVars({ ...names, Read: pascalOf(read), shapes, title: humanOf(names.Name), ui: place.ui });
|
|
247
|
+
const folder = `${place.appBase}/src/components/blocks/${names.Name}`;
|
|
248
|
+
const stories = `${place.appBase}/stories/blocks/${names.Name}.stories.tsx`;
|
|
249
|
+
for (const relative of [folder, stories]) {
|
|
250
|
+
if (fs.existsSync(path.join(place.repoRoot, ...relative.split('/')))) throw new ScaffoldError('HFS_ADD_EXISTS', `${relative} already exists; starci app add never overwrites a unit`);
|
|
251
|
+
}
|
|
252
|
+
ensureSlotOwners(created, place);
|
|
253
|
+
writeNew(created, place.repoRoot, `${folder}/index.tsx`, fill('block/index.tsx.tpl', vars));
|
|
254
|
+
writeNew(created, place.repoRoot, `${folder}/component.tsx`, fill('block/component.tsx.tpl', vars));
|
|
255
|
+
writeNew(created, place.repoRoot, stories, fill('block/stories.tsx.tpl', vars));
|
|
256
|
+
addCatalogKeys(created, place, { nameCamel: names.nameCamel, title: vars.title, shapes });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Writes one composite, branch or leaf: into the shared ui package and its barrel, or into the app's components. */
|
|
260
|
+
function addVocabulary({ created, place, noun, Name }) {
|
|
261
|
+
const layer = FE_UNIT_LAYERS[noun];
|
|
262
|
+
const body = fill(`${noun}/index.tsx.tpl`, { Name });
|
|
263
|
+
if (place.ui === null) {
|
|
264
|
+
writeNew(created, place.repoRoot, `${place.appBase}/src/components/${layer}/${Name}/index.tsx`, body);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
writeNew(created, place.repoRoot, `${place.ui.relative}/src/${layer}/${Name}/index.tsx`, body);
|
|
268
|
+
exportFrom(created, place.repoRoot, `${place.ui.relative}/src/index.ts`, `export { ${Name} } from "./${layer}/${Name}"`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Generates one front-end unit of the shape-slot pattern in the app at `repoRoot`.
|
|
273
|
+
*
|
|
274
|
+
* @param {{ repoRoot: string, noun: string, name: string, options?: { app?: string, shapes?: string | string[], read?: string } }} input - The app root, the tier noun, the unit name (kebab or PascalCase) and its options.
|
|
275
|
+
* @returns {{ created: string[] }} The files written or extended, relative to the app root (the folder of hfs.json).
|
|
276
|
+
*/
|
|
277
|
+
export function addFeUnit({ repoRoot, noun, name, options = {} }) {
|
|
278
|
+
if (!Object.hasOwn(FE_UNIT_LAYERS, noun)) throw new ScaffoldError('HFS_ADD_UNKNOWN_NOUN', `${noun} is not a front-end unit tier`);
|
|
279
|
+
const Name = pascalOf(name);
|
|
280
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(Name)) throw new ScaffoldError('HFS_ADD_NAME_INVALID', `${name} is not a unit name`);
|
|
281
|
+
if (noun !== 'block' && options.shapes !== undefined) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', `a ${noun} has no shapes; a pure half with shapes is a block's`);
|
|
282
|
+
const declaration = declarationOf(repoRoot);
|
|
283
|
+
const place = { repoRoot, appBase: `fe/apps/${appOf(declaration, options.app)}`, ui: sharedUiOf(repoRoot, declaration) };
|
|
284
|
+
const created = [];
|
|
285
|
+
if (noun === 'block') addBlock({ created, place, names: { Name, nameCamel: camelOf(Name), nameKebab: words(Name).join('-') }, options });
|
|
286
|
+
else addVocabulary({ created, place, noun, Name });
|
|
287
|
+
return { created };
|
|
288
|
+
}
|
package/scaffold/add.mjs
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// starci app add reactor payment-status --event payment-settled --from payment --service PaymentStatusService=@modules/domain/order
|
|
12
12
|
// starci app add queue receipt modules/queues/receipt: the typed producer
|
|
13
13
|
// starci app add projection order-summary --connection order
|
|
14
|
+
// starci app add block handoff-block --shapes prepared,sent,returned a front-end unit in the shape-slot pattern (add-fe-unit.mjs)
|
|
15
|
+
// starci app add page handoffs | overlay invite-member --kind modal | layout settings a route page, overlay or segment layout
|
|
16
|
+
// (add-fe-page.mjs, add-fe-overlay.mjs, add-fe-layout.mjs)
|
|
14
17
|
//
|
|
15
18
|
// Body placeholders: for each variable `v` of the tree (job, step, event, ...) `@@v@@` is its kebab name, `@@V@@` its PascalCase and
|
|
16
19
|
// `@@vCamel@@` its camelCase, `@@vUpper@@` its UPPER_SNAKE and `@@vSnake@@` its snake_case; the option `--service Class=module` gives `@@service@@` (the class) and `@@serviceModule@@`.
|
|
@@ -20,6 +23,10 @@ import { ScaffoldError, pascalOf } from './service.mjs';
|
|
|
20
23
|
import { addTable, graphqlPlatformFiles } from './add-table.mjs';
|
|
21
24
|
import { addModuleImport, compositionCode, readComposeGroup, writeComposeGroup } from './add-compose.mjs';
|
|
22
25
|
import { addApp } from './add-app.mjs';
|
|
26
|
+
import { FE_UNIT_LAYERS, addFeUnit } from './add-fe-unit.mjs';
|
|
27
|
+
import { addFePage } from './add-fe-page.mjs';
|
|
28
|
+
import { addFeOverlay } from './add-fe-overlay.mjs';
|
|
29
|
+
import { addFeLayout } from './add-fe-layout.mjs';
|
|
23
30
|
import { ensureLiteCli } from './add-cli-lite.mjs';
|
|
24
31
|
import { addLiteWebhookInbox } from './add-inbox-lite.mjs';
|
|
25
32
|
import { planLiteQueue } from './add-queue-lite.mjs';
|
|
@@ -30,10 +37,17 @@ import { bundledPins } from '../runtime/scripts/hfs/canon-pins.mjs';
|
|
|
30
37
|
import { byCodeUnit } from '../report/order.mjs';
|
|
31
38
|
import { registerLiteDomainService, registerLiteExports } from './lite-exports.mjs';
|
|
32
39
|
|
|
40
|
+
/** The front-end owner nouns besides the component tiers: each one's generator, given the app root, the name and the options. */
|
|
41
|
+
const FE_OWNERS = Object.freeze({
|
|
42
|
+
page: ({ repoRoot, name, options }) => addFePage({ repoRoot, route: name, options }),
|
|
43
|
+
overlay: addFeOverlay,
|
|
44
|
+
layout: ({ repoRoot, name, options }) => addFeLayout({ repoRoot, segment: name, options }),
|
|
45
|
+
});
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* Generates the tree of one noun in the app at `repoRoot` (the folder of hfs.json).
|
|
35
49
|
*
|
|
36
|
-
* @param {{ repoRoot: string, noun: string, name: string, options?: object, now?: () => number }} input - The app root, the noun (job, reactor, queue, projection), the instance name and the options the noun needs.
|
|
50
|
+
* @param {{ repoRoot: string, noun: string, name: string, options?: object, now?: () => number }} input - The app root, the noun (job, reactor, queue, projection, ..., or a front-end unit: block, composite, branch, leaf, page, overlay, layout), the instance name and the options the noun needs.
|
|
37
51
|
* @returns {{ created: Array<string>, registered: { patterns: Array<string>, kinds: Array<string> } }} The files written (app-root relative) and what hfs.json gained.
|
|
38
52
|
*/
|
|
39
53
|
export function addKind({ repoRoot, noun, name, options = {}, now = Date.now }) {
|
|
@@ -45,6 +59,11 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
|
|
|
45
59
|
const result = addApp({ root: repoRoot, name });
|
|
46
60
|
return { ...result, registered: { patterns: [], kinds: [] } };
|
|
47
61
|
}
|
|
62
|
+
const feOwner = FE_OWNERS[noun];
|
|
63
|
+
if (feOwner !== undefined || Object.hasOwn(FE_UNIT_LAYERS, noun)) {
|
|
64
|
+
const result = feOwner === undefined ? addFeUnit({ repoRoot, noun, name, options }) : feOwner({ repoRoot, name, options });
|
|
65
|
+
return { ...result, registered: { patterns: [], kinds: [] } };
|
|
66
|
+
}
|
|
48
67
|
const context = readAddContext({ repoRoot, noun, name, options, now });
|
|
49
68
|
const builtin = addBuiltinLiteCli(context);
|
|
50
69
|
if (builtin) return builtin;
|
package/src/main.mjs
CHANGED
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
// exactly that kind's file tree, generated FROM the files: tree of its pattern topic (knowledge/patterns/be) with the
|
|
56
56
|
// one template body of each entry (templates/be/patterns), plus the platform capabilities it needs when they are missing;
|
|
57
57
|
// it registers the patterns and the trigger kind in hfs.json (sides.be.patterns, sides.be.kinds). Never overwrites a file (scaffold/add.mjs). In a lite app a noun whose slots lite does not have refuses: `add <noun>: full edition only`.
|
|
58
|
+
// starci app add <block|composite|branch|leaf> <name> [--shapes a,b,c] [--read <read>] [--app <fe app>] [--cwd <dir>]
|
|
59
|
+
// starci app add <page|layout> <route> [--blocks a,b] [--tabs a,b (page)] | overlay <name> --kind modal|drawer [--form] a route page, segment layout
|
|
60
|
+
// or overlay owner (scaffold/add-fe-page.mjs, add-fe-layout.mjs, add-fe-overlay.mjs).
|
|
61
|
+
// one front-end unit in the shape-slot pattern from templates/fe/units (scaffold/add-fe-unit.mjs); the
|
|
62
|
+
// first block writes the app's one copy of the status owners. Never overwrites a unit.
|
|
58
63
|
// Every finding names a why code and carries its Vietnamese text. check, lint and explain read the app, never write to it. Exit codes:
|
|
59
64
|
// 0 clean, 1 error findings, 2 a refusal or bad usage.
|
|
60
65
|
import fs from 'node:fs';
|
|
@@ -97,7 +102,7 @@ export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check',
|
|
|
97
102
|
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
98
103
|
|
|
99
104
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
100
|
-
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [options] [--cwd <dir>]
|
|
105
|
+
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|composite|branch|leaf|page|overlay|layout> <name> [options] [--cwd <dir>]
|
|
101
106
|
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
|
|
102
107
|
starci app codemod button-intent [--write] [--format json|markdown] [--report <file.json|file.md>] [--cwd <dir>]
|
|
103
108
|
starci app sync (--check | --write) [--cwd <dir>]
|
|
@@ -136,10 +141,10 @@ starci app hygiene [--cwd <dir>]
|
|
|
136
141
|
starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]
|
|
137
142
|
`;
|
|
138
143
|
const PER_CODE_LIMIT = 25;
|
|
139
|
-
const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility', '--shapes', '--read', '--app']);
|
|
144
|
+
const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility', '--shapes', '--read', '--app', '--kind', '--blocks', '--tabs']);
|
|
140
145
|
/** Flags that may repeat: their values are collected in order. */
|
|
141
146
|
const LIST_FLAGS = new Set(['--inject']);
|
|
142
|
-
const BOOL_FLAGS = new Set(['--json', '--fast', '--fe', '--no-types', '--db-types', '--wiring']);
|
|
147
|
+
const BOOL_FLAGS = new Set(['--json', '--fast', '--fe', '--no-types', '--db-types', '--wiring', '--form']);
|
|
143
148
|
|
|
144
149
|
function coverageArgument(arg, args, opts) {
|
|
145
150
|
const inline = arg.startsWith('--coverage-from=');
|
|
@@ -390,10 +395,11 @@ function newMain(opts, { cwd: repoRoot, stdout: out }) {
|
|
|
390
395
|
function addMain(opts, { cwd: repoRoot, stdout: out }) {
|
|
391
396
|
const [noun, name, ...extra] = opts.positional;
|
|
392
397
|
if (!noun || !name || extra.length) throw new Error('starci app add takes `<noun> <name>` and the options of the noun');
|
|
393
|
-
const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: opts.fe, noTypes: opts['no-types'], shapes: opts.shapes, read: opts.read, app: opts.app } });
|
|
398
|
+
const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: opts.fe, noTypes: opts['no-types'], shapes: opts.shapes, read: opts.read, app: opts.app, kind: opts.kind, form: opts.form, blocks: opts.blocks, tabs: opts.tabs } });
|
|
394
399
|
for (const file of created) out(`created ${file}\n`);
|
|
395
400
|
const kinds = registered.kinds.length ? `; kinds ${registered.kinds.join(', ')}` : '';
|
|
396
|
-
|
|
401
|
+
// A noun that registers nothing in hfs.json (a table, an app, a front-end unit) ends with what it wrote.
|
|
402
|
+
if (registered.patterns.length || registered.kinds.length) out(`starci app add ${noun}: registered patterns ${registered.patterns.join(', ')}${kinds}\n`);
|
|
397
403
|
return 0;
|
|
398
404
|
}
|
|
399
405
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Page } from "@starci/grammar/common"
|
|
2
2
|
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
3
3
|
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
4
4
|
|
|
@@ -8,11 +8,13 @@ export type AppHomePageBaseProps = {
|
|
|
8
8
|
readonly props: { readonly title: string }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Draw the front door of the product app as one Grammar Page (R325): the title heads the page band, and the body holds
|
|
13
|
+
* the sign-in form for a signed-out reader, the reader and sign-out once signed in.
|
|
14
|
+
*/
|
|
12
15
|
export const AppHomePageBase = (props: AppHomePageBaseProps) => (
|
|
13
|
-
|
|
14
|
-
<Heading level={1}>{props.props.title}</Heading>
|
|
16
|
+
<Page header={{ title: props.props.title }}>
|
|
15
17
|
<SignInForm />
|
|
16
18
|
<SignOutButton />
|
|
17
|
-
|
|
19
|
+
</Page>
|
|
18
20
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Button,
|
|
1
|
+
import { Button, Page } from "@starci/grammar/common"
|
|
2
2
|
|
|
3
3
|
/** Props for {@link LandingPageBase}. */
|
|
4
4
|
export type LandingPageBaseProps = {
|
|
@@ -12,13 +12,14 @@ export type LandingPageBaseProps = {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Draw the public front door as one Grammar Page (R325): the welcome heads the band with one sentence about the product
|
|
17
|
+
* under it, and the body holds the way into the product app.
|
|
18
|
+
*/
|
|
16
19
|
export const LandingPageBase = (props: LandingPageBaseProps) => (
|
|
17
|
-
|
|
18
|
-
<Heading level={1}>{props.props.title}</Heading>
|
|
19
|
-
<Text tone="muted">{props.props.lede}</Text>
|
|
20
|
+
<Page header={{ title: props.props.title, description: props.props.lede }}>
|
|
20
21
|
<Button href={props.props.appHref} intent="commit">
|
|
21
22
|
{props.props.openApp}
|
|
22
23
|
</Button>
|
|
23
|
-
|
|
24
|
+
</Page>
|
|
24
25
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Page } from "@starci/grammar/common"
|
|
2
2
|
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
3
3
|
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
4
4
|
|
|
@@ -7,11 +7,10 @@ export type AppHomePageBaseProps = {
|
|
|
7
7
|
readonly props: { readonly title: string }
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/** Draws the front door: the sign-in form
|
|
10
|
+
/** Draws the front door as one Grammar Page (R325): the title heads the band, the body the sign-in form or the reader and sign-out. */
|
|
11
11
|
export const AppHomePageBase = (props: AppHomePageBaseProps) => (
|
|
12
|
-
|
|
13
|
-
<Heading level={1}>{props.props.title}</Heading>
|
|
12
|
+
<Page header={{ title: props.props.title }}>
|
|
14
13
|
<SignInForm />
|
|
15
14
|
<SignOutButton />
|
|
16
|
-
|
|
15
|
+
</Page>
|
|
17
16
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SurfaceCard, Text } from "@starci/grammar/common"
|
|
2
|
+
import { SlotView } from "{{slotViewFrom}}"
|
|
3
|
+
import type { DataSlot, SlotLabels } from "@/modules/types"
|
|
4
|
+
{{shapeDeclarations}}
|
|
5
|
+
/** One read value as {{Name}} draws it. */
|
|
6
|
+
export interface {{Name}}Item {
|
|
7
|
+
readonly id: string
|
|
8
|
+
{{itemShapeMember}}}
|
|
9
|
+
|
|
10
|
+
/** Copy, the one read and the retry the connected {{Name}} supplies; atoms only. */
|
|
11
|
+
export interface {{Name}}BaseProps {
|
|
12
|
+
{{shapeMember}} readonly props: {
|
|
13
|
+
readonly title: string
|
|
14
|
+
{{shapeLabelsMember}} readonly item: DataSlot<{{Name}}Item>
|
|
15
|
+
readonly slotLabels: SlotLabels
|
|
16
|
+
}
|
|
17
|
+
readonly on: { readonly retry: () => void }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A value of the same shape as the read: the skeleton draws the ready tree from it{{placeholderShapeDoc}}. */
|
|
21
|
+
export const {{NAME_UPPER}}_PLACEHOLDER: {{Name}}Item = { id: ""{{placeholderShape}} }
|
|
22
|
+
{{shapeBody}}
|
|
23
|
+
/** Draws {{Name}} from atoms: its read renders every status through SlotView, inside the unchanged surface. */
|
|
24
|
+
export const {{Name}}Base = (props: {{Name}}BaseProps) => (
|
|
25
|
+
<SurfaceCard label={props.props.title}>
|
|
26
|
+
<SlotView
|
|
27
|
+
slot={props.props.item}
|
|
28
|
+
placeholder={{{NAME_UPPER}}_PLACEHOLDER}
|
|
29
|
+
labels={props.props.slotLabels}
|
|
30
|
+
onRetry={props.on.retry}
|
|
31
|
+
>
|
|
32
|
+
{(item, isSkeleton) => {{readyTree}}}
|
|
33
|
+
</SlotView>
|
|
34
|
+
</SurfaceCard>
|
|
35
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl"
|
|
4
|
+
import { mapSlot, slotOfSwr, useSlotLabels } from "@/hooks/slot"
|
|
5
|
+
import { useQuery{{Read}}Swr } from "@/hooks/swr"
|
|
6
|
+
import { {{componentImports}} } from "./component"
|
|
7
|
+
{{componentTypeImport}}
|
|
8
|
+
/** Input of {{Name}}: the id of the value it reads. */
|
|
9
|
+
type {{Name}}Props = {
|
|
10
|
+
readonly id: string
|
|
11
|
+
}
|
|
12
|
+
{{shapeOfHelper}}
|
|
13
|
+
/** The connected {{Name}}: folds its one read into a slot, {{connectedDoc}}resolves every label and the retry, and renders its pure half. */
|
|
14
|
+
export const {{Name}} = (props: {{Name}}Props) => {
|
|
15
|
+
const t = useTranslations("{{nameCamel}}")
|
|
16
|
+
const slotLabels = useSlotLabels()
|
|
17
|
+
const read = useQuery{{Read}}Swr(props.id)
|
|
18
|
+
const item = mapSlot(slotOfSwr(read), (value) => ({ id: value.id{{itemShapeMapped}} }))
|
|
19
|
+
return (
|
|
20
|
+
<{{Name}}Base
|
|
21
|
+
{{shapeAttribute}} props={{
|
|
22
|
+
title: t("title"),
|
|
23
|
+
{{shapeLabelsResolved}} item,
|
|
24
|
+
slotLabels: slotLabels(t("empty")),
|
|
25
|
+
}}
|
|
26
|
+
on={{ retry: () => void read.mutate() }}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
2
|
+
import { {{Name}}Base } from "../../src/components/blocks/{{Name}}/component"
|
|
3
|
+
import type { {{Name}}BaseProps } from "../../src/components/blocks/{{Name}}/component"
|
|
4
|
+
import { loadingSlot } from "../../src/hooks/slot/slot.shared"
|
|
5
|
+
|
|
6
|
+
/** The ready read every story draws: one value, settled. */
|
|
7
|
+
const READY: {{Name}}BaseProps["props"] = {
|
|
8
|
+
title: "{{title}}",
|
|
9
|
+
{{shapeLabelsFixture}} item: { items: { id: "{{nameKebab}}-1"{{fixtureShape}} } },
|
|
10
|
+
slotLabels: { empty: "Nothing yet", forbidden: "Not allowed", error: "Could not load", retry: "Try again" },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The pure half of {{Name}} drawn from fixtures: {{storiesDoc}} and the loading twin of the first. */
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "{{Name}}",
|
|
16
|
+
component: {{Name}}Base,
|
|
17
|
+
args: { {{shapeArg}}props: READY, on: { retry: () => undefined } },
|
|
18
|
+
} satisfies Meta<typeof {{Name}}Base>
|
|
19
|
+
|
|
20
|
+
export default meta
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof meta>
|
|
23
|
+
{{readyStories}}
|
|
24
|
+
|
|
25
|
+
/** The loading twin of {{firstStory}}: the same tree drawn from the placeholder with every leaf resting. */
|
|
26
|
+
export const {{firstStory}}Skeleton: Story = {
|
|
27
|
+
args: { {{shapeArg}}props: { ...READY, item: loadingSlot() } },
|
|
28
|
+
parameters: { dna: { pair: "{{firstStory}}", phase: "loading" } },
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { Button } from "@starci/grammar/common"
|
|
4
|
+
|
|
5
|
+
/** The resolved labels of the toggle and the content it shows; the branch owns only whether it is open. */
|
|
6
|
+
type {{Name}}Props = {
|
|
7
|
+
readonly showLabel: string
|
|
8
|
+
readonly hideLabel: string
|
|
9
|
+
readonly children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Holds its intrinsic state alone (open or closed); it reads no data, resolves no copy and renders below a client block. */
|
|
13
|
+
export const {{Name}} = (props: {{Name}}Props) => {
|
|
14
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<Button intent="neutral" onPress={() => setIsOpen((current) => !current)}>
|
|
18
|
+
{isOpen ? props.hideLabel : props.showLabel}
|
|
19
|
+
</Button>
|
|
20
|
+
{isOpen ? props.children : null}
|
|
21
|
+
</>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Text } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** The resolved fields {{Name}} draws; its block resolves the copy and passes the waiting flag down. */
|
|
4
|
+
type {{Name}}Props = {
|
|
5
|
+
readonly term: string
|
|
6
|
+
readonly value: string
|
|
7
|
+
readonly isSkeleton: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A closed arrangement of Grammar parts: a term and its value, each resting as itself while its block waits. */
|
|
11
|
+
export const {{Name}} = (props: {{Name}}Props) => (
|
|
12
|
+
<>
|
|
13
|
+
<Text tone="muted" isSkeleton={props.isSkeleton}>
|
|
14
|
+
{props.term}
|
|
15
|
+
</Text>
|
|
16
|
+
<Text isSkeleton={props.isSkeleton}>{props.value}</Text>
|
|
17
|
+
</>
|
|
18
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { PrimaryRailLayout } from "@starci/grammar/common"
|
|
3
|
+
{{blockImports}}
|
|
4
|
+
/** Framework-layout boundary input: the routed page of the {{segment}} segment and nothing else. */
|
|
5
|
+
type {{Name}}Props = { readonly content: ReactNode }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The {{segment}} segment's frame: Grammar's PrimaryRailLayout places the routed page and {{railDoc}}. A frame only places
|
|
9
|
+
* regions and never insets a page; the Page inside owns its inset and rhythm.
|
|
10
|
+
*/
|
|
11
|
+
export const {{Name}} = (props: {{Name}}Props) => {{frame}}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { {{Name}} } from "@/features/layouts/{{Name}}"
|
|
3
|
+
|
|
4
|
+
type LayoutProps = { readonly children: ReactNode }
|
|
5
|
+
|
|
6
|
+
/** The {{segment}} segment's layout slot: it mounts {{Name}} around the routed page and nothing else. */
|
|
7
|
+
const Layout = (props: LayoutProps) => <{{Name}} content={props.children} />
|
|
8
|
+
|
|
9
|
+
export default Layout
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Badge } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** One resolved label and the waiting flag of the block that draws it. */
|
|
4
|
+
type {{Name}}Props = {
|
|
5
|
+
readonly label: string
|
|
6
|
+
readonly isSkeleton: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A fixed semantic unit over one Grammar leaf; it rests as itself by passing `isSkeleton` on. */
|
|
10
|
+
export const {{Name}} = (props: {{Name}}Props) => <Badge isSkeleton={props.isSkeleton}>{props.label}</Badge>
|