@starci/hfs 4.9.5 → 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 +24 -0
- package/README.md +24 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +1 -1
- package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +4 -3
- 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/app/hooks/husky/pre-push +4 -1
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.9.8 - 2026-10-28
|
|
4
|
+
|
|
5
|
+
- Added: `starci app add page <route> --tabs a,b` puts the page tabs in the `Page` header as link tabs (Grammar 0.15.6): the first tab is the route itself, every other one its sub-route `<route>/<tab>`; the server owner resolves the labels (`<page>.tabs.label` and `<page>.tabs.items.<tab>` in every catalog) and passes no state or handler, and the stories carry the tabs. Fewer than two or repeated tab ids are refused (`HFS_ADD_TABS_INVALID`).
|
|
6
|
+
- Changed: `starci app add overlay --form` keeps the decision group in the shell's footer: the way out first, then `<Button type="submit" form="<id>">` naming the body's `Form id` (Grammar 0.15.6 `Button form`), instead of buttons inside the form; the shell no longer passes `closeLabel`, since the footer offers the way out.
|
|
7
|
+
- Fixed: the skeleton's `AppHomePage` (full and lite) and the landing app's `LandingPage` draw one Grammar `Page` with the title (and the landing's lede as `description`) in its header, instead of a bare `Heading` and blocks, which R325 (`@starci/eslint-grammar-core` 0.15.6) now refuses.
|
|
8
|
+
- Changed: the bundled canon pins name the Grammar family 0.15.6 and hfs 4.9.8.
|
|
9
|
+
|
|
10
|
+
## 4.9.7 - 2026-10-28
|
|
11
|
+
|
|
12
|
+
- Added: `starci app add block|composite|branch|leaf|page|overlay|layout` writes every front-end unit already in the canon's shape, from `templates/fe/units` (`scaffold/add-fe-unit.mjs`, `add-fe-page.mjs`, `add-fe-overlay.mjs`, `add-fe-layout.mjs`), with its copy in every catalog; nothing that exists is overwritten.
|
|
13
|
+
- `block <name> [--shapes a,b,c] [--read <read>]`: the connected half folds one `useQuery<Read>Swr` read through `slotOfSwr`/`mapSlot` and takes its shape from the read's `status` (the placeholder's while it loads); the pure half draws through `SlotView`, and with shapes declares `X_SHAPES`, `XShape = (typeof X_SHAPES)[number]` and the exhaustive switch (FE-FUNCTION-7); `stories/blocks/<Name>.stories.tsx` has one story per shape and the loading twin paired by `parameters.dna`. The first block writes the app's one copy of the status owners (`DataSlot`, `SlotLabels`, `slotOfSwr`, `mapSlot`, `loadingSlot`, `useSlotLabels`, `SlotView`) and the shared `status` namespace.
|
|
14
|
+
- `composite`, `branch`, `leaf`: one file of Grammar parts on resolved fields; in a front end of two or more apps with the shared ui package they, and `SlotView`, go to that package and its barrel (R91), `DataSlot` and `SlotLabels` declared beside `SlotView` and re-exported by `modules/types`.
|
|
15
|
+
- `page <route> [--blocks a,b]`: the route's `page`, `loading` and `error` files, the server page owner drawing one Grammar `Page` (R325) with the trail and heading in its header and the blocks (or its empty sentence) in its body, its loading view (the same page with `isSkeleton`, STATE-8), its error page (the page with one negative Alert) and the stories pairing the page with its loading view.
|
|
16
|
+
- `overlay <name> --kind modal|drawer [--form]`: a `Dialog` or trailing `Drawer` owner whose connected half owns its open state; way out first and commit last, in the footer or, with `--form`, as the last row of a Form over a one-column `FormGrid`; stories closed and open.
|
|
17
|
+
- `layout <segment> [--blocks a,b]`: the segment's layout file and its owner composing `PrimaryRailLayout`, the page in its primary region and the blocks in its rail.
|
|
18
|
+
- Spec: `src/fe-unit-shape-slot.spec.mjs` adds every kind to a scaffolded full app and lints it with the starci-fe canon and the core Grammar's rules, and type-checks them under the next preset.
|
|
19
|
+
- Changed: `starci app add` prints its `registered patterns` line only for a noun that registers something in hfs.json.
|
|
20
|
+
- Changed: the bundled canon pins name hfs 4.9.7.
|
|
21
|
+
|
|
22
|
+
## 4.9.6 - 2026-10-28
|
|
23
|
+
|
|
24
|
+
- Changed: the bundled canon pins name hfs 4.9.6, the Grammar family 0.15.5 (grammar, eslint-grammar-core, eslint-grammar-offset-pop) and eslint-canon-fe 9.4.5.
|
|
25
|
+
- Changed: the app pre-push hook runs `node node_modules/starci-harness/dist/main.js deps check` after the bundled push gate (under `set -e`), so a push whose committed @starci pins npm cannot install, or whose local package copies run ahead of npm, is refused; RT_HOOK_SHAPE (R225) requires the call. Spec: `src/pre-push-hook.spec.mjs`.
|
|
26
|
+
|
|
3
27
|
## 4.9.5 - 2026-10-28
|
|
4
28
|
|
|
5
29
|
- Added: the bundled rule catalog carries R324 (`FE_PAGE_TABS_OUTSIDE_HEADER`, `page-tabs-in-header`) and R325 (`FE_ROUTE_PAGE_OUTSIDE_PAGE`, `route-page-is-one-page`), both enforced by `@starci/eslint-grammar-core`, and their failure-code guidance.
|
package/README.md
CHANGED
|
@@ -10,6 +10,9 @@ Every command runs at the app root. A side is judged with its folder as the root
|
|
|
10
10
|
```sh
|
|
11
11
|
starci app scaffold <name> [--into <dir>] [--edition full|lite] [--cwd <dir>]
|
|
12
12
|
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [--fe] [--no-types] [options] [--cwd <dir>]
|
|
13
|
+
starci app add <block|composite|branch|leaf> <name> [--shapes a,b,c] [--read <read>] [--app <fe app>] [--cwd <dir>]
|
|
14
|
+
starci app add <page|layout> <route> [--blocks a,b] [--tabs a,b] [--app <fe app>] [--cwd <dir>]
|
|
15
|
+
starci app add overlay <name> --kind modal|drawer [--form] [--app <fe app>] [--cwd <dir>]
|
|
13
16
|
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
|
|
14
17
|
starci app sync (--check | --write) [--cwd <dir>]
|
|
15
18
|
starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--db-types]
|
|
@@ -24,6 +27,27 @@ starci app secret list|show|set|gen ... [--cwd <dir>]
|
|
|
24
27
|
starci app hygiene [--cwd <dir>]
|
|
25
28
|
```
|
|
26
29
|
|
|
30
|
+
`starci app add` writes every front-end unit, in either edition, already in the shape of the harness's `contexts/code/fe`
|
|
31
|
+
and `contexts/ui`, from `templates/fe/units`, with its copy in every catalog; nothing that exists is overwritten.
|
|
32
|
+
|
|
33
|
+
- `block`: its connected half (one `useQuery<Read>Swr` read folded by `slotOfSwr` and `mapSlot`, its labels and the retry)
|
|
34
|
+
over its pure half of Grammar parts drawn through `SlotView`, plus `stories/blocks/<Name>.stories.tsx` (one story per drawn
|
|
35
|
+
shape and the loading twin, paired for the DNA gate). `--shapes a,b,c` adds the `X_SHAPES` inventory, the `XShape` union
|
|
36
|
+
and the exhaustive switch, and the connected half takes the shape from the read's `status`. The first block writes the
|
|
37
|
+
app's one copy of the status owners and the shared `status` catalog namespace (FE-ERROR-1).
|
|
38
|
+
- `composite`, `branch`, `leaf`: one file of Grammar parts taking resolved fields. In a front end of two or more apps with
|
|
39
|
+
the shared ui package these, and `SlotView`, go to that package and its barrel (R91).
|
|
40
|
+
- `page <route>`: `app/[locale]/<route>/{page,loading,error}.tsx`, the server page owner `<Name>Page` drawing one Grammar
|
|
41
|
+
`Page` (R325) whose header carries the trail and the heading and whose body composes `--blocks` (blocks that take no input;
|
|
42
|
+
else the page's empty sentence) and, with `--tabs a,b`, the page tabs in its header as link tabs (the first tab the route, each
|
|
43
|
+
other one its sub-route `<route>/<tab>`, the selected tab the current path's), its loading view `<Name>LoadingPage` (the same page drawn with `isSkeleton`, STATE-8),
|
|
44
|
+
`<Name>ErrorPage` (the page with one negative Alert), and the stories pairing the page with its loading view.
|
|
45
|
+
- `overlay <name> --kind modal|drawer`: `features/overlays/<Name>Overlay`, a `Dialog` or a trailing `Drawer` whose connected
|
|
46
|
+
half owns its open state; its decision group puts the way out first and the commit last, always in the footer; with `--form`
|
|
47
|
+
the body holds a Form with an `id` over a one-column `FormGrid` and the footer's commit submits it by `form="<id>"`; stories draw it closed and open.
|
|
48
|
+
- `layout <segment>`: `app/[locale]/<segment>/layout.tsx` and `features/layouts/<Name>Layout`, which composes Grammar's
|
|
49
|
+
`PrimaryRailLayout`: the routed page in its primary region, `--blocks` in its rail. A frame never insets a page.
|
|
50
|
+
|
|
27
51
|
## Lite edition
|
|
28
52
|
|
|
29
53
|
Lite is the smaller edition of the same HFS standard, not another profile or engine. The declaration sets `edition` to `lite`;
|
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.15.
|
|
34
|
+
version: 0.15.6
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
@@ -45,21 +45,21 @@ pins:
|
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.4.
|
|
48
|
+
version: 9.4.5
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.15.
|
|
55
|
+
version: 0.15.6
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.15.
|
|
62
|
+
version: 0.15.6
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 4.9.
|
|
109
|
+
version: 4.9.8
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -3894,7 +3894,7 @@ rules:
|
|
|
3894
3894
|
- {kind: runtime, id: protected-zone, at: scripts/hfs/runtime-rules/rights-policy.mjs}
|
|
3895
3895
|
- id: R225
|
|
3896
3896
|
code: "RT_HOOK_SHAPE"
|
|
3897
|
-
law: "The app hook templates keep the gate model: pre-commit is L0 only (no typecheck or test run); pre-push invokes the bundled scripts/guards/release-push-gate.mjs policy for fast-forward main, a clean outgoing secret scan and annotated v* tags. It runs no build, test, typecheck or lint."
|
|
3897
|
+
law: "The app hook templates keep the gate model: pre-commit is L0 only (no typecheck or test run); pre-push invokes the bundled scripts/guards/release-push-gate.mjs policy for fast-forward main, a clean outgoing secret scan and annotated v* tags, then the harness's `starci deps check` (every committed @starci pin installable from npm, no local package copy ahead of npm). It runs no build, test, typecheck or lint."
|
|
3898
3898
|
scope: runtime
|
|
3899
3899
|
kinds: [check]
|
|
3900
3900
|
gates: [land, runtime]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// hook-shape.mjs - RT_HOOK_SHAPE (knowledge/hfs/rules.yaml, gate runtime): the app hook templates keep the gate model.
|
|
2
2
|
// pre-commit L0 only: no typecheck and no test run (`test:` scripts, npm test, jest, vitest, a spec). Types and the targeted specs
|
|
3
3
|
// belong to the op gate.
|
|
4
|
-
// pre-push the bundled push gate owns fast-forward main, annotated tags and the secret scan
|
|
4
|
+
// pre-push the bundled push gate owns fast-forward main, annotated tags and the secret scan, then `starci deps check` the
|
|
5
|
+
// app's @starci pins; no build, test or lint run.
|
|
5
6
|
// Guard hooks keep their host timeout above the runtime's own input, verdict, cleanup and output budgets.
|
|
6
7
|
// Comments are not judged (the templates explain what they do not run). Pure apart from ctx.read.
|
|
7
8
|
import { shellCommandSource } from '../../lib/shell-source.mjs';
|
|
@@ -23,7 +24,7 @@ const PRE_PUSH_FORBIDDEN = [
|
|
|
23
24
|
[/\btypecheck\b/, 'a typecheck'],
|
|
24
25
|
[/\blint\b/, 'a lint run'],
|
|
25
26
|
];
|
|
26
|
-
const PRE_PUSH_MARKERS = ["node --input-type=module -e", "import { main } from '@starci/hfs/runtime/scripts/guards/release-push-gate.mjs'", 'process.exit(await main())'];
|
|
27
|
+
const PRE_PUSH_MARKERS = ["node --input-type=module -e", "import { main } from '@starci/hfs/runtime/scripts/guards/release-push-gate.mjs'", 'process.exit(await main())', 'node node_modules/starci-harness/dist/main.js deps check'];
|
|
27
28
|
|
|
28
29
|
const finding = (file, message) => ({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: ${message}` });
|
|
29
30
|
|
|
@@ -39,7 +40,7 @@ function fileHookShapeFindings({ path: file, name, text }) {
|
|
|
39
40
|
const missing = PRE_PUSH_MARKERS.filter((marker) => !body.includes(marker));
|
|
40
41
|
if (missing.length) {
|
|
41
42
|
const markers = missing.map((m) => `\`${m}\``).join(', ');
|
|
42
|
-
found.push(finding(file, `the push gate lacks the bundled gate marker${missing.length === 1 ? '' : 's'} ${markers}; it must invoke the shared fast-forward, annotation and secret-scan policy`));
|
|
43
|
+
found.push(finding(file, `the push gate lacks the bundled gate marker${missing.length === 1 ? '' : 's'} ${markers}; it must invoke the shared fast-forward, annotation and secret-scan policy and the dependency push gate`));
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
return found;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// starci app add layout <segment> [--blocks a,b] [--app <fe app>] - one route-segment layout in the canon's shape.
|
|
2
|
+
//
|
|
3
|
+
// starci app add layout settings app/[locale]/settings/layout.tsx and features/layouts/SettingsLayout
|
|
4
|
+
// starci app add layout settings --blocks settings-nav-block the blocks fill the frame's rail
|
|
5
|
+
//
|
|
6
|
+
// The route file only mounts the layout owner (contexts/code/fe architecture.md FE-ARCHITECTURE-1). The owner is a server
|
|
7
|
+
// component that composes a Grammar layout frame, `PrimaryRailLayout`: the routed page is its primary region and the `--blocks`
|
|
8
|
+
// (existing blocks of components/blocks) its rail. A frame places regions only and never insets a page or draws a page head;
|
|
9
|
+
// `Page` owns both (contexts/ui/components page-header.md). The owner has no copy to resolve, so it has no pure half and no story:
|
|
10
|
+
// the frame's look is the Grammar's and the regions are the page's and the blocks'. The bodies are templates/fe/units/layout/.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { ScaffoldError, pascalOf } from './service.mjs';
|
|
14
|
+
import { appOf, declarationOf, fill, writeNew } from './add-fe-unit.mjs';
|
|
15
|
+
import { blockParts, blocksOf, routeSegmentsOf } from './add-fe-page.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates one route-segment layout in the app at `repoRoot`.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ repoRoot: string, segment: string, options?: { app?: string, blocks?: string } }} input - The app root, the segment under [locale] and the options.
|
|
21
|
+
* @returns {{ created: string[] }} The files written, relative to the app root.
|
|
22
|
+
*/
|
|
23
|
+
export function addFeLayout({ repoRoot, segment, options = {} }) {
|
|
24
|
+
const segments = routeSegmentsOf(segment);
|
|
25
|
+
const appBase = `fe/apps/${appOf(declarationOf(repoRoot), options.app)}`;
|
|
26
|
+
const Name = `${pascalOf(segments.join('-'))}Layout`;
|
|
27
|
+
const route = `${appBase}/src/app/[locale]/${segments.join('/')}/layout.tsx`;
|
|
28
|
+
const owner = `${appBase}/src/features/layouts/${Name}`;
|
|
29
|
+
for (const relative of [route, owner]) {
|
|
30
|
+
if (fs.existsSync(path.join(repoRoot, ...relative.split('/')))) throw new ScaffoldError('HFS_ADD_EXISTS', `${relative} already exists; starci app add never overwrites a unit`);
|
|
31
|
+
}
|
|
32
|
+
const blocks = blocksOf(repoRoot, appBase, options.blocks);
|
|
33
|
+
const parts = blockParts(blocks, ' ');
|
|
34
|
+
const vars = {
|
|
35
|
+
Name, segment: segments.join('/'),
|
|
36
|
+
blockImports: parts.imports,
|
|
37
|
+
railDoc: blocks.length === 0 ? 'leaves its rail empty until a block fills it' : `fills its rail with ${blocks.join(', ')}`,
|
|
38
|
+
frame: blocks.length === 0
|
|
39
|
+
? '<PrimaryRailLayout primary={props.content} />'
|
|
40
|
+
: ['(', ' <PrimaryRailLayout', ' primary={props.content}', ' rail={', ' <>', parts.jsx, ' </>', ' }', ' />', ')'].join('\n'),
|
|
41
|
+
};
|
|
42
|
+
const created = [];
|
|
43
|
+
writeNew(created, repoRoot, route, fill('layout/route-layout.tsx.tpl', vars));
|
|
44
|
+
writeNew(created, repoRoot, `${owner}/index.tsx`, fill('layout/index.tsx.tpl', vars));
|
|
45
|
+
return { created };
|
|
46
|
+
}
|
|
@@ -1 +1,109 @@
|
|
|
1
|
+
// starci app add overlay <name> --kind modal|drawer [--form] [--app <fe app>] - one overlay owner in the canon's shape.
|
|
2
|
+
//
|
|
3
|
+
// starci app add overlay invite-member --kind modal features/overlays/InviteMemberOverlay: a Dialog
|
|
4
|
+
// starci app add overlay edit-details --kind drawer --form a Drawer from the trailing edge holding a one-column form
|
|
5
|
+
//
|
|
6
|
+
// The two shells an overlay owner may draw are Grammar's `Dialog` (a modal) and `Drawer` (contexts/ui/components
|
|
7
|
+
// dialog-drawer.md). The connected half (`"use client"`, an overlay entry of FE-BOUNDARY-2) owns whether it is open and resolves
|
|
8
|
+
// every label; the pure half takes `{ props, on }` of atoms (FE-FUNCTION-2). The decision group puts the way out first and the
|
|
9
|
+
// commit last (action-group.md `decision`, CTA-5), always in the shell's footer. With `--form`, the body holds a Form with an `id`
|
|
10
|
+
// whose fields sit in a one-column `FormGrid` (text-field.md `narrow`), and the footer's commit is `type="submit"` with
|
|
11
|
+
// `form="<id>"`, so it submits that form from outside it. The bodies are templates/fe/units/overlay/; the stories draw it closed and open. A page or layout owner mounts it.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { ScaffoldError, camelOf, pascalOf } from './service.mjs';
|
|
15
|
+
import { addCatalogNamespaces, appOf, declarationOf, fill, humanOf, words, writeNew } from './add-fe-unit.mjs';
|
|
1
16
|
|
|
17
|
+
/** The shells an overlay may be: `--kind` to Grammar component. */
|
|
18
|
+
const SHELLS = Object.freeze({ modal: 'Dialog', drawer: 'Drawer' });
|
|
19
|
+
|
|
20
|
+
/** The copy of a new overlay, by catalog locale; any other locale takes the English words. */
|
|
21
|
+
const OVERLAY_COPY = Object.freeze({
|
|
22
|
+
en: { open: 'Open', cancel: 'Cancel', submit: 'Save', description: 'Describe the task this overlay holds.', name: 'Name' },
|
|
23
|
+
vi: { open: 'Mở', cancel: 'Hủy', submit: 'Lưu', description: 'Mô tả việc mà lớp phủ này đảm nhận.', name: 'Tên' },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** The variables of the overlay templates for one shell, with or without a form. */
|
|
27
|
+
function overlayVars({ Name, nameKebab, shell, form }) {
|
|
28
|
+
const decision = (indent, cancel, commit) => [
|
|
29
|
+
`${indent}<Button intent="dismiss" onPress={${cancel}}>`,
|
|
30
|
+
`${indent} {props.props.cancelLabel}`,
|
|
31
|
+
`${indent}</Button>`,
|
|
32
|
+
`${indent}${commit}`,
|
|
33
|
+
`${indent} {props.props.submitLabel}`,
|
|
34
|
+
`${indent}</Button>`,
|
|
35
|
+
].join('\n');
|
|
36
|
+
const shellAttributes = shell === 'Drawer' ? ' placement="right"\n' : '';
|
|
37
|
+
/** The shell's footer: the decision group, the way out first and the commit last. */
|
|
38
|
+
const footer = (commit) => [
|
|
39
|
+
' footer={(close) => (',
|
|
40
|
+
' <>',
|
|
41
|
+
decision(' ', 'close', commit),
|
|
42
|
+
' </>',
|
|
43
|
+
' )}',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
if (form) {
|
|
47
|
+
const formId = `${nameKebab}-form`;
|
|
48
|
+
return {
|
|
49
|
+
grammarImports: ['Button', shell, 'Form', 'FormGrid', 'Input'].sort().join(', '),
|
|
50
|
+
labelMembers: ' readonly nameLabel: string\n',
|
|
51
|
+
labelsResolved: ' nameLabel: t("name"),\n',
|
|
52
|
+
labelsFixture: ' nameLabel: "Name",\n',
|
|
53
|
+
submitMember: ' readonly submit: (input: FormData) => void\n',
|
|
54
|
+
submitHandler: 'submit: () => setIsOpen(false)',
|
|
55
|
+
submitFixture: 'submit: () => undefined',
|
|
56
|
+
doc: `The ${shell} holding one form: its fields in one column in the body, the footer's way out first and the submit, which names the form, last.`,
|
|
57
|
+
shellAttributes,
|
|
58
|
+
footer: footer(`<Button type="submit" form="${formId}" intent="commit">`),
|
|
59
|
+
body: [
|
|
60
|
+
` <Form id="${formId}" label={props.props.title} onSubmit={props.on.submit}>`,
|
|
61
|
+
' <FormGrid columns={1}>',
|
|
62
|
+
` <Input id="${nameKebab}-name" name="name" label={props.props.nameLabel} isRequired />`,
|
|
63
|
+
' </FormGrid>',
|
|
64
|
+
' </Form>',
|
|
65
|
+
].join('\n'),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
grammarImports: ['Button', shell, 'Text'].sort().join(', '),
|
|
70
|
+
labelMembers: ' readonly description: string\n',
|
|
71
|
+
labelsResolved: ' description: t("description"),\n',
|
|
72
|
+
labelsFixture: ' description: "Describe the task this overlay holds.",\n',
|
|
73
|
+
submitMember: ' readonly confirm: () => void\n',
|
|
74
|
+
submitHandler: 'confirm: () => setIsOpen(false)',
|
|
75
|
+
submitFixture: 'confirm: () => undefined',
|
|
76
|
+
doc: `The ${shell} of one task: its sentence, and the footer's way out first and commit last.`,
|
|
77
|
+
shellAttributes,
|
|
78
|
+
footer: footer('<Button intent="commit" onPress={props.on.confirm}>'),
|
|
79
|
+
body: ' <Text>{props.props.description}</Text>',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generates one overlay owner in the app at `repoRoot`.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ repoRoot: string, name: string, options?: { app?: string, kind?: string, form?: boolean } }} input - The app root, the overlay name and the options.
|
|
87
|
+
* @returns {{ created: string[] }} The files written or extended, relative to the app root.
|
|
88
|
+
*/
|
|
89
|
+
export function addFeOverlay({ repoRoot, name, options = {} }) {
|
|
90
|
+
const shell = SHELLS[options.kind];
|
|
91
|
+
if (shell === undefined) throw new ScaffoldError('HFS_ADD_OVERLAY_KIND', `an overlay names its shell with --kind ${Object.keys(SHELLS).join('|')}; those are the only two`);
|
|
92
|
+
const base = pascalOf(name);
|
|
93
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(base)) throw new ScaffoldError('HFS_ADD_NAME_INVALID', `${name} is not a unit name`);
|
|
94
|
+
const Name = base.endsWith('Overlay') ? base : `${base}Overlay`;
|
|
95
|
+
const appBase = `fe/apps/${appOf(declarationOf(repoRoot), options.app)}`;
|
|
96
|
+
const folder = `${appBase}/src/features/overlays/${Name}`;
|
|
97
|
+
if (fs.existsSync(path.join(repoRoot, ...folder.split('/')))) throw new ScaffoldError('HFS_ADD_EXISTS', `${folder} already exists; starci app add never overwrites a unit`);
|
|
98
|
+
const form = options.form === true;
|
|
99
|
+
const vars = { Name, shell, nameCamel: camelOf(Name), title: humanOf(words(base).join('-')), ...overlayVars({ Name, nameKebab: words(Name).join('-'), shell, form }) };
|
|
100
|
+
const created = [];
|
|
101
|
+
writeNew(created, repoRoot, `${folder}/index.tsx`, fill('overlay/index.tsx.tpl', vars));
|
|
102
|
+
writeNew(created, repoRoot, `${folder}/component.tsx`, fill('overlay/component.tsx.tpl', vars));
|
|
103
|
+
writeNew(created, repoRoot, `${appBase}/stories/overlays/${Name}.stories.tsx`, fill('overlay/stories.tsx.tpl', vars));
|
|
104
|
+
addCatalogNamespaces(created, { repoRoot, appBase }, (locale) => {
|
|
105
|
+
const copy = OVERLAY_COPY[locale] ?? OVERLAY_COPY.en;
|
|
106
|
+
return { [vars.nameCamel]: { title: vars.title, open: copy.open, ...(form ? { name: copy.name } : { description: copy.description }), cancel: copy.cancel, submit: copy.submit } };
|
|
107
|
+
});
|
|
108
|
+
return { created };
|
|
109
|
+
}
|
package/scaffold/add-fe-page.mjs
CHANGED
|
@@ -1 +1,138 @@
|
|
|
1
|
+
// starci app add page <route> [--blocks a,b] [--tabs a,b] [--app <fe app>] - one route page of the locale segment, in the canon's shape.
|
|
2
|
+
//
|
|
3
|
+
// starci app add page handoffs app/[locale]/handoffs/{page,loading,error}.tsx, HandoffsPage, HandoffsErrorPage
|
|
4
|
+
// starci app add page settings/billing --blocks invoice-list-block
|
|
5
|
+
// starci app add page settings --tabs profile,billing header.tabs: Profile at /settings, Billing at /settings/billing
|
|
6
|
+
//
|
|
7
|
+
// The route files only adapt (contexts/code/fe function.md FE-FUNCTION-5, architecture.md FE-ARCHITECTURE-1, boundaries.md
|
|
8
|
+
// FE-BOUNDARY-1). The page owner `features/pages/<Name>Page` is a server component that resolves its copy and draws one Grammar
|
|
9
|
+
// `Page` (R325) whose header carries the trail and the heading (page commands and page tabs go to the header, R324), and whose body
|
|
10
|
+
// composes the `--blocks` (blocks of components/blocks that already exist), or the page's empty sentence when none is named. With
|
|
11
|
+
// `--tabs`, the header carries the page tabs as link tabs (contexts/ui/components tabs.md `level:page`): the first tab is the route
|
|
12
|
+
// itself and every other one its sub-route `<route>/<tab>`, which the author adds as its own page; Grammar selects the tab of the
|
|
13
|
+
// current path, so the server owner passes no state and no handler. The
|
|
14
|
+
// owner also exports the route's loading view, the same page drawn with `isSkeleton` (contexts/ui composition.md STATE-8), and
|
|
15
|
+
// `<Name>ErrorPage` keeps the page and puts one negative Alert where the first region would be. The bodies are
|
|
16
|
+
// templates/fe/units/page/; the stories pair the page with its loading view for the DNA gate.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { ScaffoldError, camelOf, pascalOf } from './service.mjs';
|
|
20
|
+
import { addCatalogNamespaces, appOf, declarationOf, fill, humanOf, words, writeNew } from './add-fe-unit.mjs';
|
|
1
21
|
|
|
22
|
+
/** The copy of a new page, by catalog locale; any other locale takes the English sentences. */
|
|
23
|
+
const PAGE_COPY = Object.freeze({
|
|
24
|
+
en: { breadcrumb: 'Breadcrumb', home: 'Home', empty: 'Nothing to show yet.', failed: 'This page could not be loaded.', retry: 'Try again' },
|
|
25
|
+
vi: { breadcrumb: 'Đường dẫn', home: 'Trang chủ', empty: 'Chưa có gì để hiển thị.', failed: 'Không tải được trang này.', retry: 'Thử lại' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
29
|
+
|
|
30
|
+
/** The static segments of `<route>` (`settings/billing`); a dynamic or grouped segment is the author's to write. */
|
|
31
|
+
export function routeSegmentsOf(route) {
|
|
32
|
+
const segments = String(route).split('/').filter(Boolean);
|
|
33
|
+
if (segments.length === 0 || segments.some((segment) => !SEGMENT.test(segment))) {
|
|
34
|
+
throw new ScaffoldError('HFS_ADD_ROUTE_INVALID', `${route} is not a route of kebab-case segments (a dynamic [param] or (group) segment is written by hand)`);
|
|
35
|
+
}
|
|
36
|
+
return segments;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The blocks of `--blocks a,b`, as their PascalCase names: each an existing block of the app whose connected half takes no input
|
|
41
|
+
* (`export const X = () =>`), since a generated owner has no value to hand one; a block that takes input is composed by hand.
|
|
42
|
+
*/
|
|
43
|
+
export function blocksOf(repoRoot, appBase, option) {
|
|
44
|
+
if (option === undefined) return [];
|
|
45
|
+
return String(option).split(',').map((value) => value.trim()).filter(Boolean).map((value) => {
|
|
46
|
+
const Name = pascalOf(value);
|
|
47
|
+
const entry = path.join(repoRoot, ...appBase.split('/'), 'src', 'components', 'blocks', Name, 'index.tsx');
|
|
48
|
+
if (!fs.existsSync(entry)) {
|
|
49
|
+
throw new ScaffoldError('HFS_ADD_BLOCK_MISSING', `--blocks names ${Name}, which is no block of ${appBase}/src/components/blocks (starci app add block ${value})`);
|
|
50
|
+
}
|
|
51
|
+
if (!new RegExp(`export const ${Name} = \\(\\)`).test(fs.readFileSync(entry, 'utf8'))) {
|
|
52
|
+
throw new ScaffoldError('HFS_ADD_BLOCK_TAKES_INPUT', `${Name} takes input the generated owner cannot supply; compose it in the owner by hand`);
|
|
53
|
+
}
|
|
54
|
+
return Name;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The page tabs of `--tabs a,b`: two or more distinct kebab-case ids, each with its catalog key and its route. The first tab is
|
|
60
|
+
* the page's own route, every other one the sub-route `<route>/<id>`.
|
|
61
|
+
*/
|
|
62
|
+
export function tabsOf(route, option) {
|
|
63
|
+
if (option === undefined) return [];
|
|
64
|
+
const ids = String(option).split(',').map((value) => value.trim()).filter(Boolean);
|
|
65
|
+
if (ids.length < 2 || new Set(ids).size !== ids.length || ids.some((id) => !SEGMENT.test(id))) {
|
|
66
|
+
throw new ScaffoldError('HFS_ADD_TABS_INVALID', `--tabs ${option} is not two or more distinct kebab-case tab ids (a,b)`);
|
|
67
|
+
}
|
|
68
|
+
return ids.map((id, index) => ({ id, key: camelOf(id), label: humanOf(id), href: index === 0 ? `/${route}` : `/${route}/${id}` }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The imports and the JSX lines of the blocks a page or layout composes. */
|
|
72
|
+
export const blockParts = (blocks, indent) => ({
|
|
73
|
+
imports: blocks.map((Name) => `import { ${Name} } from "@/components/blocks/${Name}"\n`).join(''),
|
|
74
|
+
jsx: blocks.map((Name) => `${indent}<${Name} />`).join('\n'),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generates one route page in the app at `repoRoot`.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ repoRoot: string, route: string, options?: { app?: string, blocks?: string, tabs?: string } }} input - The app root, the route under [locale] and the options.
|
|
81
|
+
* @returns {{ created: string[] }} The files written or extended, relative to the app root.
|
|
82
|
+
*/
|
|
83
|
+
export function addFePage({ repoRoot, route, options = {} }) {
|
|
84
|
+
const segments = routeSegmentsOf(route);
|
|
85
|
+
const appBase = `fe/apps/${appOf(declarationOf(repoRoot), options.app)}`;
|
|
86
|
+
const base = pascalOf(segments.join('-'));
|
|
87
|
+
const Name = `${base}Page`;
|
|
88
|
+
const vars = {
|
|
89
|
+
Name, LoadingName: `${base}LoadingPage`, ErrorName: `${base}ErrorPage`, nameCamel: camelOf(Name), route: segments.join('/'),
|
|
90
|
+
routeId: segments.join('-'), title: humanOf(words(base).join('-')),
|
|
91
|
+
};
|
|
92
|
+
const routeDir = `${appBase}/src/app/[locale]/${vars.route}`;
|
|
93
|
+
for (const relative of [`${routeDir}/page.tsx`, `${appBase}/src/features/pages/${Name}`, `${appBase}/src/features/pages/${vars.ErrorName}`]) {
|
|
94
|
+
if (fs.existsSync(path.join(repoRoot, ...relative.split('/')))) throw new ScaffoldError('HFS_ADD_EXISTS', `${relative} already exists; starci app add never overwrites a unit`);
|
|
95
|
+
}
|
|
96
|
+
const blocks = blocksOf(repoRoot, appBase, options.blocks);
|
|
97
|
+
const tabs = tabsOf(vars.route, options.tabs);
|
|
98
|
+
const parts = blockParts(blocks, ' ');
|
|
99
|
+
const empty = blocks.length === 0;
|
|
100
|
+
const tabbed = tabs.length > 0;
|
|
101
|
+
const pageVars = {
|
|
102
|
+
...vars,
|
|
103
|
+
grammarImports: empty ? 'EmptyNotice, Page' : 'Page',
|
|
104
|
+
blockImports: parts.imports,
|
|
105
|
+
body: empty ? ' <EmptyNotice message={props.props.empty} />' : parts.jsx,
|
|
106
|
+
emptyMember: empty ? ' readonly empty: string\n' : '',
|
|
107
|
+
emptyResolved: empty ? ' empty: t("empty"),\n' : '',
|
|
108
|
+
emptyFixture: empty ? ' empty: "Nothing to show yet.",\n' : '',
|
|
109
|
+
tabsMember: tabbed
|
|
110
|
+
? ' /** The page tabs: link tabs, the selected one follows the current path. */\n readonly tabsLabel: string\n readonly tabs: ReadonlyArray<{ readonly id: string; readonly label: string; readonly href: string }>\n'
|
|
111
|
+
: '',
|
|
112
|
+
tabsHeader: tabbed ? ' tabs: { label: props.props.tabsLabel, items: props.props.tabs },\n' : '',
|
|
113
|
+
tabsResolved: tabbed
|
|
114
|
+
? ` tabsLabel: t("tabs.label"),\n tabs: [\n${tabs.map((tab) => ` { id: "${tab.id}", label: t("tabs.items.${tab.key}"), href: "${tab.href}" },\n`).join('')} ],\n`
|
|
115
|
+
: '',
|
|
116
|
+
tabsFixture: tabbed
|
|
117
|
+
? ` tabsLabel: "${vars.title}",\n tabs: [\n${tabs.map((tab) => ` { id: "${tab.id}", label: "${tab.label}", href: "${tab.href}" },\n`).join('')} ],\n`
|
|
118
|
+
: '',
|
|
119
|
+
};
|
|
120
|
+
const created = [];
|
|
121
|
+
writeNew(created, repoRoot, `${routeDir}/page.tsx`, fill('page/route-page.tsx.tpl', vars));
|
|
122
|
+
writeNew(created, repoRoot, `${routeDir}/loading.tsx`, fill('page/route-loading.tsx.tpl', vars));
|
|
123
|
+
writeNew(created, repoRoot, `${routeDir}/error.tsx`, fill('page/route-error.tsx.tpl', vars));
|
|
124
|
+
writeNew(created, repoRoot, `${appBase}/src/features/pages/${Name}/index.tsx`, fill('page/index.tsx.tpl', pageVars));
|
|
125
|
+
writeNew(created, repoRoot, `${appBase}/src/features/pages/${Name}/component.tsx`, fill('page/component.tsx.tpl', pageVars));
|
|
126
|
+
writeNew(created, repoRoot, `${appBase}/src/features/pages/${vars.ErrorName}/index.tsx`, fill('page/error-index.tsx.tpl', vars));
|
|
127
|
+
writeNew(created, repoRoot, `${appBase}/src/features/pages/${vars.ErrorName}/component.tsx`, fill('page/error-component.tsx.tpl', vars));
|
|
128
|
+
writeNew(created, repoRoot, `${appBase}/stories/pages/${Name}.stories.tsx`, fill('page/stories.tsx.tpl', pageVars));
|
|
129
|
+
addCatalogNamespaces(created, { repoRoot, appBase }, (locale) => {
|
|
130
|
+
const copy = PAGE_COPY[locale] ?? PAGE_COPY.en;
|
|
131
|
+
return { [vars.nameCamel]: {
|
|
132
|
+
title: vars.title, breadcrumb: copy.breadcrumb, home: copy.home, ...(empty ? { empty: copy.empty } : {}),
|
|
133
|
+
...(tabbed ? { tabs: { label: vars.title, items: Object.fromEntries(tabs.map((tab) => [tab.key, tab.label])) } } : {}),
|
|
134
|
+
failed: copy.failed, retry: copy.retry,
|
|
135
|
+
} };
|
|
136
|
+
});
|
|
137
|
+
return { created };
|
|
138
|
+
}
|