create-minlang-app 0.1.0 → 0.3.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/bin.mjs +30 -8
- package/package.json +1 -1
- package/template/AGENTS.md +66 -0
- package/template/CLAUDE.md +3 -0
- package/template/Makefile +13 -1
- package/template/README.md +7 -3
- package/template/app/app/screens/[screen]/page.tsx +17 -0
- package/template/app/e2e/99-screens.spec.ts +32 -0
- package/template/app/package.json +12 -6
- package/template/app/skins/README.md +15 -0
- package/template/app/tsconfig.typecheck.json +4 -0
- package/template/github/workflows/deploy-vercel.yml +12 -53
- package/template/gitignore +1 -0
- package/template/scripts/lint-skins.mjs +197 -0
- package/template/app/app/screens/board/page.tsx +0 -5
- package/template/app/app/screens/projects/page.tsx +0 -5
package/bin.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// published @minlang/* runtime packages. Compile with ml1 (see the printed
|
|
5
5
|
// next steps), run with pnpm, deploy via the included Vercel workflow.
|
|
6
6
|
|
|
7
|
-
import { cpSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
@@ -37,22 +37,44 @@ substitute(dest);
|
|
|
37
37
|
renameSync(join(dest, "__APP_NAME__.ml"), join(dest, `${name}.ml`));
|
|
38
38
|
// npm strips dotfiles from published packages; restore them.
|
|
39
39
|
renameSync(join(dest, "gitignore"), join(dest, ".gitignore"));
|
|
40
|
+
renameSync(join(dest, "gitattributes"), join(dest, ".gitattributes"));
|
|
40
41
|
renameSync(join(dest, "github"), join(dest, ".github"));
|
|
41
42
|
|
|
43
|
+
// .github/ only works at the repository root: warn when the scaffold is
|
|
44
|
+
// nested inside an existing git repo, where the deploy workflow would be dead.
|
|
45
|
+
const enclosingGitRoot = (start) => {
|
|
46
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
47
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
48
|
+
if (dirname(dir) === dir) return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const gitRoot = enclosingGitRoot(dirname(dest));
|
|
52
|
+
|
|
42
53
|
console.log(`
|
|
43
54
|
Created ${name}/ — a MinLang web app.
|
|
44
55
|
|
|
45
56
|
Next steps:
|
|
46
57
|
cd ${name}
|
|
47
|
-
# 1. install the ml1 compiler
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# 2.
|
|
58
|
+
# 1. install the ml1 compiler:
|
|
59
|
+
# bash <(curl -fsSL https://raw.githubusercontent.com/codeshift-ai-solutions/minlang-releases/main/install/install.sh)
|
|
60
|
+
# (Windows: iwr -useb https://raw.githubusercontent.com/codeshift-ai-solutions/minlang-releases/main/install/install.ps1 | iex)
|
|
61
|
+
# 2. one-time toolchain setup:
|
|
62
|
+
corepack enable && corepack prepare pnpm@10.33.0 --activate
|
|
63
|
+
# 3. compile, install, run:
|
|
51
64
|
make compile
|
|
52
65
|
pnpm --dir app install
|
|
53
|
-
|
|
66
|
+
make dev # http://localhost:3111
|
|
54
67
|
|
|
55
68
|
Your whole app lives in ${name}.ml (it starts as a task tracker — replace it).
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
Agent instructions: ${name}/AGENTS.md
|
|
70
|
+
Language rules: https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
71
|
+
Deploys: push to GitHub with org secrets VERCEL_TOKEN/VERCEL_ORG_ID set;
|
|
72
|
+
the first deploy prints a VERCEL_PROJECT_ID to pin as a repo secret.
|
|
58
73
|
`);
|
|
74
|
+
|
|
75
|
+
if (gitRoot) {
|
|
76
|
+
console.warn(`warning: ${name}/ is nested inside the git repository at ${gitRoot}.
|
|
77
|
+
GitHub only runs workflows from the repository root, so ${name}/.github/ would
|
|
78
|
+
be ignored. Either create the app in its own repository (recommended) or move
|
|
79
|
+
${name}/.github/workflows/* to ${gitRoot}/.github/workflows/ and adjust paths.`);
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Agent instructions for __APP_NAME__
|
|
2
|
+
|
|
3
|
+
This is a **MinLang** app. The entire application is `__APP_NAME__.ml` —
|
|
4
|
+
entities, constraints, actions, queries, screens, and tests in one file.
|
|
5
|
+
Everything under `app/generated/` is compiler output: committed, **never
|
|
6
|
+
hand-edited**. The `app/` directory is a thin Next.js shell over the
|
|
7
|
+
published `@minlang/*` runtime packages; it rarely needs changes.
|
|
8
|
+
|
|
9
|
+
## The one rule
|
|
10
|
+
|
|
11
|
+
To change behavior, edit `__APP_NAME__.ml`, then run `make compile`.
|
|
12
|
+
Never edit files under `app/generated/` — CI recompiles with `--check`
|
|
13
|
+
and fails the build if they drift from the `.ml` source.
|
|
14
|
+
|
|
15
|
+
## Language authority (fetch before writing MinLang)
|
|
16
|
+
|
|
17
|
+
Download the single-file language bundle and read it before producing or
|
|
18
|
+
modifying any MinLang source:
|
|
19
|
+
|
|
20
|
+
https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
21
|
+
|
|
22
|
+
It contains the canonical rules (Creator + Gatekeeper packets), the
|
|
23
|
+
detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
24
|
+
- Forbidden everywhere: `now()`, `today()`, `random()`, `current_user`
|
|
25
|
+
(runtime inputs are injected: `created_at`, `current_time`, `actor_id`,
|
|
26
|
+
`rng_draw`, `request_id`).
|
|
27
|
+
- Generate -> validate -> fix -> revalidate until zero violations, then
|
|
28
|
+
output. `ml1 validate` is the same gate CI runs.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Task | Command |
|
|
33
|
+
|------|---------|
|
|
34
|
+
| One-time setup | `corepack enable && corepack prepare pnpm@10.33.0 --activate` |
|
|
35
|
+
| Validate the program | `make validate` |
|
|
36
|
+
| Compile to `app/generated/` | `make compile` |
|
|
37
|
+
| Install shell deps | `pnpm --dir app install` |
|
|
38
|
+
| Unit + generated test triads | `make test` |
|
|
39
|
+
| Lint custom skins (`app/skins/`) | `make lint-skins` (also runs in `make test`) |
|
|
40
|
+
| Dev server (http://localhost:3111) | `make dev` |
|
|
41
|
+
| Update toolchain + deps | `make update` |
|
|
42
|
+
| Production build | `make build` |
|
|
43
|
+
| Browser tests (a11y, perf) | `make test-e2e` (installs the browser on first run) |
|
|
44
|
+
| Screen previews (`screen-previews/*.png`, all screens, mobile + desktop) | `make preview` |
|
|
45
|
+
|
|
46
|
+
`make compile` requires the `ml1` compiler on PATH — install per README.
|
|
47
|
+
If `pnpm typecheck` complains about routes you deleted, remove the stale
|
|
48
|
+
Next cache: `rm -rf app/.next`.
|
|
49
|
+
|
|
50
|
+
## Workflow for every change
|
|
51
|
+
|
|
52
|
+
1. Edit `__APP_NAME__.ml`.
|
|
53
|
+
2. `make compile` (validates first; fails on any violation).
|
|
54
|
+
3. `make test`.
|
|
55
|
+
4. Commit the `.ml` file **and** `app/generated/` together.
|
|
56
|
+
|
|
57
|
+
## Shell rules (everything outside the .ml and app/generated/)
|
|
58
|
+
|
|
59
|
+
The shell is wiring and presentation only. Never put domain logic,
|
|
60
|
+
user-facing copy, or input validation in shell code — they belong in
|
|
61
|
+
`__APP_NAME__.ml`, where the validator and generated tests cover them.
|
|
62
|
+
Screens route through the single dynamic page
|
|
63
|
+
`app/app/screens/[screen]/page.tsx`; do not create per-screen folders.
|
|
64
|
+
Custom widget renderers ("skins") go in `app/skins/` — see the Skins
|
|
65
|
+
section of the language bundle; `make lint-skins` enforces the contract
|
|
66
|
+
(no hardcoded copy, no raw colors, no fetch/Date.now/adapter imports).
|
package/template/Makefile
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
APP := __APP_NAME__
|
|
2
2
|
|
|
3
|
-
.PHONY: validate compile build test test-e2e dev
|
|
3
|
+
.PHONY: validate compile build test test-e2e lint-skins preview dev update
|
|
4
4
|
|
|
5
5
|
validate:
|
|
6
6
|
ml1 validate $(APP).ml
|
|
@@ -13,9 +13,21 @@ build:
|
|
|
13
13
|
|
|
14
14
|
test:
|
|
15
15
|
pnpm --dir app run test
|
|
16
|
+
node scripts/lint-skins.mjs app/skins
|
|
17
|
+
|
|
18
|
+
lint-skins:
|
|
19
|
+
node scripts/lint-skins.mjs app/skins
|
|
16
20
|
|
|
17
21
|
test-e2e:
|
|
22
|
+
pnpm --dir app exec playwright install chromium
|
|
18
23
|
pnpm --dir app run test:e2e
|
|
19
24
|
|
|
25
|
+
preview:
|
|
26
|
+
pnpm --dir app exec playwright install chromium
|
|
27
|
+
pnpm --dir app run test:e2e e2e/99-screens.spec.ts
|
|
28
|
+
|
|
20
29
|
dev:
|
|
21
30
|
pnpm --dir app run dev
|
|
31
|
+
|
|
32
|
+
update:
|
|
33
|
+
ml1 update
|
package/template/README.md
CHANGED
|
@@ -5,11 +5,15 @@ A MinLang web app. The whole application lives in `__APP_NAME__.ml`;
|
|
|
5
5
|
the thin Next.js shell consuming the published `@minlang/*` runtime.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
+
corepack enable && corepack prepare pnpm@10.33.0 --activate
|
|
8
9
|
make compile # __APP_NAME__.ml -> app/generated/ (needs ml1 on PATH)
|
|
9
10
|
pnpm --dir app install
|
|
10
|
-
|
|
11
|
+
make dev # http://localhost:3111
|
|
11
12
|
make test # generated test triads + integration
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
https://
|
|
15
|
+
Install ml1:
|
|
16
|
+
`bash <(curl -fsSL https://raw.githubusercontent.com/codeshift-ai-solutions/minlang-releases/main/install/install.sh)`
|
|
17
|
+
|
|
18
|
+
Language rules + authoring guide (single file, for you and your coding agent):
|
|
19
|
+
https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Thin dynamic route over the generated screens — wrapper pages never go
|
|
2
|
+
// stale when the screen set changes. Unknown keys 404.
|
|
3
|
+
// Segment config must live in the page file itself for Next to see it.
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
import type { ReactElement } from "react";
|
|
7
|
+
import { notFound } from "next/navigation";
|
|
8
|
+
import { AppScreen } from "../../../generated/app/screen";
|
|
9
|
+
import { screenSchemas } from "../../../generated/ui/screens";
|
|
10
|
+
|
|
11
|
+
export default async function Page(props: {
|
|
12
|
+
params: Promise<{ screen: string }>;
|
|
13
|
+
}): Promise<ReactElement> {
|
|
14
|
+
const { screen } = await props.params;
|
|
15
|
+
if (!screenSchemas.some((s) => s.key === screen)) notFound();
|
|
16
|
+
return AppScreen({ screenKey: screen });
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Screen previews: a full-page screenshot of every manifest screen at
|
|
2
|
+
// mobile (390x844) and desktop (1280x800) viewports, written to
|
|
3
|
+
// screen-previews/ (gitignored) for the CI artifact and `make preview`.
|
|
4
|
+
// No assertions beyond successful page load. Runs last (99-) so data
|
|
5
|
+
// created by earlier specs makes the previews realistic.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { expect, test } from "@playwright/test";
|
|
10
|
+
import manifest from "../generated/manifest.json";
|
|
11
|
+
|
|
12
|
+
const viewports = [
|
|
13
|
+
{ name: "mobile", width: 390, height: 844 },
|
|
14
|
+
{ name: "desktop", width: 1280, height: 800 },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const outDir = join(__dirname, "..", "screen-previews");
|
|
18
|
+
|
|
19
|
+
for (const screen of manifest.screens) {
|
|
20
|
+
for (const viewport of viewports) {
|
|
21
|
+
test(`preview: ${screen.key} (${viewport.name})`, async ({ page }) => {
|
|
22
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
23
|
+
await page.goto(screen.route, { waitUntil: "networkidle" });
|
|
24
|
+
await expect(page.getByRole("heading", { level: 1 }).first()).toBeVisible();
|
|
25
|
+
mkdirSync(outDir, { recursive: true });
|
|
26
|
+
await page.screenshot({
|
|
27
|
+
path: join(outDir, `${screen.key}-${viewport.name}.png`),
|
|
28
|
+
fullPage: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"name": "__APP_NAME__-web",
|
|
3
3
|
"private": true,
|
|
4
4
|
"scripts": {
|
|
5
|
-
"dev": "next dev",
|
|
5
|
+
"dev": "next dev --port 3111",
|
|
6
6
|
"build": "next build",
|
|
7
7
|
"start": "next start --port 3111",
|
|
8
|
-
"typecheck": "tsc -p tsconfig.json",
|
|
8
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
9
9
|
"test": "vitest run",
|
|
10
10
|
"test:e2e": "next build && playwright test"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@minlang/design-system": "^0.1
|
|
14
|
-
"@minlang/runtime-web": "^0.1
|
|
15
|
-
"@minlang/tailwind-preset": "^0.1
|
|
13
|
+
"@minlang/design-system": "^0.2.1",
|
|
14
|
+
"@minlang/runtime-web": "^0.2.1",
|
|
15
|
+
"@minlang/tailwind-preset": "^0.2.1",
|
|
16
16
|
"next": "15.5.19",
|
|
17
17
|
"react": "19.2.7",
|
|
18
18
|
"react-dom": "19.2.7",
|
|
@@ -31,5 +31,11 @@
|
|
|
31
31
|
"typescript": "5.6.3",
|
|
32
32
|
"vitest": "3.2.6"
|
|
33
33
|
},
|
|
34
|
-
"packageManager": "pnpm@10.33.0"
|
|
34
|
+
"packageManager": "pnpm@10.33.0",
|
|
35
|
+
"pnpm": {
|
|
36
|
+
"onlyBuiltDependencies": [
|
|
37
|
+
"esbuild",
|
|
38
|
+
"sharp"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
35
41
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Skins
|
|
2
|
+
|
|
3
|
+
Custom renderers ("skins") for widget keys, registered in `../seed.ts` via
|
|
4
|
+
`bindRegistryOverrides({ <widget>: Component })`. One skin per file
|
|
5
|
+
(`<widget-key>.tsx`), presentation only: props are exactly `WidgetProps<K>`,
|
|
6
|
+
copy comes only from the schema node, styling uses design-token classes
|
|
7
|
+
(never hex/rgb literals), and state changes go through the provided
|
|
8
|
+
server-action refs.
|
|
9
|
+
|
|
10
|
+
The full recipe (including adapting Figma Make exports) is the **Skins**
|
|
11
|
+
section of the language bundle:
|
|
12
|
+
https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
13
|
+
|
|
14
|
+
The contract is mechanically enforced — `make lint-skins`
|
|
15
|
+
(`node scripts/lint-skins.mjs app/skins`) runs as part of `make test` and CI.
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# Verifies the committed generated output matches the .ml source
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
1
|
+
# Verifies the committed generated output matches the .ml source, tests,
|
|
2
|
+
# builds, and deploys prebuilt to Vercel. All logic lives in the public
|
|
3
|
+
# reusable workflow (minlang-releases) — this file just calls it.
|
|
4
|
+
#
|
|
5
|
+
# Secrets (skips with a notice when absent):
|
|
6
|
+
# VERCEL_TOKEN + VERCEL_ORG_ID — org-level, set once for the whole org
|
|
7
|
+
# VERCEL_PROJECT_ID — per-repo; the first run links by name, prints the
|
|
8
|
+
# project id, and tells you to pin it as this secret
|
|
6
9
|
|
|
7
10
|
name: Deploy (Vercel)
|
|
8
11
|
|
|
@@ -17,51 +20,7 @@ concurrency:
|
|
|
17
20
|
|
|
18
21
|
jobs:
|
|
19
22
|
deploy:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
steps:
|
|
25
|
-
- uses: actions/checkout@v4
|
|
26
|
-
|
|
27
|
-
- name: Generated output is current
|
|
28
|
-
uses: codeshift-ai-solutions/minlang-core/.github/actions/minlang-compile@main
|
|
29
|
-
with:
|
|
30
|
-
file: __APP_NAME__.ml
|
|
31
|
-
target: web
|
|
32
|
-
out: app
|
|
33
|
-
check: "true"
|
|
34
|
-
|
|
35
|
-
- name: Setup Node + pnpm
|
|
36
|
-
uses: actions/setup-node@v4
|
|
37
|
-
with:
|
|
38
|
-
node-version: 22
|
|
39
|
-
- run: corepack enable
|
|
40
|
-
|
|
41
|
-
- name: Install, typecheck, test
|
|
42
|
-
working-directory: app
|
|
43
|
-
run: |
|
|
44
|
-
pnpm install --frozen-lockfile
|
|
45
|
-
pnpm run typecheck
|
|
46
|
-
pnpm run test
|
|
47
|
-
|
|
48
|
-
- name: Gate on Vercel secrets
|
|
49
|
-
id: gate
|
|
50
|
-
run: |
|
|
51
|
-
if [ -z "${VERCEL_TOKEN}" ] || [ -z "${VERCEL_ORG_ID}" ]; then
|
|
52
|
-
echo "notice: VERCEL_TOKEN / VERCEL_ORG_ID not set — skipping deploy"
|
|
53
|
-
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
|
54
|
-
else
|
|
55
|
-
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
|
56
|
-
fi
|
|
57
|
-
|
|
58
|
-
- name: Link, build, deploy
|
|
59
|
-
if: steps.gate.outputs.enabled == 'true'
|
|
60
|
-
working-directory: app
|
|
61
|
-
run: |
|
|
62
|
-
pnpm dlx vercel@latest link --yes \
|
|
63
|
-
--project "${{ github.event.repository.name }}" --token "${VERCEL_TOKEN}"
|
|
64
|
-
pnpm dlx vercel@latest pull --yes --environment=production --token "${VERCEL_TOKEN}"
|
|
65
|
-
pnpm dlx vercel@latest build --prod --token "${VERCEL_TOKEN}"
|
|
66
|
-
url=$(pnpm dlx vercel@latest deploy --prebuilt --prod --yes --token "${VERCEL_TOKEN}")
|
|
67
|
-
echo "deployed: $url" | tee -a "$GITHUB_STEP_SUMMARY"
|
|
23
|
+
uses: codeshift-ai-solutions/minlang-releases/.github/workflows/deploy-vercel.yml@main
|
|
24
|
+
with:
|
|
25
|
+
file: __APP_NAME__.ml
|
|
26
|
+
secrets: inherit
|
package/template/gitignore
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mechanical SHELL_CONTRACT lint for registry skins (spec/target-web/SKIN_GUIDE.md).
|
|
3
|
+
//
|
|
4
|
+
// Usage: node scripts/lint-skins.mjs [dir...] (default: app/skins, if it exists)
|
|
5
|
+
//
|
|
6
|
+
// Scans .ts/.tsx files line by line and exits 1 with file:line findings on:
|
|
7
|
+
// jsx-text hardcoded copy as JSX text (.tsx only — heuristic below)
|
|
8
|
+
// color-literal #hex (3/4/6/8) · rgb(/rgba(/hsl(/hsla( · Tailwind -[# classes
|
|
9
|
+
// forbidden-api fetch( · Date.now · Math.random · new Date( · toLocale*
|
|
10
|
+
// adapter-import imports of @minlang/runtime-web/server or any */adapters/* path
|
|
11
|
+
//
|
|
12
|
+
// jsx-text heuristic (documented contract): string literals are blanked, the
|
|
13
|
+
// line is cut at any // comment, =>/<=/>= and space-surrounded </> comparison
|
|
14
|
+
// operators are blanked, {…} JSX expressions are removed; the line is flagged when (a) letters remain between a `>` and a `<`
|
|
15
|
+
// on the same line, or (b) the line is bare prose (letters/spaces/simple
|
|
16
|
+
// punctuation, not a JS keyword) and the previous non-blank line ends with `>`
|
|
17
|
+
// but not `=>`. Block comments are not masked. The shipped design system must
|
|
18
|
+
// lint clean (regression-tested in scripts/tests/lint-skins.test.mjs).
|
|
19
|
+
//
|
|
20
|
+
// Opt-out, per offending line: trailing `// skin-lint-allow: <rule>[, <rule>...]`.
|
|
21
|
+
// Exit codes: 0 clean · 1 findings · 2 usage error (explicit dir missing).
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/;
|
|
27
|
+
const COLOR_FN = /\b(?:rgb|rgba|hsl|hsla)\s*\(/;
|
|
28
|
+
const ARBITRARY_COLOR = /-\[#/;
|
|
29
|
+
const FORBIDDEN_APIS = [
|
|
30
|
+
[/\bfetch\s*\(/, "fetch("],
|
|
31
|
+
[/\bDate\.now\b/, "Date.now"],
|
|
32
|
+
[/\bMath\.random\b/, "Math.random"],
|
|
33
|
+
[/\bnew\s+Date\s*\(/, "new Date("],
|
|
34
|
+
[/toLocale/, "toLocale* (locale-ambient formatting)"],
|
|
35
|
+
];
|
|
36
|
+
const SERVER_IMPORT = /@minlang\/runtime-web\/server/;
|
|
37
|
+
const ADAPTER_PATH = /["'][^"']*\/adapters\/[^"']*["']/;
|
|
38
|
+
const JS_KEYWORDS = new Set([
|
|
39
|
+
"return", "break", "continue", "else", "try", "finally", "default", "do",
|
|
40
|
+
"case", "new", "typeof", "void", "delete", "await", "yield", "async",
|
|
41
|
+
"export", "import", "const", "let", "var", "function", "class", "if",
|
|
42
|
+
"for", "while", "switch", "throw", "true", "false", "null", "undefined",
|
|
43
|
+
]);
|
|
44
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "generated", "coverage"]);
|
|
45
|
+
|
|
46
|
+
/** Blank string-literal contents, preserving length and the quote chars. */
|
|
47
|
+
const blankStrings = (line) =>
|
|
48
|
+
line.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, (m) =>
|
|
49
|
+
m[0] + " ".repeat(m.length - 2) + m[0],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** Remove {…} JSX expressions and blank arrow/comparison operators. */
|
|
53
|
+
const dropExpressions = (text) => {
|
|
54
|
+
let t = text.replace(/=>|<=|>=/g, " ").replace(/ [<>] /g, " ");
|
|
55
|
+
for (let prev = ""; prev !== t; ) {
|
|
56
|
+
prev = t;
|
|
57
|
+
t = t.replace(/\{[^{}]*\}/g, "");
|
|
58
|
+
}
|
|
59
|
+
return t;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const BARE_PROSE = /^[A-Za-z][A-Za-z0-9 ,.'!?-]*$/;
|
|
63
|
+
|
|
64
|
+
/** Lint one file; returns findings [{line, rule, message}]. */
|
|
65
|
+
const lintFile = (path) => {
|
|
66
|
+
const findings = [];
|
|
67
|
+
const isTsx = path.endsWith(".tsx");
|
|
68
|
+
let prevCode = "";
|
|
69
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
70
|
+
lines.forEach((raw, index) => {
|
|
71
|
+
const blanked = blankStrings(raw);
|
|
72
|
+
const commentIdx = blanked.indexOf("//");
|
|
73
|
+
const allowed = new Set();
|
|
74
|
+
let code = raw;
|
|
75
|
+
let codeBlanked = blanked;
|
|
76
|
+
if (commentIdx !== -1) {
|
|
77
|
+
const match = raw.slice(commentIdx).match(/skin-lint-allow:\s*([a-z, -]+)/);
|
|
78
|
+
for (const rule of match === null ? [] : match[1].split(",")) {
|
|
79
|
+
allowed.add(rule.trim());
|
|
80
|
+
}
|
|
81
|
+
code = raw.slice(0, commentIdx);
|
|
82
|
+
codeBlanked = blanked.slice(0, commentIdx);
|
|
83
|
+
}
|
|
84
|
+
const report = (rule, message) => {
|
|
85
|
+
if (!allowed.has(rule)) {
|
|
86
|
+
findings.push({ line: index + 1, rule, message });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
checkColors(code, report);
|
|
90
|
+
checkForbiddenApis(codeBlanked, report);
|
|
91
|
+
checkAdapterImports(code, report);
|
|
92
|
+
if (isTsx) {
|
|
93
|
+
checkJsxText(codeBlanked, prevCode, report);
|
|
94
|
+
}
|
|
95
|
+
if (code.trim() !== "") {
|
|
96
|
+
prevCode = code;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return findings;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const checkColors = (code, report) => {
|
|
103
|
+
if (HEX_COLOR.test(code)) {
|
|
104
|
+
report("color-literal", "hex color literal — style via design tokens / token classes");
|
|
105
|
+
}
|
|
106
|
+
if (COLOR_FN.test(code)) {
|
|
107
|
+
report("color-literal", "rgb()/hsl() color literal — style via design tokens");
|
|
108
|
+
}
|
|
109
|
+
if (ARBITRARY_COLOR.test(code)) {
|
|
110
|
+
report("color-literal", "Tailwind arbitrary color class — use token classes");
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const checkForbiddenApis = (codeBlanked, report) => {
|
|
115
|
+
for (const [pattern, name] of FORBIDDEN_APIS) {
|
|
116
|
+
if (pattern.test(codeBlanked)) {
|
|
117
|
+
report("forbidden-api", `${name} is forbidden in skins (SHELL_CONTRACT nondeterminism/IO)`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const checkAdapterImports = (code, report) => {
|
|
123
|
+
if (SERVER_IMPORT.test(code)) {
|
|
124
|
+
report("adapter-import", "@minlang/runtime-web/server import — skins never touch the adapter");
|
|
125
|
+
}
|
|
126
|
+
if (ADAPTER_PATH.test(code)) {
|
|
127
|
+
report("adapter-import", "adapter/store import — skins read only the schema node and deps");
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const checkJsxText = (codeBlanked, prevCode, report) => {
|
|
132
|
+
const masked = dropExpressions(codeBlanked);
|
|
133
|
+
const between = masked.match(/>([^<>]*[A-Za-z][^<>]*)</);
|
|
134
|
+
if (between !== null) {
|
|
135
|
+
report("jsx-text", `hardcoded JSX text "${between[1].trim()}" — copy must come from the schema node`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const bare = masked.trim();
|
|
139
|
+
const prev = prevCode.trim();
|
|
140
|
+
if (
|
|
141
|
+
BARE_PROSE.test(bare) &&
|
|
142
|
+
!JS_KEYWORDS.has(bare) &&
|
|
143
|
+
prev.endsWith(">") &&
|
|
144
|
+
!prev.endsWith("=>")
|
|
145
|
+
) {
|
|
146
|
+
report("jsx-text", `hardcoded JSX text "${bare}" — copy must come from the schema node`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const collectFiles = (dir, out) => {
|
|
151
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
152
|
+
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const path = join(dir, entry);
|
|
156
|
+
if (statSync(path).isDirectory()) {
|
|
157
|
+
collectFiles(path, out);
|
|
158
|
+
} else if (/\.tsx?$/.test(entry)) {
|
|
159
|
+
out.push(path);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const main = () => {
|
|
166
|
+
const args = process.argv.slice(2);
|
|
167
|
+
let dirs = args;
|
|
168
|
+
if (args.length === 0) {
|
|
169
|
+
if (!existsSync("app/skins")) {
|
|
170
|
+
console.log("lint-skins: no app/skins directory — nothing to lint");
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
dirs = ["app/skins"];
|
|
174
|
+
}
|
|
175
|
+
for (const dir of dirs) {
|
|
176
|
+
if (!existsSync(dir)) {
|
|
177
|
+
console.error(`lint-skins: no such directory: ${dir}`);
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const files = dirs.flatMap((dir) => collectFiles(dir, []));
|
|
182
|
+
let total = 0;
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
for (const finding of lintFile(file)) {
|
|
185
|
+
total += 1;
|
|
186
|
+
console.error(`${file}:${finding.line}: [${finding.rule}] ${finding.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (total > 0) {
|
|
190
|
+
console.error(`lint-skins: ${total} finding(s) in ${files.length} file(s)`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
console.log(`lint-skins: ${files.length} file(s) clean`);
|
|
194
|
+
return 0;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
process.exit(main());
|