@starci/hfs 4.4.0 → 4.4.1
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 +3 -0
- package/README.md +1 -1
- package/db/migrate.mjs +4 -4
- package/db/post-cutover.mjs +4 -4
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +2 -2
- package/lint/run.mjs +2 -2
- package/package.json +1 -1
- package/release/gate.mjs +4 -4
- package/release/index.mjs +2 -2
- package/release/push.mjs +1 -1
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +410 -967
- package/runtime/knowledge/hfs/slots.yaml +13 -24
- package/runtime/modules/kernel/failure-codes.yaml +0 -22
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +4 -6
- package/runtime/packages/cli/src/catalog.generated.mjs +46 -381
- package/runtime/scripts/api/git/lib.mjs +3 -3
- package/runtime/scripts/api/sops/lib.mjs +1 -1
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/example-secrets.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +1 -1
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +2 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/scaffold/add-fe-overlay.mjs +0 -121
- package/scaffold/add-fe-page.mjs +0 -268
- package/scaffold/supabase-ports.mjs +1 -1
- package/stack/age-install.mjs +1 -1
- package/stack/cli.mjs +2 -2
- package/stack/context.mjs +1 -1
- package/stack/digest.mjs +1 -1
- package/stack/edge-alias.mjs +1 -1
- package/stack/image-scan.mjs +1 -1
- package/stack/ingress-create.mjs +2 -2
- package/stack/ingress.mjs +3 -3
- package/stack/remote.mjs +2 -2
- package/stack/sealed.mjs +5 -5
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +2 -2
- package/sync/stack-images.mjs +2 -2
- package/upgrade/index.mjs +1 -1
|
@@ -17,7 +17,7 @@ import { byCodeUnit } from '../lib/list.mjs';
|
|
|
17
17
|
import { trimTrailingSlashes } from './trailing-slashes.mjs';
|
|
18
18
|
|
|
19
19
|
/** The component that holds the shared infrastructure: the platform tier and every capability more than one service app composes. */
|
|
20
|
-
|
|
20
|
+
const PLATFORM_COMPONENT = 'platform';
|
|
21
21
|
const SERVICE_APP_KINDS = new Set(['api', 'worker']);
|
|
22
22
|
/** `text` with every `<placeholder>` (a `<`, at least one character, the next `>`) replaced by `*`; a `<` with no closing `>` stays. */
|
|
23
23
|
export const star = (text) => {
|
|
@@ -135,7 +135,7 @@ function commandEnvironment() {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/** Run an installed app command in a sealed temporary copy, returning its exit code and preserving report artifacts. */
|
|
138
|
-
|
|
138
|
+
async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
|
|
139
139
|
return withMaterializedExample(repoRoot, '.', (app) => {
|
|
140
140
|
const launch = commandLaunch(command, args);
|
|
141
141
|
const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(), stdio: 'inherit', windowsHide: true });
|
|
@@ -56,8 +56,6 @@ export const unitRolesOf = (manifest) => manifest.ruleParams.be.unitRoles;
|
|
|
56
56
|
/** The roles that carry logic (ruleParams.be.logicRoles): measured at 100 per file inside a coverage-required slot. */
|
|
57
57
|
export const logicRolesOf = (manifest) => manifest.ruleParams.be.logicRoles;
|
|
58
58
|
|
|
59
|
-
/** The roles a feature-tier file is judged thin by (ruleParams.be.thinRoles, R203 BE_FEATURE_THIN). */
|
|
60
|
-
export const thinRolesOf = (manifest) => manifest.ruleParams.be.thinRoles;
|
|
61
59
|
|
|
62
60
|
/** Shape problems of ruleParams.be.unitRoles: unique roles of the suffix vocabulary, each spec `<role>.spec`. */
|
|
63
61
|
export function unitRolesProblems(be) {
|
|
@@ -86,7 +84,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
86
84
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
87
85
|
const strList = (v) => stringList(v);
|
|
88
86
|
|
|
89
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
|
|
87
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exampleNa', 'exemplifiedBy'];
|
|
90
88
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
91
89
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
92
90
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
@@ -220,6 +218,8 @@ function slotCoverageEvidenceProblems(slot, at) {
|
|
|
220
218
|
const bad = [];
|
|
221
219
|
if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
|
|
222
220
|
bad.push(`${at}: templateNa must be non-empty text`);
|
|
221
|
+
if (slot.exampleNa !== undefined && !(typeof slot.exampleNa === 'string' && slot.exampleNa.trim()))
|
|
222
|
+
bad.push(`${at}: exampleNa must be non-empty text`);
|
|
223
223
|
if (slot.exemplifiedBy === undefined) return bad;
|
|
224
224
|
const refs = slot.exemplifiedBy;
|
|
225
225
|
const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// param-names.mjs - the one shape of `ruleParams.be.paramNames` of knowledge/hfs/slots.yaml (R85 injected-param-name): a non-empty list of unique
|
|
2
2
|
// entries `{type | typeSuffix, names, nameSuffix?}`. The loader of scripts/hfs/slots.mjs calls it; the schema (modules/schemas/hfs-slots.schema.yaml)
|
|
3
|
-
// states the same shape
|
|
3
|
+
// states the same shape.
|
|
4
4
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
5
5
|
|
|
6
6
|
const UPPER = /^[A-Z][A-Za-z0-9]*$/u;
|
|
@@ -16,7 +16,7 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
|
16
16
|
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
17
17
|
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
18
18
|
const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
|
|
19
|
-
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
|
|
19
|
+
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa', 'specNa', 'exampleNa']);
|
|
20
20
|
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
|
|
21
21
|
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
22
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
|
|
@@ -279,6 +279,8 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
|
|
|
279
279
|
if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
|
|
280
280
|
optionalText(rule, 'editionWhy', label, bad);
|
|
281
281
|
optionalText(rule, 'templateNa', label, bad);
|
|
282
|
+
optionalText(rule, 'specNa', label, bad);
|
|
283
|
+
optionalText(rule, 'exampleNa', label, bad);
|
|
282
284
|
for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
|
|
283
285
|
witnessProblems(rule, label, bad);
|
|
284
286
|
enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
|
|
@@ -8,7 +8,7 @@ import { found } from './read.mjs';
|
|
|
8
8
|
|
|
9
9
|
export const HFS_ASSET_OVERSIZE = 'HFS_ASSET_OVERSIZE';
|
|
10
10
|
export const HFS_ASSET_KIND = 'HFS_ASSET_KIND';
|
|
11
|
-
|
|
11
|
+
const DEFAULT_ASSET_BYTES = 512000;
|
|
12
12
|
const BUILD_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2', '.eot', '.png', '.svg', '.webp', '.jpg', '.jpeg', '.gif', '.ico', '.avif', '.bmp', '.tif', '.tiff'];
|
|
13
13
|
const MEDIA_EXTENSIONS = new Set(['.mp3', '.mp4', '.wav', '.ogg', '.oga', '.opus', '.flac', '.aac', '.m4a', '.wma', '.aiff', '.aif', '.mid', '.midi', '.mov', '.m4v', '.webm', '.avi', '.mkv', '.mpeg', '.mpg', '.wmv', '.flv', '.3gp']);
|
|
14
14
|
const IMAGE_EXTENSIONS = new Set(BUILD_EXTENSIONS.filter((ext) => !['.ttf', '.otf', '.woff', '.woff2', '.eot'].includes(ext)));
|
|
@@ -10,7 +10,7 @@ import { found } from './read.mjs';
|
|
|
10
10
|
const CLI_REQUIRED = 'BE_CLI_REQUIRED';
|
|
11
11
|
|
|
12
12
|
/** The one name of the cli app, its kind, and what its image is called below it. */
|
|
13
|
-
|
|
13
|
+
const CLI_APP = 'cli';
|
|
14
14
|
const CLI_KIND = 'cli';
|
|
15
15
|
const IMAGE = 'Dockerfile';
|
|
16
16
|
const CLI_FEATURE_SLOT = 'be.cli';
|
|
@@ -14,7 +14,7 @@ import { isPlainObject } from '../../../engine/plain-object.mjs';
|
|
|
14
14
|
import { found, readJson } from './read.mjs';
|
|
15
15
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
|
|
18
18
|
|
|
19
19
|
const CONTRACTS = /^be\/contracts\/([^/]+)\/events\.pin\.json$/;
|
|
20
20
|
|
|
@@ -15,7 +15,7 @@ import { safeRemove } from '../../api/fs/safe-remove.mjs';
|
|
|
15
15
|
import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
|
|
16
16
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
|
|
19
19
|
const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
|
|
20
20
|
const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
|
|
21
21
|
const GRAPHQL_PLATFORM = /^src\/modules\/platform\/graphql(?:\/|\.ts$|$)/;
|
|
@@ -22,7 +22,7 @@ const I18N_CATALOG = 'FE_I18N_CATALOG';
|
|
|
22
22
|
export const DEFAULT_LOCALE = 'vi';
|
|
23
23
|
const ROUTE_FILE = /\/(?:page|layout|template|loading|error|not-found)\.[jt]sx?$/;
|
|
24
24
|
const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/;
|
|
25
|
-
|
|
25
|
+
const WIRE_SCHEMA_STAMP = '.schema-sha256';
|
|
26
26
|
|
|
27
27
|
/** Every leaf key of a parsed catalog as a dotted path. */
|
|
28
28
|
function catalogKeys(value, prefix = '') {
|
|
@@ -32,7 +32,7 @@ function catalogKeys(value, prefix = '') {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Digest protocol for codegen: sorted app-relative schema path, NUL, LF-normalized source, NUL. */
|
|
35
|
-
|
|
35
|
+
function wireSchemaDigest(repoRoot, snapshots) {
|
|
36
36
|
const hash = createHash('sha256');
|
|
37
37
|
for (const snapshot of [...snapshots].sort(byCodeUnit)) {
|
|
38
38
|
let source;
|
|
@@ -77,7 +77,7 @@ function providerSecretFindings(repoRoot, file, repo, findings) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Raw L17 findings. The catalog-driven findings filter decides which editions judge them. */
|
|
80
|
-
|
|
80
|
+
function liteSecretCustodyFindings({ repoRoot, files, repo }) {
|
|
81
81
|
const findings = [];
|
|
82
82
|
for (const file of files) {
|
|
83
83
|
if (custodyPathFinding(file, repo, findings)) continue;
|
|
@@ -4,8 +4,8 @@ import { parseYaml } from '../../../engine/yaml.mjs';
|
|
|
4
4
|
|
|
5
5
|
const WORKFLOW = /(?:^|\/)\.?(?:github\/)?workflows\/[^/]+\.ya?ml$/;
|
|
6
6
|
|
|
7
|
-
/** The tracked workflow files of the runtime, its examples and the hfs templates
|
|
8
|
-
const workflowFiles = (files) => files.filter((file) => WORKFLOW.test(file)
|
|
7
|
+
/** The tracked workflow files of the runtime, its examples and the hfs templates. */
|
|
8
|
+
const workflowFiles = (files) => files.filter((file) => WORKFLOW.test(file));
|
|
9
9
|
|
|
10
10
|
/** The parsed workflow, or null when the text is not YAML (the other checks name that). */
|
|
11
11
|
export function parseWorkflow(text) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// is this path tracked isTracked(P) / trackingOf(P); the rule catalog (knowledge/hfs/rules.yaml, modules/schemas/hfs-rules.schema.yaml) loads through loadRuleCatalog / rules().
|
|
6
6
|
// Every check and lint rule of HFS reads knowledge/hfs/slots.yaml through this module; none keeps its own path
|
|
7
7
|
// list. The manifest shape is modules/schemas/hfs-slots.schema.yaml and hfs.json is modules/schemas/hfs-repo.schema.yaml;
|
|
8
|
-
// the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv
|
|
8
|
+
// the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv.
|
|
9
9
|
//
|
|
10
10
|
// A product is ONE app repository: hfs.json at the app root has kind `app` and declares its two sides, `be` and `fe`, each in
|
|
11
11
|
// the folder of that name. The resolver of the app answers for the whole tree: a root path with the slots of profile `app`,
|
|
@@ -41,7 +41,7 @@ export { HfsSlotsError } from './slot-errors.mjs';
|
|
|
41
41
|
export { loadRuleCatalog };
|
|
42
42
|
export { rules } from './rule-catalog.mjs';
|
|
43
43
|
export { APP_SCOPE };
|
|
44
|
-
|
|
44
|
+
const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
|
|
45
45
|
/** The manifest of kind runtime: the standard tree of the StarCi runtime repository (judged by scripts/hfs/runtime-check.mjs). */
|
|
46
46
|
export const RUNTIME_MANIFEST_FILE = 'knowledge/hfs/runtime-slots.yaml';
|
|
47
47
|
export const HFS_DECLARATION_FILE = 'hfs.json';
|
|
@@ -9,14 +9,6 @@ import { byCodeUnit } from '../lib/list.mjs';
|
|
|
9
9
|
// builds afresh.
|
|
10
10
|
const runs = new AsyncLocalStorage();
|
|
11
11
|
|
|
12
|
-
/** A program run: run(fn) shares what is built inside fn; release() drops it. */
|
|
13
|
-
export function typeScriptProgramRun() {
|
|
14
|
-
const values = new Map();
|
|
15
|
-
return {
|
|
16
|
-
run: fn => runs.run(values, fn),
|
|
17
|
-
release: () => values.clear(),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
12
|
|
|
21
13
|
function keyOf(value) {
|
|
22
14
|
if (value === undefined) return 'u';
|
|
@@ -11,8 +11,8 @@ export const isSpecRun = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT);
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The environment for a child that is itself a test runner: `env` without the mark of an enclosing spec run. A `node --test` that inherits NODE_TEST_CONTEXT believes it is a
|
|
14
|
-
* subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (
|
|
15
|
-
*
|
|
14
|
+
* subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (the package clean-test, the scaffold e2e, the release cut)
|
|
15
|
+
* would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
|
|
16
16
|
*/
|
|
17
17
|
export function withoutTestRunner(env = process.env) {
|
|
18
18
|
const { NODE_TEST_CONTEXT: _enclosing, ...rest } = env;
|
|
@@ -12,7 +12,7 @@ export const unquoteDiffPath = (value) =>
|
|
|
12
12
|
|
|
13
13
|
// git's repository-local variables (git rev-parse --local-env-vars). A hook or alias run in a linked worktree exports
|
|
14
14
|
// GIT_DIR=<main>/.git/worktrees/<wt>; a spec inheriting it pointed every fixture git at the LIVE .claude repo and re-inited
|
|
15
|
-
// it core.bare=true (2026-09-29, a6f60352c).
|
|
15
|
+
// it core.bare=true (2026-09-29, a6f60352c). e2e/setup/isolated-registry.mjs drops the same list.
|
|
16
16
|
export const GIT_LOCAL_ENV_VARS = ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_IMPLICIT_WORK_TREE', 'GIT_PREFIX', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_GRAFT_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_SHALLOW_FILE', 'GIT_INTERNAL_SUPER_PREFIX'];
|
|
17
17
|
/** `parent` (the caller's env) without git's repository-local variables (GIT_CONFIG_KEY_n/VALUE_n go with GIT_CONFIG_COUNT). Pure. */
|
|
18
18
|
export function withoutGitLocalEnv(parent) {
|
|
@@ -49,8 +49,6 @@ export function loadCatalog(root = skillRoot, { scope = 'runtime' } = {}) {
|
|
|
49
49
|
return cache.get(key);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** Forget the cached catalogs (a spec that rewrites a catalog file). */
|
|
53
|
-
export const resetCatalogCache = () => cache.clear();
|
|
54
52
|
|
|
55
53
|
/** The names each `pattern` match captures (group 1) in `text`, in match order (`sort` sorts them). */
|
|
56
54
|
export const captureNames = (text, pattern, { sort = false } = {}) => {
|
|
@@ -76,7 +76,7 @@ const I18N_CATALOG_DIR = 'modules/i18n/messages/';
|
|
|
76
76
|
const OP_MANIFEST_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']) });
|
|
77
77
|
const OP_MANIFEST_DIR = 'modules/ops/ops/';
|
|
78
78
|
/** The declared Vietnamese fields of `rel`: its own entry, or the op-manifest text field for a manifest of modules/ops/ops. */
|
|
79
|
-
|
|
79
|
+
const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[rel]
|
|
80
80
|
?? (rel.startsWith(I18N_CATALOG_DIR) && !rel.slice(I18N_CATALOG_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? I18N_CATALOG_VIETNAMESE_FIELDS : null)
|
|
81
81
|
?? (rel.startsWith(OP_MANIFEST_DIR) && !rel.slice(OP_MANIFEST_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? OP_MANIFEST_VIETNAMESE_FIELDS : null);
|
|
82
82
|
|
|
@@ -17,16 +17,14 @@
|
|
|
17
17
|
// directory or from the Work directory name outside this module.
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
-
import { skillRoot,
|
|
20
|
+
import { skillRoot, starciLocalRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
|
|
21
21
|
import { tempRoot } from '../../engine/temp-root.mjs';
|
|
22
22
|
|
|
23
23
|
export { tempRoot, starciLocalRoot, starciSourceRoot };
|
|
24
24
|
|
|
25
25
|
/** The runtime tree this code runs from. */
|
|
26
|
-
|
|
26
|
+
const runtimeTree = skillRoot;
|
|
27
27
|
|
|
28
|
-
/** The state directory of a runtime tree (default: this one). */
|
|
29
|
-
export const runtimeState = (tree = runtimeTree) => runtimeStateDir(tree);
|
|
30
28
|
|
|
31
29
|
/** The name of the Work records directory inside a product tree. */
|
|
32
30
|
export const WORK_DIR_NAME = '.starciwork';
|
|
@@ -1,122 +1 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { ScaffoldError, pascalOf } from "./service.mjs";
|
|
4
|
-
import { feName, readFeContext, readFeFile, requireNewFeOwner, planFeCatalogs, writeFePlan } from "./add-fe-page.mjs";
|
|
5
1
|
|
|
6
|
-
function loadTypeScript(root) {
|
|
7
|
-
try { return createRequire(path.join(root, "package.json"))("typescript"); } catch {
|
|
8
|
-
throw new ScaffoldError("HFS_ADD_TYPESCRIPT_MISSING", "add overlay reads the host page with its installed TypeScript compiler");
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function pageArrow(ts, source, symbol) {
|
|
13
|
-
for (const statement of source.statements) {
|
|
14
|
-
if (!ts.isVariableStatement(statement) || !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
15
|
-
const candidate = statement.declarationList.declarations.find((declaration) => declaration.name.getText(source) === symbol);
|
|
16
|
-
if (candidate?.initializer && ts.isArrowFunction(candidate.initializer)) return candidate.initializer;
|
|
17
|
-
}
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function pageRender(ts, arrow) {
|
|
22
|
-
if (!arrow) return null;
|
|
23
|
-
if (!ts.isBlock(arrow.body)) {
|
|
24
|
-
let body = arrow.body;
|
|
25
|
-
while (ts.isParenthesizedExpression(body)) body = body.expression;
|
|
26
|
-
return body;
|
|
27
|
-
}
|
|
28
|
-
const returns = arrow.body.statements.filter(ts.isReturnStatement);
|
|
29
|
-
const unconditional = arrow.body.statements.every((statement) => ts.isVariableStatement(statement) || ts.isExpressionStatement(statement) || ts.isReturnStatement(statement));
|
|
30
|
-
if (!unconditional || returns.length !== 1) return null;
|
|
31
|
-
let render = returns[0].expression;
|
|
32
|
-
while (render && ts.isParenthesizedExpression(render)) render = render.expression;
|
|
33
|
-
return render;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function mountOverlay({ ts, before, relative, pageSymbol, symbol }) {
|
|
37
|
-
const source = ts.createSourceFile(relative, before, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
38
|
-
const client = source.statements.some((statement) => ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression) && statement.expression.text === "use client");
|
|
39
|
-
const render = pageRender(ts, pageArrow(ts, source, pageSymbol));
|
|
40
|
-
const isJsx = render && (ts.isJsxElement(render) || ts.isJsxSelfClosingElement(render) || ts.isJsxFragment(render));
|
|
41
|
-
if (source.parseDiagnostics.length || client || !isJsx) {
|
|
42
|
-
throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `${relative} must export the server ${pageSymbol} arrow with one unconditional JSX return`);
|
|
43
|
-
}
|
|
44
|
-
if (before.split(/[^A-Za-z0-9_]/).includes(symbol)) {
|
|
45
|
-
throw new ScaffoldError("HFS_ADD_EXISTS", `${relative} already references ${symbol}`);
|
|
46
|
-
}
|
|
47
|
-
const from = render.getStart(source);
|
|
48
|
-
const wrapped = ["(", " <>", ` ${before.slice(from, render.end)}`, ` <${symbol} />`, " </>", " )"].join("\n");
|
|
49
|
-
const body = before.slice(0, from) + wrapped + before.slice(render.end);
|
|
50
|
-
const imports = source.statements.filter(ts.isImportDeclaration);
|
|
51
|
-
const last = imports.at(-1)?.end ?? 0;
|
|
52
|
-
const importLine = `import { ${symbol} } from "@/features/overlays/${symbol}"`;
|
|
53
|
-
return `${body.slice(0, last)}\n${importLine}${body.slice(last)}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function overlaySources(symbol, namespace) {
|
|
57
|
-
return {
|
|
58
|
-
entry: `"use client"
|
|
59
|
-
|
|
60
|
-
import { useTranslations } from "next-intl"
|
|
61
|
-
import { ${symbol}Base } from "./component"
|
|
62
|
-
|
|
63
|
-
/** Resolve the overlay's copy at its client interaction boundary. */
|
|
64
|
-
export const ${symbol} = () => {
|
|
65
|
-
const t = useTranslations(${JSON.stringify(namespace)})
|
|
66
|
-
return <${symbol}Base props={{ title: t("title"), open: t("open"), close: t("close"), description: t("description") }} />
|
|
67
|
-
}
|
|
68
|
-
`,
|
|
69
|
-
component: `import { Button, Dialog, Text } from "@starci/grammar/common"
|
|
70
|
-
|
|
71
|
-
/** Settled labels and content for the overlay's accessible dialog. */
|
|
72
|
-
export interface ${symbol}BaseProps {
|
|
73
|
-
readonly props: {
|
|
74
|
-
readonly title: string
|
|
75
|
-
readonly open: string
|
|
76
|
-
readonly close: string
|
|
77
|
-
readonly description: string
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Draw an accessible dialog with an intrinsic trigger and dismissal. */
|
|
82
|
-
export const ${symbol}Base = (props: ${symbol}BaseProps) => (
|
|
83
|
-
<Dialog
|
|
84
|
-
title={props.props.title}
|
|
85
|
-
closeLabel={props.props.close}
|
|
86
|
-
trigger={<Button>{props.props.open}</Button>}
|
|
87
|
-
>
|
|
88
|
-
<Text>{props.props.description}</Text>
|
|
89
|
-
</Dialog>
|
|
90
|
-
)
|
|
91
|
-
`,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Add a localized client overlay and mount its public entry in a server feature page. */
|
|
96
|
-
export function addFeOverlay({ root, app, name, page, messages, ts }) {
|
|
97
|
-
feName(name, "overlay name");
|
|
98
|
-
if (page === undefined) throw new ScaffoldError("HFS_ADD_OPTION_MISSING", "add overlay needs --page naming its server feature page");
|
|
99
|
-
feName(page, "host page name");
|
|
100
|
-
const context = readFeContext({ root, app });
|
|
101
|
-
const symbol = `${pascalOf(name)}Overlay`;
|
|
102
|
-
const owner = `${context.source}/features/overlays/${symbol}`;
|
|
103
|
-
requireNewFeOwner(context, owner);
|
|
104
|
-
const pageSymbol = `${pascalOf(page)}Page`;
|
|
105
|
-
const relative = `${context.source}/features/pages/${pageSymbol}/index.tsx`;
|
|
106
|
-
const before = readFeFile(context, relative);
|
|
107
|
-
const body = mountOverlay({ ts: ts ?? loadTypeScript(context.root), before, relative, pageSymbol, symbol });
|
|
108
|
-
const catalogs = planFeCatalogs(context, {
|
|
109
|
-
group: "overlays", name, messages,
|
|
110
|
-
defaults: {
|
|
111
|
-
vi: { title: "Th\u00f4ng tin", open: "M\u1edf th\u00f4ng tin", close: "\u0110\u00f3ng", description: "Xem th\u00f4ng tin c\u1ee7a trang n\u00e0y." },
|
|
112
|
-
en: { title: "Information", open: "Open information", close: "Close", description: "View information about this page." },
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
const sources = overlaySources(symbol, catalogs.namespace);
|
|
116
|
-
const created = [
|
|
117
|
-
{ relative: `${owner}/index.tsx`, body: sources.entry },
|
|
118
|
-
{ relative: `${owner}/component.tsx`, body: sources.component },
|
|
119
|
-
];
|
|
120
|
-
const updates = [...catalogs.updates, { relative, before, body }];
|
|
121
|
-
return { ...writeFePlan(context, created, updates), registered: { app: context.app, overlay: symbol, page: pageSymbol } };
|
|
122
|
-
}
|