@nifrajs/cli 3.5.0 → 4.0.0
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/README.md +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +4 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +13 -0
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +49 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +291 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -63
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +262 -82
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +57 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +270 -26
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +52 -13
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +41 -21
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +5 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -27
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +67 -17
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.js +1 -1
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +2 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +132 -108
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +17 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +14 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +45 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-render.js +2 -2
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +110 -23
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -9
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +16 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +81 -53
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +982 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +5 -0
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +34 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +12 -8
- package/dist/review.js.map +1 -1
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +71 -38
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +132 -87
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +29 -13
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +3807 -580
- package/docs/types.json +14229 -10939
- package/package.json +19 -12
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +17 -0
- package/src/check-scan.ts +358 -63
- package/src/check.ts +97 -62
- package/src/cli.ts +296 -87
- package/src/command-catalog.ts +374 -38
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +54 -12
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +44 -31
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +90 -27
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +140 -33
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +1 -1
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +160 -132
- package/src/mcp-http.ts +19 -5
- package/src/mcp-io.ts +57 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-render.ts +2 -2
- package/src/mcp-root.ts +117 -22
- package/src/mcp-run.ts +104 -10
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +106 -64
- package/src/migrate-layout.ts +1121 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +5 -0
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +13 -8
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +71 -39
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +147 -95
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +13 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +27 -13
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NF-C028 and NF-C029: the frontend/backend zone rules, checked from source.
|
|
3
|
+
*
|
|
4
|
+
* Every browser build and dev server enforces these already; this reports them without building,
|
|
5
|
+
* with the same classifier (`@nifrajs/web/zones`) so the three can never disagree. NF-C004 owns
|
|
6
|
+
* browser code reaching server code (with the chain); NF-C028 owns the other directions: a zoned
|
|
7
|
+
* file importing one in no zone, backend code importing frontend code, and shared code importing
|
|
8
|
+
* anything but shared code. NF-C029 owns private environment reads in browser code.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
|
11
|
+
import { dirname, isAbsolute, join, relative } from "node:path"
|
|
12
|
+
import {
|
|
13
|
+
type Classification,
|
|
14
|
+
createZoneClassifier,
|
|
15
|
+
importAllowed,
|
|
16
|
+
importRuleMessage,
|
|
17
|
+
privateEnvReads,
|
|
18
|
+
privateEnvReason,
|
|
19
|
+
type ZoneClassifier,
|
|
20
|
+
} from "@nifrajs/web/zones"
|
|
21
|
+
import { CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
|
|
22
|
+
import { importSites, stripComments } from "../check-scan.ts"
|
|
23
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
24
|
+
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
25
|
+
import type { CheckRule, RuleContext } from "./index.ts"
|
|
26
|
+
|
|
27
|
+
const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
|
|
28
|
+
const FRONTEND = new Set(["route-frontend", "frontend"])
|
|
29
|
+
const SERVER = new Set(["backend", "route-backend", "fn"])
|
|
30
|
+
const BACKEND = new Set(["backend", "route-backend"])
|
|
31
|
+
const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
|
|
32
|
+
const CODE_FILE = /\.(?:[cm]?[jt]sx?|svelte|vue|mdx)$/
|
|
33
|
+
|
|
34
|
+
/** The app a project file belongs to: the path before its first zone folder, or `undefined`. */
|
|
35
|
+
function appOf(file: string): string | undefined {
|
|
36
|
+
const parts = file.split("/")
|
|
37
|
+
const at = parts.findIndex((part, i) => i < parts.length - 1 && ZONE_FOLDER.has(part))
|
|
38
|
+
return at === -1 ? undefined : parts.slice(0, at).join("/")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ZonedFile {
|
|
42
|
+
readonly file: string
|
|
43
|
+
readonly app: string
|
|
44
|
+
readonly classification: Classification
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every scanned source file that belongs to an app, classified once per app classifier. */
|
|
48
|
+
export function zonedFiles(ctx: RuleContext): {
|
|
49
|
+
readonly files: readonly ZonedFile[]
|
|
50
|
+
readonly classifierFor: (app: string) => ZoneClassifier
|
|
51
|
+
} {
|
|
52
|
+
const classifiers = new Map<string, ZoneClassifier>()
|
|
53
|
+
const classifierFor = (app: string): ZoneClassifier => {
|
|
54
|
+
let classifier = classifiers.get(app)
|
|
55
|
+
if (classifier === undefined) {
|
|
56
|
+
const appRoot = join(ctx.root, app)
|
|
57
|
+
classifier = createZoneClassifier({
|
|
58
|
+
appRoot,
|
|
59
|
+
generatedFiles: [join(appRoot, "server-manifest.ts")],
|
|
60
|
+
})
|
|
61
|
+
classifiers.set(app, classifier)
|
|
62
|
+
}
|
|
63
|
+
return classifier
|
|
64
|
+
}
|
|
65
|
+
const files: ZonedFile[] = []
|
|
66
|
+
for (const file of ctx.sources.files) {
|
|
67
|
+
if (!CODE_FILE.test(file)) continue
|
|
68
|
+
const app = appOf(file)
|
|
69
|
+
if (app === undefined) continue
|
|
70
|
+
files.push({ file, app, classification: classifierFor(app).classify(join(ctx.root, file)) })
|
|
71
|
+
}
|
|
72
|
+
return { files, classifierFor }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const real = (path: string): string => {
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(path)
|
|
78
|
+
} catch {
|
|
79
|
+
return path
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where a resolved import lands, as the project names it. Both sides are real paths: a checkout
|
|
84
|
+
* reached through a symlink (`/var` -> `/private/var` on macOS) still names files project-relative. */
|
|
85
|
+
function display(root: string, file: string): string {
|
|
86
|
+
const rel = relative(real(root), real(file)).replaceAll("\\", "/")
|
|
87
|
+
return rel.startsWith("../") || isAbsolute(rel) ? file : rel
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A relative import resolved against the importing file, or `undefined` when it does not resolve. */
|
|
91
|
+
function resolveRelative(root: string, from: string, specifier: string): string | undefined {
|
|
92
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
|
|
93
|
+
try {
|
|
94
|
+
// Relative specifiers only: a bare one would let the resolver reach for a global install.
|
|
95
|
+
return Bun.resolveSync(specifier, dirname(join(root, from)))
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const zoneImportRule: CheckRule = {
|
|
102
|
+
code: "NF-C028",
|
|
103
|
+
title: "Zone import check",
|
|
104
|
+
async scan(ctx) {
|
|
105
|
+
const { files, classifierFor } = zonedFiles(ctx)
|
|
106
|
+
const findings: Diagnostic[] = []
|
|
107
|
+
for (const { file, app, classification: from } of files) {
|
|
108
|
+
if (from.zone === "error" || from.zone === "library" || from.zone === "generated") continue
|
|
109
|
+
const content = ctx.sources.read(file)
|
|
110
|
+
if (content === undefined) continue
|
|
111
|
+
for (const { specifier, line } of importSites(content, file)) {
|
|
112
|
+
const target = resolveRelative(ctx.root, file, specifier)
|
|
113
|
+
if (target === undefined) continue
|
|
114
|
+
const to = classifierFor(app).classify(target)
|
|
115
|
+
const targetName = display(ctx.root, target)
|
|
116
|
+
let message: string
|
|
117
|
+
if (to.zone === "error") message = `${file} imports ${targetName}: ${to.reason}`
|
|
118
|
+
else if (
|
|
119
|
+
(SERVER.has(from.zone) && FRONTEND.has(to.zone)) ||
|
|
120
|
+
// Shared code reaching backend code is NF-C004's, reported there with the chain.
|
|
121
|
+
(from.zone === "shared" && !importAllowed(from.zone, to.zone) && !BACKEND.has(to.zone))
|
|
122
|
+
) {
|
|
123
|
+
message = importRuleMessage(file, from.zone, targetName, to.zone)
|
|
124
|
+
} else continue
|
|
125
|
+
findings.push(
|
|
126
|
+
diagnostic({
|
|
127
|
+
code: "NF-C028",
|
|
128
|
+
severity: "error",
|
|
129
|
+
file,
|
|
130
|
+
line,
|
|
131
|
+
message,
|
|
132
|
+
evidence: [`import: ${specifier}`, `from zone: ${from.zone}`, `to zone: ${to.zone}`],
|
|
133
|
+
verify: "nifra check",
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return findings.sort(
|
|
139
|
+
(a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const PREFIX_EXPORT =
|
|
145
|
+
/\bexport\s+const\s+publicEnvPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')/
|
|
146
|
+
|
|
147
|
+
/** The app's public-env prefix when its config declares it as a string literal, else the default. */
|
|
148
|
+
function publicPrefix(root: string, app: string): string {
|
|
149
|
+
for (const config of [FRAMEWORK_FILE, CONFIG_FILE]) {
|
|
150
|
+
const path = join(root, app, config)
|
|
151
|
+
if (!existsSync(path)) continue
|
|
152
|
+
const match = PREFIX_EXPORT.exec(stripComments(readFileSync(path, "utf8")))
|
|
153
|
+
if (match !== null) return match[1] ?? match[2] ?? "PUBLIC_"
|
|
154
|
+
}
|
|
155
|
+
return "PUBLIC_"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const privateEnvRule: CheckRule = {
|
|
159
|
+
code: "NF-C029",
|
|
160
|
+
title: "Private environment read in browser code",
|
|
161
|
+
async scan(ctx) {
|
|
162
|
+
const { files } = zonedFiles(ctx)
|
|
163
|
+
const prefixes = new Map<string, string>()
|
|
164
|
+
const findings: Diagnostic[] = []
|
|
165
|
+
for (const { file, app, classification } of files) {
|
|
166
|
+
if (!BROWSER_CODE.has(classification.zone)) continue
|
|
167
|
+
const content = ctx.sources.read(file)
|
|
168
|
+
if (content === undefined) continue
|
|
169
|
+
let prefix = prefixes.get(app)
|
|
170
|
+
if (prefix === undefined) {
|
|
171
|
+
prefix = publicPrefix(ctx.root, app)
|
|
172
|
+
prefixes.set(app, prefix)
|
|
173
|
+
}
|
|
174
|
+
const reads = privateEnvReads(file, content, prefix)
|
|
175
|
+
if (reads.length === 0) continue
|
|
176
|
+
const first = reads[0] ?? ""
|
|
177
|
+
const name = /[.[(]["']?([A-Za-z_$][\w$]*)["']?[)\]]?$/.exec(first)?.[1]
|
|
178
|
+
const index = name === undefined ? -1 : content.indexOf(name)
|
|
179
|
+
findings.push(
|
|
180
|
+
diagnostic({
|
|
181
|
+
code: "NF-C029",
|
|
182
|
+
severity: "error",
|
|
183
|
+
file,
|
|
184
|
+
...(index === -1 ? {} : { line: content.slice(0, index).split("\n").length }),
|
|
185
|
+
message: `${file} ${privateEnvReason(reads, prefix)}`,
|
|
186
|
+
evidence: reads.map((read) => `read: ${read}`),
|
|
187
|
+
verify: "nifra check",
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
return findings
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule])
|
package/src/scaffold.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra_scaffold` - turn a URL path into the correct `routes/`
|
|
3
|
-
* gets wrong) + a minimal, contract-correct
|
|
4
|
-
*
|
|
5
|
-
* `/` → `
|
|
2
|
+
* `nifra_scaffold` - turn a URL path into the correct `routes/` files (the convention an agent most often
|
|
3
|
+
* gets wrong) + a minimal, contract-correct route pair: the page, and its `.backend.ts` half holding the
|
|
4
|
+
* loader and its output schema. The mapping is the inverse of @nifrajs/web's `filePathToPatterns`:
|
|
5
|
+
* `:id`/`[id]` → `[id]`, `*rest`/`[...rest]` → `[...rest]`, `[[lang]]` optional, `/` → `index`. The
|
|
6
|
+
* framework (→ file extension) comes from the project's `clientModule`.
|
|
6
7
|
*
|
|
7
8
|
* Page stubs are emitted for the JSX family (react/preact/solid - one shared, verified shape) and
|
|
8
9
|
* for vanilla (a zero-runtime `html` page carrying the golden island pattern); for vue/svelte we
|
|
9
|
-
* return the correct
|
|
10
|
-
* rather than hand-writing an SFC we can't typecheck here.
|
|
10
|
+
* return the correct PATHS, the backend half and the route contract, and point at `nifra_example` for
|
|
11
|
+
* the page body, rather than hand-writing an SFC we can't typecheck here.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { lstat, mkdir, realpath, writeFile } from "node:fs/promises"
|
|
14
|
-
import { dirname, resolve, sep } from "node:path"
|
|
15
|
+
import { basename, dirname, resolve, sep } from "node:path"
|
|
16
|
+
import { paramConstraint } from "@nifrajs/core/pattern"
|
|
15
17
|
|
|
16
18
|
export type Framework = "react" | "preact" | "solid" | "vue" | "svelte" | "vanilla"
|
|
17
19
|
|
|
@@ -52,6 +54,12 @@ function assertSafeRouteSegment(segment: string, urlPath: string): void {
|
|
|
52
54
|
`invalid route segment in ${JSON.stringify(urlPath)}: ${JSON.stringify(segment)}`,
|
|
53
55
|
)
|
|
54
56
|
}
|
|
57
|
+
const brace = segment.indexOf("{")
|
|
58
|
+
if (segment.startsWith(":") && brace > 0 && paramConstraint(segment.slice(brace)) !== undefined) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`a param constraint cannot be written in a route file name: ${JSON.stringify(segment)} in ${JSON.stringify(urlPath)}. Scaffold the page without it and check the value in the route's loader.`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
55
63
|
const fileSegment = segmentToFile(segment)
|
|
56
64
|
if (
|
|
57
65
|
fileSegment === "." ||
|
|
@@ -83,25 +91,69 @@ export function routePathToFile(urlPath: string, ext: string): string {
|
|
|
83
91
|
return `routes/${segments.map(segmentToFile).join("/")}.${ext}`
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
const ROUTE_CONTRACT = `A route
|
|
87
|
-
- \`export default function Page(
|
|
88
|
-
- \`export async function loader({ params,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
const ROUTE_CONTRACT = `A route is two files:
|
|
95
|
+
- The page, \`routes/x.<ext>\` - ships to the browser: \`export default function Page({ data }: Route.ComponentProps)\` and \`export const meta = { title, meta: […] }\`. It never imports backend code.
|
|
96
|
+
- Its backend half, \`routes/x.backend.ts\` - never reaches the browser: \`export const loaderOutput = t.object({…})\` with \`export async function loader({ params, api }: Route.LoaderArgs)\` (data for SSR - only what the schema declares is sent), \`actionOutput\` with \`action\` (the form POST), and \`hydrate\`, \`islandScripts\`, \`revalidate\`, \`middleware\`. Reach the backend through the typed \`api\`.
|
|
97
|
+
Both import \`type { Route } from "./+types/<name>"\`, which \`nifra dev\`, \`nifra build\`, \`nifra check\` and \`nifra types\` generate. Path params are typed on \`params\`.`
|
|
98
|
+
|
|
99
|
+
interface RouteParam {
|
|
100
|
+
readonly name: string
|
|
101
|
+
readonly optional: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Param names a route file declares, for the stubs. */
|
|
105
|
+
function paramsOf(file: string): RouteParam[] {
|
|
106
|
+
const out: RouteParam[] = []
|
|
107
|
+
for (const m of file.matchAll(/(\[)?\[(?:\.\.\.)?([A-Za-z_][A-Za-z0-9_]*)\]/g))
|
|
108
|
+
out.push({ name: m[2] as string, optional: m[1] !== undefined })
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `./+types/<name>` for a route file: the generated module both halves import `Route` from. */
|
|
113
|
+
const typesOf = (file: string): string => `./+types/${basename(file).replace(/\.[^.]+$/, "")}`
|
|
114
|
+
|
|
115
|
+
/** `routes/users/[id].tsx` → `routes/users/[id].backend.ts`. */
|
|
116
|
+
export const backendFileOf = (file: string): string => file.replace(/\.[^./]+$/, ".backend.ts")
|
|
117
|
+
|
|
118
|
+
/** The page's backend half: its loader, and the schema that bounds what the loader sends. */
|
|
119
|
+
function backendStub(file: string, params: readonly RouteParam[], vanilla: boolean): string {
|
|
120
|
+
const last = params.at(-1)
|
|
121
|
+
const loader =
|
|
122
|
+
last === undefined
|
|
123
|
+
? `export async function loader(_args: Route.LoaderArgs) {
|
|
124
|
+
// Load through the typed backend client, \`api\` in these args; see nifra_example("loader").
|
|
125
|
+
return { title: "TODO" }
|
|
126
|
+
}`
|
|
127
|
+
: `export async function loader({ params }: Route.LoaderArgs) {
|
|
128
|
+
// Load by params.${last.name} through the typed backend client, \`api\` in these args; see nifra_example("loader").
|
|
129
|
+
return { title: \`TODO: \${params.${last.name}${last.optional ? ' ?? ""' : ""}}\` }
|
|
130
|
+
}`
|
|
131
|
+
const vanillaExports = vanilla
|
|
132
|
+
? `
|
|
133
|
+
|
|
134
|
+
// No client framework to hydrate with - vanilla routes are documents, not hydrated apps.
|
|
135
|
+
export const hydrate = false
|
|
136
|
+
|
|
137
|
+
// The built URL of each island enhancer this page renders a marker for (./<name>.client.ts).
|
|
138
|
+
// export const islandScripts = []`
|
|
139
|
+
: ""
|
|
140
|
+
return `// ${backendFileOf(file)} - the server half of ${file}; it never reaches the browser.
|
|
141
|
+
import { t } from "@nifrajs/schema"
|
|
142
|
+
import type { Route } from "${typesOf(file)}"
|
|
143
|
+
|
|
144
|
+
// Only what this schema declares is sent to the page.
|
|
145
|
+
export const loaderOutput = t.object({ title: t.string() })
|
|
146
|
+
|
|
147
|
+
${loader}${vanillaExports}
|
|
148
|
+
`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function jsxStub(file: string): string {
|
|
152
|
+
return `// ${file} - the page; it ships to the browser. Its data comes from ${basename(backendFileOf(file))}.
|
|
153
|
+
import type { Route } from "${typesOf(file)}"
|
|
154
|
+
|
|
155
|
+
export default function Page({ data }: Route.ComponentProps) {
|
|
156
|
+
return <main>{data.title}</main>
|
|
105
157
|
}
|
|
106
158
|
`
|
|
107
159
|
}
|
|
@@ -113,29 +165,20 @@ export default function Page() {
|
|
|
113
165
|
* returns its cleanup (the one thing NF-C020 checks), wired through `mountIslands`. Interactivity is
|
|
114
166
|
* added by uncommenting and writing the companion `<name>.client.ts`, never by turning on hydration.
|
|
115
167
|
*/
|
|
116
|
-
function vanillaStub(file: string
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const loaderLine =
|
|
120
|
-
params.length > 0
|
|
121
|
-
? `// export async function loader({ params, api }: LoaderArgs<typeof backend>) { return { /* fetch by ${params[0]} */ } }`
|
|
122
|
-
: `// export async function loader({ api }: LoaderArgs<typeof backend>) { return {} }`
|
|
123
|
-
return `// ${file} - @nifrajs/web-vanilla route. Server-rendered HTML, ZERO framework runtime.
|
|
124
|
-
// Available here: ${paramsNote}. Fetch data in a loader via the typed \`api\`; see nifra_example("loader").
|
|
168
|
+
function vanillaStub(file: string): string {
|
|
169
|
+
return `// ${file} - @nifrajs/web-vanilla page. Server-rendered HTML, ZERO framework runtime; its data,
|
|
170
|
+
// and \`hydrate = false\`, live in ${basename(backendFileOf(file))}.
|
|
125
171
|
import { html } from "@nifrajs/web-vanilla"
|
|
172
|
+
import type { Route } from "${typesOf(file)}"
|
|
126
173
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
${loaderLine}
|
|
131
|
-
|
|
132
|
-
export default function Page() {
|
|
133
|
-
return html\`<main><h1>TODO: ${file}</h1></main>\`
|
|
174
|
+
export default function Page({ data }: Route.ComponentProps) {
|
|
175
|
+
return html\`<main><h1>\${data.title}</h1></main>\`
|
|
134
176
|
}
|
|
135
177
|
|
|
136
178
|
// --- Add interactivity the AI-safe way (islands), NOT hydration ---------------------------------
|
|
137
179
|
// 1. Render a marker in the page above: html\`<nifra-island data-id="counter" data-props=\${JSON.stringify({ start: 0 })}></nifra-island>\`
|
|
138
|
-
// 2. Wire the route to its enhancer bundle
|
|
180
|
+
// 2. Wire the route to its enhancer bundle, in ${basename(backendFileOf(file))}:
|
|
181
|
+
// export const islandScripts = [/* built URL of ./counter.client.ts */]
|
|
139
182
|
// 3. Write ./counter.client.ts as an imperative enhancer that ALWAYS returns its cleanup:
|
|
140
183
|
//
|
|
141
184
|
// import { defineIsland, mountIslands } from "@nifrajs/web/islands"
|
|
@@ -164,33 +207,21 @@ export default function Page() {
|
|
|
164
207
|
* The page body is a valid, typecheckable static document; interactivity is added by writing the
|
|
165
208
|
* companion client shown, never by turning on hydration.
|
|
166
209
|
*/
|
|
167
|
-
function nanoStub(file: string
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
params.length > 0
|
|
172
|
-
? `// export async function loader({ params, api }: LoaderArgs<typeof backend>) { return { /* seed by ${params[0]} */ } }`
|
|
173
|
-
: `// export async function loader({ api }: LoaderArgs<typeof backend>) { return {} }`
|
|
174
|
-
return `// ${file} - @nifrajs/web-vanilla route + a nano island (explicit reactivity, zero VDOM).
|
|
175
|
-
// Available here: ${paramsNote}. Seed data in a loader via the typed \`api\`; see nifra_example("loader").
|
|
210
|
+
function nanoStub(file: string): string {
|
|
211
|
+
return `// ${file} - @nifrajs/web-vanilla page + a nano island (explicit reactivity, zero VDOM). The
|
|
212
|
+
// document is static and the nano client below owns all interactivity; \`hydrate = false\` and the
|
|
213
|
+
// island's \`islandScripts\` URL live in ${basename(backendFileOf(file))}.
|
|
176
214
|
import { html } from "@nifrajs/web-vanilla"
|
|
215
|
+
import type { Route } from "${typesOf(file)}"
|
|
177
216
|
|
|
178
|
-
|
|
179
|
-
export const hydrate = false
|
|
180
|
-
|
|
181
|
-
${loaderLine}
|
|
182
|
-
|
|
183
|
-
export default function Page() {
|
|
217
|
+
export default function Page({ data }: Route.ComponentProps) {
|
|
184
218
|
// The island marker: data-props is the initial state the client reads on mount.
|
|
185
219
|
return html\`<main>
|
|
186
|
-
<h1
|
|
220
|
+
<h1>\${data.title}</h1>
|
|
187
221
|
<nifra-island data-id="todos" data-props=\${JSON.stringify({ items: [] })}></nifra-island>
|
|
188
222
|
</main>\`
|
|
189
223
|
}
|
|
190
224
|
|
|
191
|
-
// Wire the route to the built URL of ./todos.client.ts (the nano enhancer below).
|
|
192
|
-
// export const islandScripts = [/* built URL of ./todos.client.ts */]
|
|
193
|
-
|
|
194
225
|
// --- ./todos.client.ts - the golden nano pattern (write this as a companion file) -----------------
|
|
195
226
|
//
|
|
196
227
|
// import { defineIsland, mountIslands } from "@nifrajs/web/islands"
|
|
@@ -234,17 +265,13 @@ export default function Page() {
|
|
|
234
265
|
`
|
|
235
266
|
}
|
|
236
267
|
|
|
237
|
-
/** Param names a route file declares, for the stub's notes. */
|
|
238
|
-
function paramsOf(file: string): string[] {
|
|
239
|
-
const out: string[] = []
|
|
240
|
-
for (const m of file.matchAll(/\[(?:\.\.\.)?([A-Za-z_][A-Za-z0-9_]*)\]/g))
|
|
241
|
-
out.push(m[1] as string)
|
|
242
|
-
return out
|
|
243
|
-
}
|
|
244
|
-
|
|
245
268
|
export interface ScaffoldResult {
|
|
269
|
+
/** The page file. */
|
|
246
270
|
readonly file: string
|
|
271
|
+
/** The page stub, when there is a verified one for the framework. */
|
|
247
272
|
readonly content?: string
|
|
273
|
+
/** The page's backend half - framework-independent, so always present. */
|
|
274
|
+
readonly backend: { readonly file: string; readonly content: string }
|
|
248
275
|
readonly note: string
|
|
249
276
|
}
|
|
250
277
|
|
|
@@ -253,8 +280,9 @@ export interface ScaffoldWriteResult extends ScaffoldResult {
|
|
|
253
280
|
readonly reason?: string
|
|
254
281
|
}
|
|
255
282
|
|
|
256
|
-
/** Scaffold a page route for `urlPath` under the project's `framework`. Returns the correct file
|
|
257
|
-
* always; a ready-to-write stub for the JSX family, contract
|
|
283
|
+
/** Scaffold a page route for `urlPath` under the project's `framework`. Returns the correct file paths
|
|
284
|
+
* and the backend half always; a ready-to-write page stub for the JSX family and vanilla, contract
|
|
285
|
+
* guidance otherwise. */
|
|
258
286
|
export function scaffoldRoute(
|
|
259
287
|
urlPath: string,
|
|
260
288
|
framework: Framework,
|
|
@@ -263,32 +291,40 @@ export function scaffoldRoute(
|
|
|
263
291
|
const ext = EXT[framework]
|
|
264
292
|
const file = routePathToFile(urlPath, ext)
|
|
265
293
|
const params = paramsOf(file)
|
|
294
|
+
const backend = {
|
|
295
|
+
file: backendFileOf(file),
|
|
296
|
+
content: backendStub(file, params, framework === "vanilla"),
|
|
297
|
+
}
|
|
266
298
|
// `stateful` is vanilla-only: it swaps the static island stub for the nano golden pattern. Asking
|
|
267
299
|
// for it on a JSX/SFC framework is a no-op on the flavour (those lanes have their own reactivity).
|
|
268
300
|
if (framework === "vanilla" && variant === "stateful") {
|
|
269
301
|
return {
|
|
270
302
|
file,
|
|
271
|
-
content: nanoStub(file
|
|
272
|
-
|
|
303
|
+
content: nanoStub(file),
|
|
304
|
+
backend,
|
|
305
|
+
note: `Create ${file} and ${backend.file} as a zero-runtime @nifrajs/web-vanilla route with a nano island. ${ROUTE_CONTRACT}\nThe stub embeds the golden nano client (signal + computed(fn,[deps]) + keyed bindList + collected cleanups); write it as the companion .client.ts. \`nifra check\` runs NF-C021/C022/C023 over it; nifra_docs("nano") is the full cookbook.`,
|
|
273
306
|
}
|
|
274
307
|
}
|
|
275
308
|
if (framework === "react" || framework === "preact" || framework === "solid") {
|
|
276
309
|
return {
|
|
277
310
|
file,
|
|
278
|
-
content: jsxStub(file
|
|
279
|
-
|
|
311
|
+
content: jsxStub(file),
|
|
312
|
+
backend,
|
|
313
|
+
note: `Create ${file} and ${backend.file}. ${ROUTE_CONTRACT}\nIf the rendered page misbehaves (hydration warning, a value that stops updating), call nifra_frontend { adapter: "${framework}" } for the cause + fix.`,
|
|
280
314
|
}
|
|
281
315
|
}
|
|
282
316
|
if (framework === "vanilla") {
|
|
283
317
|
return {
|
|
284
318
|
file,
|
|
285
|
-
content: vanillaStub(file
|
|
286
|
-
|
|
319
|
+
content: vanillaStub(file),
|
|
320
|
+
backend,
|
|
321
|
+
note: `Create ${file} and ${backend.file} as a zero-runtime @nifrajs/web-vanilla route. ${ROUTE_CONTRACT}\nInteractivity comes from islands (imperative enhancers), never hydration - the stub embeds the golden pattern; nifra_example("islands") has the full cookbook. For explicit local state (a list a human edits), scaffold with variant "stateful" to get the nano pattern instead.`,
|
|
287
322
|
}
|
|
288
323
|
}
|
|
289
324
|
return {
|
|
290
325
|
file,
|
|
291
|
-
|
|
326
|
+
backend,
|
|
327
|
+
note: `Create ${file} as a ${framework} route module, and ${backend.file} from the stub below. ${ROUTE_CONTRACT}\nFor the ${framework} page body, call nifra_example (it ships verified ${framework} snippets) rather than guessing the SFC shape. If a value stops updating the template, call nifra_frontend { adapter: "${framework}" } for the ${framework} reactivity-loss fix.`,
|
|
292
328
|
}
|
|
293
329
|
}
|
|
294
330
|
|
|
@@ -333,9 +369,11 @@ async function assertNoSymlinkedAncestors(root: string, target: string): Promise
|
|
|
333
369
|
}
|
|
334
370
|
}
|
|
335
371
|
|
|
336
|
-
/** Write a scaffolded route
|
|
337
|
-
* intentionally conservative: it refuses
|
|
338
|
-
* uses `wx`, so an agent
|
|
372
|
+
/** Write a scaffolded route pair when the framework has a verified ready-to-write page body. The write
|
|
373
|
+
* is intentionally conservative: it refuses frameworks where we only return contract guidance (a
|
|
374
|
+
* backend half alone is a build error), refuses when either file exists, and uses `wx`, so an agent
|
|
375
|
+
* cannot overwrite user work by accident. It then generates the route's `./+types` module, so both
|
|
376
|
+
* halves typecheck at once. */
|
|
339
377
|
export async function writeScaffoldRoute(
|
|
340
378
|
cwd: string,
|
|
341
379
|
urlPath: string,
|
|
@@ -350,22 +388,35 @@ export async function writeScaffoldRoute(
|
|
|
350
388
|
reason: "no verified ready-to-write stub for this framework; use nifra_example for the body",
|
|
351
389
|
}
|
|
352
390
|
}
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
return { ...result, written: false, reason: `file already exists: ${
|
|
391
|
+
const files = [
|
|
392
|
+
{ file: result.file, content: result.content },
|
|
393
|
+
{ file: result.backend.file, content: result.backend.content },
|
|
394
|
+
]
|
|
395
|
+
const targets = files.map(({ file }) => resolveInsideRoutes(cwd, file))
|
|
396
|
+
for (const target of targets) await assertNoSymlinkedAncestors(cwd, target)
|
|
397
|
+
for (const [i, target] of targets.entries()) {
|
|
398
|
+
if ((await lstat(target).catch(() => undefined)) !== undefined) {
|
|
399
|
+
return { ...result, written: false, reason: `file already exists: ${files[i]?.file}` }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
await mkdir(dirname(targets[0] as string), { recursive: true })
|
|
403
|
+
for (const target of targets) await assertNoSymlinkedAncestors(cwd, target)
|
|
404
|
+
for (const [i, target] of targets.entries()) {
|
|
405
|
+
try {
|
|
406
|
+
await writeFile(target, files[i]?.content ?? "", { flag: "wx" })
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (err && typeof err === "object" && (err as { code?: string }).code === "EEXIST") {
|
|
409
|
+
return { ...result, written: false, reason: `file already exists: ${files[i]?.file}` }
|
|
410
|
+
}
|
|
411
|
+
throw err
|
|
362
412
|
}
|
|
363
|
-
throw err
|
|
364
413
|
}
|
|
414
|
+
const { refreshRouteTypes } = await import("./route-types.ts")
|
|
415
|
+
refreshRouteTypes(cwd, () => {})
|
|
365
416
|
return { ...result, written: true }
|
|
366
417
|
}
|
|
367
418
|
|
|
368
|
-
/** Render the tool result as markdown - the file
|
|
419
|
+
/** Render the tool result as markdown - the file paths, the stubs (if any), and the contract note. */
|
|
369
420
|
export function renderScaffold(
|
|
370
421
|
urlPath: string,
|
|
371
422
|
framework: Framework,
|
|
@@ -377,6 +428,7 @@ export function renderScaffold(
|
|
|
377
428
|
} catch (err) {
|
|
378
429
|
return `Cannot scaffold ${JSON.stringify(urlPath)}: ${err instanceof Error ? err.message : String(err)}`
|
|
379
430
|
}
|
|
380
|
-
const
|
|
381
|
-
|
|
431
|
+
const page = r.content ? `\n\n\`${r.file}\`:\n\n\`\`\`${EXT[framework]}\n${r.content}\`\`\`` : ""
|
|
432
|
+
const backend = `\n\n\`${r.backend.file}\`:\n\n\`\`\`ts\n${r.backend.content}\`\`\``
|
|
433
|
+
return `# Scaffold route \`${urlPath}\` (${framework})\n\n**Files:** \`${r.file}\` + \`${r.backend.file}\`\n\n${r.note}${page}${backend}`
|
|
382
434
|
}
|