mikoshi-construct 0.1.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/cli.js +1492 -0
  4. package/package.json +73 -0
  5. package/templates/ai/claude/CLAUDE.md.eta +18 -0
  6. package/templates/ai/claude/CLAUDE.md.existing.eta +15 -0
  7. package/templates/ai/claude/_claude/agents/architect.md +38 -0
  8. package/templates/ai/claude/_claude/agents/harness.md +40 -0
  9. package/templates/ai/claude/_claude/agents/implementer.md +40 -0
  10. package/templates/ai/claude/_claude/commands/plan.md +24 -0
  11. package/templates/ai/claude/_claude/skills/implement/SKILL.md +42 -0
  12. package/templates/ai/claude/scripts/construct/implement.workflow.mjs +185 -0
  13. package/templates/ai/cursor/_cursor/rules/construct.mdc +19 -0
  14. package/templates/ai/review/_github/workflows/claude-review.yml.eta +51 -0
  15. package/templates/ai/shared/AGENTS.md.eta +123 -0
  16. package/templates/ai/shared/AGENTS.md.existing.eta +50 -0
  17. package/templates/ai/shared/_claude/commands/construct-discover.md +75 -0
  18. package/templates/ai/shared/_claude/rules/conventions.md +63 -0
  19. package/templates/ai/shared/_claude/rules/secrets.md +8 -0
  20. package/templates/ai/shared/_claude/rules/tests.md +6 -0
  21. package/templates/base/_editorconfig +12 -0
  22. package/templates/base/_github/workflows/security.yml +41 -0
  23. package/templates/base/_gitignore +11 -0
  24. package/templates/base/_gitleaks.toml +2 -0
  25. package/templates/base/_nvmrc.eta +1 -0
  26. package/templates/base/_vscode/settings.json +22 -0
  27. package/templates/base/architecture/checklists.md +42 -0
  28. package/templates/base/architecture/principles.md +113 -0
  29. package/templates/base/architecture/security-invariants.md +17 -0
  30. package/templates/harness/_github/workflows/ci.yml.eta +31 -0
  31. package/templates/harness/eslint.config.mjs +13 -0
  32. package/templates/harness/package.json.eta +29 -0
  33. package/templates/harness/pnpm-workspace.yaml +11 -0
  34. package/templates/harness/scripts/composition/check.ts +19 -0
  35. package/templates/harness/scripts/composition/files.ts +34 -0
  36. package/templates/harness/scripts/composition/model.ts +137 -0
  37. package/templates/harness/scripts/composition/render.ts +72 -0
  38. package/templates/harness/scripts/composition/sync-docs.ts +11 -0
  39. package/templates/harness/scripts/tests/composition/files.test.ts +16 -0
  40. package/templates/harness/scripts/tests/composition/model.test.ts +41 -0
  41. package/templates/harness/scripts/tests/composition/render.test.ts +54 -0
  42. package/templates/harness/tsconfig.base.json +14 -0
  43. package/templates/harness/tsconfig.json +5 -0
  44. package/templates/harness/vitest.config.ts +8 -0
  45. package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +100 -0
  46. package/templates/presets/monorepo/baseline/package.json.eta +18 -0
  47. package/templates/presets/monorepo/baseline/packages/shared/package.json.eta +22 -0
  48. package/templates/presets/monorepo/baseline/packages/shared/src/api/openapi.ts +81 -0
  49. package/templates/presets/monorepo/baseline/packages/shared/src/index.ts +1 -0
  50. package/templates/presets/monorepo/baseline/packages/shared/tsconfig.build.json +10 -0
  51. package/templates/presets/monorepo/baseline/packages/shared/tsconfig.json +4 -0
  52. package/templates/presets/monorepo/baseline/pnpm-workspace.yaml.eta +31 -0
  53. package/templates/presets/monorepo/baseline/tsconfig.json +12 -0
  54. package/templates/presets/monorepo/baseline/vitest.config.ts +8 -0
  55. package/templates/presets/monorepo/sample/apps/api/package.json.eta +23 -0
  56. package/templates/presets/monorepo/sample/apps/api/tsconfig.build.json +10 -0
  57. package/templates/presets/monorepo/sample/apps/api/tsconfig.json +4 -0
  58. package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +36 -0
  59. package/templates/presets/node-backend/baseline/eslint.config.mjs +59 -0
  60. package/templates/presets/node-backend/baseline/package.json.eta +20 -0
  61. package/templates/presets/node-backend/baseline/src/contracts/openapi.ts +81 -0
  62. package/templates/presets/node-backend/baseline/tsconfig.build.json +10 -0
  63. package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +31 -0
  64. package/templates/presets/node-frontend/baseline/_claude/rules/css.md +158 -0
  65. package/templates/presets/node-frontend/baseline/eslint.config.mjs +44 -0
  66. package/templates/presets/node-frontend/baseline/package.json.eta +15 -0
  67. package/templates/presets/node-frontend/baseline/tsconfig.json +11 -0
  68. package/templates/presets/node-frontend/baseline/vitest.config.ts +10 -0
  69. package/templates/presets/node-frontend/sample/architecture/app.md +37 -0
  70. package/templates/presets/node-frontend/sample/architecture/composition/app.yaml +41 -0
  71. package/templates/presets/node-frontend/sample/index.html.eta +12 -0
  72. package/templates/presets/node-frontend/sample/src/app.ts +32 -0
  73. package/templates/presets/node-frontend/sample/src/main.ts +9 -0
  74. package/templates/presets/node-frontend/sample/src/styles/app.css +43 -0
  75. package/templates/presets/node-frontend/sample/src/styles/tokens.css +16 -0
  76. package/templates/presets/node-frontend/sample/tests/app.test.ts +21 -0
  77. package/templates/stacks/express-api/app/src/app.ts +27 -0
  78. package/templates/stacks/express-api/app/src/config.ts.eta +30 -0
  79. package/templates/stacks/express-api/app/src/contracts/types.ts.eta +1 -0
  80. package/templates/stacks/express-api/app/src/health/health.controller.ts +12 -0
  81. package/templates/stacks/express-api/app/src/health/health.service.ts +23 -0
  82. package/templates/stacks/express-api/app/src/http/api-contract.middleware.ts +13 -0
  83. package/templates/stacks/express-api/app/src/http/app-error.ts +14 -0
  84. package/templates/stacks/express-api/app/src/http/async-handler.middleware.ts +7 -0
  85. package/templates/stacks/express-api/app/src/http/error-handler.middleware.ts +50 -0
  86. package/templates/stacks/express-api/app/src/server.ts +9 -0
  87. package/templates/stacks/express-api/app/tests/health.test.ts +28 -0
  88. package/templates/stacks/express-api/app/tests/http/error-handler.middleware.test.ts +48 -0
  89. package/templates/stacks/express-api/repo/architecture/composition/http.yaml.eta +45 -0
  90. package/templates/stacks/express-api/repo/architecture/http.md +39 -0
  91. package/templates/stacks/http-contract/_github/workflows/api-contract.yml +36 -0
  92. package/templates/stacks/http-contract/contracts/api/openapi.yaml.eta +62 -0
  93. package/templates/stacks/http-contract/package.json.eta +13 -0
  94. package/templates/stacks/http-contract/redocly.yaml.eta +8 -0
  95. package/templates/stacks/http-contract/scripts/contracts/types.mjs.eta +20 -0
  96. package/templates/stacks/http-contract/scripts/tests/contracts/security.test.ts.eta +86 -0
@@ -0,0 +1,81 @@
1
+ export interface paths {
2
+ "/health": {
3
+ parameters: {
4
+ query?: never;
5
+ header?: never;
6
+ path?: never;
7
+ cookie?: never;
8
+ };
9
+ /** Liveness */
10
+ get: operations["getHealth"];
11
+ put?: never;
12
+ post?: never;
13
+ delete?: never;
14
+ options?: never;
15
+ head?: never;
16
+ patch?: never;
17
+ trace?: never;
18
+ };
19
+ }
20
+ export type webhooks = Record<string, never>;
21
+ export interface components {
22
+ schemas: {
23
+ HealthResponse: {
24
+ /** @enum {string} */
25
+ status: "ok" | "error";
26
+ service: string;
27
+ /** Format: date-time */
28
+ time: string;
29
+ };
30
+ ErrorResponse: {
31
+ error: string;
32
+ message: string;
33
+ };
34
+ };
35
+ responses: never;
36
+ parameters: never;
37
+ requestBodies: never;
38
+ headers: never;
39
+ pathItems: never;
40
+ }
41
+ export type $defs = Record<string, never>;
42
+ export interface operations {
43
+ getHealth: {
44
+ parameters: {
45
+ query?: never;
46
+ header?: never;
47
+ path?: never;
48
+ cookie?: never;
49
+ };
50
+ requestBody?: never;
51
+ responses: {
52
+ /** @description The service is up. */
53
+ 200: {
54
+ headers: {
55
+ [name: string]: unknown;
56
+ };
57
+ content: {
58
+ "application/json": components["schemas"]["HealthResponse"];
59
+ };
60
+ };
61
+ /** @description A dependency did not answer. */
62
+ 503: {
63
+ headers: {
64
+ [name: string]: unknown;
65
+ };
66
+ content: {
67
+ "application/json": components["schemas"]["HealthResponse"];
68
+ };
69
+ };
70
+ /** @description Unexpected error. */
71
+ default: {
72
+ headers: {
73
+ [name: string]: unknown;
74
+ };
75
+ content: {
76
+ "application/json": components["schemas"]["ErrorResponse"];
77
+ };
78
+ };
79
+ };
80
+ };
81
+ }
@@ -0,0 +1 @@
1
+ export type * from './api/openapi.js'
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "declaration": true,
6
+ "noEmit": false,
7
+ "outDir": "dist"
8
+ },
9
+ "include": ["src/**/*.ts"]
10
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
4
+ }
@@ -0,0 +1,31 @@
1
+ minimumReleaseAgeExcludePrune: true
2
+
3
+ trustPolicy: no-downgrade
4
+ trustPolicyExclude:
5
+ - chokidar@4.0.3
6
+ - undici-types@6.21.0
7
+
8
+ shellEmulator: true
9
+
10
+ packages:
11
+ - apps/*
12
+ - packages/*
13
+
14
+ allowBuilds:
15
+ esbuild: true
16
+
17
+ catalog:
18
+ '@antfu/eslint-config': ^9.5.1
19
+ '@redocly/cli': ^2.52.0
20
+ '@types/express': ^4.17.25
21
+ '@types/node': ^{{nodeMajor}}.0.0
22
+ '@types/supertest': ^7.2.1
23
+ eslint: ^10.10.0
24
+ express: ^4.21.2
25
+ express-openapi-validator: ^5.6.2
26
+ openapi-typescript: ^7.13.0
27
+ supertest: ^7.2.2
28
+ tsx: ^4.23.13
29
+ typescript: ^5.9.3
30
+ vitest: ^5.0.0
31
+ yaml: ^2.9.1
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "include": [
4
+ "apps/*/src/**/*.ts",
5
+ "apps/*/tests/**/*.ts",
6
+ "packages/*/src/**/*.ts",
7
+ "packages/*/tests/**/*.ts",
8
+ "scripts/**/*.ts",
9
+ "vitest.config.ts"
10
+ ],
11
+ "exclude": ["**/node_modules/**", "**/dist/**"]
12
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['apps/*/tests/**/*.test.ts', 'packages/*/tests/**/*.test.ts', 'scripts/tests/**/*.test.ts'],
6
+ environment: 'node',
7
+ },
8
+ })
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "{{scope}}/api",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "tsx --watch src/server.ts",
8
+ "start": "node dist/server.js",
9
+ "build": "tsc -p tsconfig.build.json"
10
+ },
11
+ "dependencies": {
12
+ "{{scope}}/shared": "workspace:*",
13
+ "express": "catalog:",
14
+ "express-openapi-validator": "catalog:"
15
+ },
16
+ "devDependencies": {
17
+ "@types/express": "catalog:",
18
+ "@types/supertest": "catalog:",
19
+ "supertest": "catalog:",
20
+ "tsx": "catalog:",
21
+ "typescript": "catalog:"
22
+ }
23
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "declaration": false,
6
+ "noEmit": false,
7
+ "outDir": "dist"
8
+ },
9
+ "include": ["src/**/*.ts"]
10
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
4
+ }
@@ -0,0 +1,36 @@
1
+ import { ESLint } from 'eslint'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { REPO_ROOT } from '../../composition/files.js'
4
+
5
+ const PROCESS = 'MemberExpression[object.name="process"]'
6
+ const PROCESS_ENV = 'MemberExpression[object.name="process"][property.name="env"]'
7
+ const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
8
+ const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
9
+ const SHARED_IMPORT = ':matches(ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration)[source.value=/^{{scope}}\\/shared(\\/|$)/]'
10
+
11
+ const eslint = new ESLint({ cwd: REPO_ROOT })
12
+
13
+ async function restrictedSelectors(file: string): Promise<string[]> {
14
+ const config = await eslint.calculateConfigForFile(file)
15
+ const [, ...restrictions] = config.rules['no-restricted-syntax'] as [unknown, ...Array<{ selector: string }>]
16
+ return restrictions.map(restriction => restriction.selector).sort()
17
+ }
18
+
19
+ const ROLES: Array<{ role: string, file: string, selectors: string[] }> = [
20
+ { role: 'a service', file: 'apps/api/src/things/things.service.ts', selectors: [PROCESS, RAW_REQUEST_DATA, RAW_SQL, SHARED_IMPORT] },
21
+ { role: 'a controller', file: 'apps/api/src/things/things.controller.ts', selectors: [PROCESS, RAW_SQL, SHARED_IMPORT] },
22
+ { role: 'a middleware', file: 'apps/api/src/http/error-handler.middleware.ts', selectors: [PROCESS, RAW_SQL, SHARED_IMPORT] },
23
+ { role: 'the contract types gateway', file: 'apps/api/src/contracts/types.ts', selectors: [PROCESS, RAW_REQUEST_DATA, RAW_SQL] },
24
+ { role: 'the app config', file: 'apps/api/src/config.ts', selectors: [RAW_SQL, SHARED_IMPORT] },
25
+ { role: 'the process entry', file: 'apps/api/src/server.ts', selectors: [RAW_SQL, SHARED_IMPORT] },
26
+ { role: 'a package module', file: 'packages/shared/src/index.ts', selectors: [PROCESS_ENV, RAW_SQL] },
27
+ { role: 'a package config', file: 'packages/shared/src/shared.config.ts', selectors: [RAW_SQL] },
28
+ ]
29
+
30
+ describe('syntax policy by file role', () => {
31
+ for (const { role, file, selectors } of ROLES) {
32
+ it(`keeps every restriction that applies to ${role}`, async () => {
33
+ expect(await restrictedSelectors(file)).toEqual([...selectors].sort())
34
+ })
35
+ }
36
+ })
@@ -0,0 +1,59 @@
1
+ import antfu from '@antfu/eslint-config'
2
+
3
+ const PROCESS_MEMBER = 'MemberExpression[object.name="process"]'
4
+ const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
5
+ const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
6
+
7
+ const NO_PROCESS_OUTSIDE_CONFIG = {
8
+ selector: PROCESS_MEMBER,
9
+ message: 'src touches process only in config.ts: read configuration through readConfig() and pass the value on',
10
+ }
11
+ const NO_RAW_REQUEST_DATA = {
12
+ selector: RAW_REQUEST_DATA,
13
+ message: 'External input is validated at the HTTP boundary: read req.body, req.query and req.params in a controller or middleware and pass values on',
14
+ }
15
+ const NO_RAW_SQL = {
16
+ selector: RAW_SQL,
17
+ message: 'SQL is never built from raw strings: interpolate tables and columns into the sql template instead of sql.raw',
18
+ }
19
+
20
+ function restrictSyntax(files, restrictions) {
21
+ return { files, rules: { 'no-restricted-syntax': ['error', ...restrictions] } }
22
+ }
23
+
24
+ const ENVIRONMENT_READERS = ['src/config.ts', 'src/server.ts', 'src/scripts/**']
25
+ const HTTP_BOUNDARY = ['src/**/*.controller.ts', 'src/**/*.middleware.ts', 'src/http/**']
26
+
27
+ const dependencyPolicy = [
28
+ restrictSyntax(['src/**'], [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG, NO_RAW_REQUEST_DATA]),
29
+ restrictSyntax(HTTP_BOUNDARY, [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG]),
30
+ restrictSyntax(ENVIRONMENT_READERS, [NO_RAW_SQL]),
31
+ ]
32
+
33
+ export default antfu(
34
+ {
35
+ isInEditor: false,
36
+ typescript: {
37
+ tsconfigPath: './tsconfig.json',
38
+ },
39
+ },
40
+ {
41
+ ignores: ['**/dist/**', '**/node_modules/**', 'pnpm-lock.yaml', 'scripts/construct/*.workflow.mjs', 'src/contracts/openapi.ts'],
42
+ },
43
+ ...dependencyPolicy,
44
+ {
45
+ files: ['src/server.ts', 'src/scripts/**'],
46
+ rules: {
47
+ 'no-console': 'off',
48
+ },
49
+ },
50
+ {
51
+ files: ['**/*.test.ts'],
52
+ rules: {
53
+ 'ts/no-unsafe-assignment': 'off',
54
+ 'ts/no-unsafe-call': 'off',
55
+ 'ts/no-unsafe-member-access': 'off',
56
+ 'ts/no-floating-promises': 'off',
57
+ },
58
+ },
59
+ )
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "tsx --watch src/server.ts",
8
+ "start": "node dist/server.js",
9
+ "build": "tsc -p tsconfig.build.json"
10
+ },
11
+ "dependencies": {
12
+ "express": "^4.21.2",
13
+ "express-openapi-validator": "^5.6.2"
14
+ },
15
+ "devDependencies": {
16
+ "@types/express": "^4.17.25",
17
+ "@types/supertest": "^7.2.1",
18
+ "supertest": "^7.2.2"
19
+ }
20
+ }
@@ -0,0 +1,81 @@
1
+ export interface paths {
2
+ "/health": {
3
+ parameters: {
4
+ query?: never;
5
+ header?: never;
6
+ path?: never;
7
+ cookie?: never;
8
+ };
9
+ /** Liveness */
10
+ get: operations["getHealth"];
11
+ put?: never;
12
+ post?: never;
13
+ delete?: never;
14
+ options?: never;
15
+ head?: never;
16
+ patch?: never;
17
+ trace?: never;
18
+ };
19
+ }
20
+ export type webhooks = Record<string, never>;
21
+ export interface components {
22
+ schemas: {
23
+ HealthResponse: {
24
+ /** @enum {string} */
25
+ status: "ok" | "error";
26
+ service: string;
27
+ /** Format: date-time */
28
+ time: string;
29
+ };
30
+ ErrorResponse: {
31
+ error: string;
32
+ message: string;
33
+ };
34
+ };
35
+ responses: never;
36
+ parameters: never;
37
+ requestBodies: never;
38
+ headers: never;
39
+ pathItems: never;
40
+ }
41
+ export type $defs = Record<string, never>;
42
+ export interface operations {
43
+ getHealth: {
44
+ parameters: {
45
+ query?: never;
46
+ header?: never;
47
+ path?: never;
48
+ cookie?: never;
49
+ };
50
+ requestBody?: never;
51
+ responses: {
52
+ /** @description The service is up. */
53
+ 200: {
54
+ headers: {
55
+ [name: string]: unknown;
56
+ };
57
+ content: {
58
+ "application/json": components["schemas"]["HealthResponse"];
59
+ };
60
+ };
61
+ /** @description A dependency did not answer. */
62
+ 503: {
63
+ headers: {
64
+ [name: string]: unknown;
65
+ };
66
+ content: {
67
+ "application/json": components["schemas"]["HealthResponse"];
68
+ };
69
+ };
70
+ /** @description Unexpected error. */
71
+ default: {
72
+ headers: {
73
+ [name: string]: unknown;
74
+ };
75
+ content: {
76
+ "application/json": components["schemas"]["ErrorResponse"];
77
+ };
78
+ };
79
+ };
80
+ };
81
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "declaration": false,
6
+ "noEmit": false,
7
+ "outDir": "dist"
8
+ },
9
+ "include": ["src/**/*.ts"]
10
+ }
@@ -0,0 +1,31 @@
1
+ import { ESLint } from 'eslint'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { REPO_ROOT } from '../../composition/files.js'
4
+
5
+ const PROCESS = 'MemberExpression[object.name="process"]'
6
+ const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
7
+ const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
8
+
9
+ const eslint = new ESLint({ cwd: REPO_ROOT })
10
+
11
+ async function restrictedSelectors(file: string): Promise<string[]> {
12
+ const config = await eslint.calculateConfigForFile(file)
13
+ const [, ...restrictions] = config.rules['no-restricted-syntax'] as [unknown, ...Array<{ selector: string }>]
14
+ return restrictions.map(restriction => restriction.selector).sort()
15
+ }
16
+
17
+ const ROLES: Array<{ role: string, file: string, selectors: string[] }> = [
18
+ { role: 'a service', file: 'src/things/things.service.ts', selectors: [PROCESS, RAW_REQUEST_DATA, RAW_SQL] },
19
+ { role: 'a controller', file: 'src/things/things.controller.ts', selectors: [PROCESS, RAW_SQL] },
20
+ { role: 'a middleware', file: 'src/http/error-handler.middleware.ts', selectors: [PROCESS, RAW_SQL] },
21
+ { role: 'the app config', file: 'src/config.ts', selectors: [RAW_SQL] },
22
+ { role: 'the process entry', file: 'src/server.ts', selectors: [RAW_SQL] },
23
+ ]
24
+
25
+ describe('syntax policy by file role', () => {
26
+ for (const { role, file, selectors } of ROLES) {
27
+ it(`keeps every restriction that applies to ${role}`, async () => {
28
+ expect(await restrictedSelectors(file)).toEqual([...selectors].sort())
29
+ })
30
+ }
31
+ })
@@ -0,0 +1,158 @@
1
+ ---
2
+ paths:
3
+ - "**/*.css"
4
+ - "**/*.vue"
5
+ - "**/*.astro"
6
+ ---
7
+
8
+ # CSS
9
+
10
+ Modern, platform-native CSS. No preprocessor — nesting, custom properties, colour functions and
11
+ container queries are all in the language now.
12
+
13
+ ## State as attributes, variants as custom properties
14
+
15
+ - **Everything that crosses from JS into CSS crosses as an attribute.** Templates and scripts toggle
16
+ `data-*`, or an ARIA attribute where one already carries the meaning (`aria-expanded`,
17
+ `aria-pressed`, `aria-checked`, `[hidden]`). JS does not add or remove styling classes and does not
18
+ write inline styles for state.
19
+ - **An attribute selector re-binds custom properties; it does not restate declarations.** The block
20
+ declares its private `--_*` properties and consumes them once; each variant only changes the values.
21
+ - Classes name *things* — a component, a layout primitive, a utility — and stay where they are genuinely
22
+ useful as hooks. They never encode state or variant: no `--modifier` classes, no new `.is-*`.
23
+
24
+ ```css
25
+ /* ✅ */
26
+ .pill {
27
+ --_bg: var(--color-surface-container-high);
28
+ --_fg: var(--color-on-surface);
29
+ --_bd: transparent;
30
+
31
+ background: var(--_bg);
32
+ color: var(--_fg);
33
+ border: 1px solid var(--_bd);
34
+
35
+ &[data-tone='info'] { --_bg: var(--color-info-bg); --_fg: var(--color-info); }
36
+ &[data-outlined] { --_bg: transparent; --_bd: var(--color-primary); }
37
+ }
38
+
39
+ /* ❌ modifier classes toggled from JS, declarations restated per variant */
40
+ .pill--info { background: …; color: …; border: …; }
41
+ ```
42
+
43
+ ## Nesting
44
+
45
+ Native CSS nesting with `&`. Nest to express relationship — variants, states, parts — not to mirror
46
+ the DOM tree depth-for-depth.
47
+
48
+ ## Container queries over media queries
49
+
50
+ - `@media` is for **preferences and devices**: `prefers-reduced-motion`, `pointer: coarse`, print. A
51
+ width-based `@media` is an anti-pattern — a component should react to the space it is in, not to the
52
+ viewport.
53
+ - Use `@container` with range syntax: `@container (inline-size <= 48rem) { … }`. Keep the breakpoints
54
+ as rem tokens (`--bp-cq-md: 48rem`). Name a container (`container: split-card / inline-size`) once
55
+ more than one rule targets it.
56
+ - Use **style container queries** when a parent decides a child's layout mode: the host sets
57
+ `container-type: inline-size` and stamps a custom property; descendants read it.
58
+
59
+ ```css
60
+ .u-section { container-type: inline-size; --cq-size: lg; }
61
+ @container (inline-size <= 48rem) { .u-section > * { --cq-size: sm; } }
62
+ @container style(--cq-size: lg) { .bento-grid { grid-template-columns: repeat(3, 1fr); } }
63
+ ```
64
+
65
+ - Theming is `color-scheme: light dark` plus `light-dark()`, not `@media (prefers-color-scheme)`.
66
+
67
+ ## Logical properties
68
+
69
+ `inline-size` / `block-size`, `min-` and `max-inline-size`, `padding-inline` / `padding-block`,
70
+ `margin-inline` / `margin-block`, `inset-block-start` / `inset-inline-start`, `border-inline`,
71
+ `text-align: start`. Physical `width` / `height` / `top` / `left` survive only where the value really is
72
+ physical: SVG geometry attributes, hairlines, `minmax()` track sizes.
73
+
74
+ ## Units — relative by default, and the right relative one
75
+
76
+ - `rem` for length and spacing; `em` for what should scale with the element's own font size (icon
77
+ `inline-size: 1em`, letter-spacing); `ch` for measure (`--body-max-inline-size: 65ch`); `lh` for
78
+ vertical rhythm (`padding: 0.25lh 1ch`); `%` / `fr` / `vi` where that *is* the meaning.
79
+ - `px` only for hairlines, shadow offsets and 1px-scale detail — never for spacing or type.
80
+ - Fluid type and space come from `clamp()` on a scale, not from size swaps at breakpoints.
81
+ - This is a default, not a purity test: pick the unit that expresses the intent.
82
+
83
+ ## Custom properties and tokens
84
+
85
+ Colour, space, radius, duration and z-index come from tokens — a raw value in a component is a missing
86
+ token. `--_name` marks a component-private property. Derive colours with relative colour syntax
87
+ (`oklch(from var(--color-primary) calc(l + 0.08) c h)`) rather than hand-picking shades.
88
+
89
+ ## Smart layouts
90
+
91
+ Layout is a small set of reusable primitives that respond to available space **intrinsically** — no
92
+ breakpoints, no per-component grid code. Compose pages from them; when none fits, add a primitive to
93
+ the shared stylesheet rather than a one-off grid to a component.
94
+
95
+ - **Page shell, named grid lines.** One grid declares `[full-start] … [popout-start] …
96
+ [content-start] … [narrow-start] …` tracks; children default to `grid-column: content` and opt out
97
+ with `.layout-full` / `.layout-popout` / `.layout-narrow`. Full-bleed comes from the shell, never
98
+ from negative margins or `100vw`.
99
+ - **Card grids, `auto-fit` / `auto-fill` + `minmax`.**
100
+ `grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr))`. The inner
101
+ `min(…, 100%)` is what stops it overflowing a narrow container — it is required, not decoration.
102
+ `auto-fit` when the row should stretch to fill, `auto-fill` when empty tracks must be kept.
103
+ - **Parameterised by custom properties and `data-*`, never by variant classes.** A caller retunes a
104
+ primitive by setting `--card-min-width` / `--column-inline-size` / `--grid-gap`, or by stamping an
105
+ attribute the CSS reads directly.
106
+
107
+ ```css
108
+ .grid-auto-fit-max-column-count {
109
+ --_max-cols: 3;
110
+ --_max-cols: attr(data-max-cols type(<number>), 3);
111
+ --_min-col: var(--column-inline-size, var(--card-min-width));
112
+ --_col-size: calc((100% - (var(--_max-cols) - 1) * var(--grid-gap)) / var(--_max-cols));
113
+
114
+ display: grid;
115
+ gap: var(--grid-gap);
116
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--_min-col), var(--_col-size))), 1fr));
117
+ }
118
+ ```
119
+
120
+ ```html
121
+ <ul class="grid-auto-fit-max-column-count" data-max-cols="4">
122
+ ```
123
+
124
+ (The plain `--_max-cols: 3` line above the typed `attr()` is the fallback for engines without typed
125
+ `attr()`; keep both.)
126
+
127
+ - **Line layouts.** One wrapping flex primitive — `display: flex; flex-wrap: wrap; gap: var(--_gap);
128
+ align-items: center` with `--_gap` overridable — before writing another one-off flex row.
129
+ - **Mode switches.** Where a layout must change *shape* rather than reflow, the host stamps `--cq-size`
130
+ and the shape lives behind `@container style(--cq-size: lg)`.
131
+ - `gap` is the spacing mechanism; margins between siblings are a smell. `subgrid` when a child must
132
+ align to an ancestor's tracks. Respect a project's `@layer` order and put a component's styles in the
133
+ layer its siblings use.
134
+
135
+ ## Animations
136
+
137
+ Keyframe animations use **dashed idents** with an `--animation-` prefix.
138
+
139
+ ```css
140
+ /* ✅ */
141
+ @keyframes --animation-charities-skeleton-pulse { /* … */ }
142
+ .el {
143
+ animation: --animation-charities-skeleton-pulse 1.4s ease-in-out infinite;
144
+ }
145
+
146
+ /* ❌ bare names */
147
+ @keyframes charities-skeleton-pulse { /* … */ }
148
+ .el {
149
+ animation: fade-in 0.3s ease;
150
+ }
151
+ ```
152
+
153
+ - Declare as `@keyframes --animation-<descriptive-name>`, reference via
154
+ `animation: --animation-<descriptive-name> …` or `animation-name: --animation-<descriptive-name>`.
155
+ - `animation: none` is fine.
156
+ - This is only about `@keyframes` / `animation-name`. Do **not** rename transition tokens
157
+ (`--transition-base` and friends).
158
+ - Applies to `.css` files and to `<style>` blocks in `.astro` / `.vue` components alike.
@@ -0,0 +1,44 @@
1
+ import antfu from '@antfu/eslint-config'
2
+
3
+ const CLASS_LIST_MUTATION = 'CallExpression[callee.object.property.name="classList"][callee.property.name=/^(add|remove|toggle|replace)$/]'
4
+ const INLINE_STYLE_WRITE = 'AssignmentExpression[left.object.property.name="style"]'
5
+ const INLINE_STYLE_PROPERTY = 'CallExpression[callee.object.property.name="style"][callee.property.name=/^(setProperty|removeProperty)$/]'
6
+
7
+ const NO_STATE_CLASSES = {
8
+ selector: CLASS_LIST_MUTATION,
9
+ message: 'State crosses into CSS as a data-* or ARIA attribute; classes name things, never state',
10
+ }
11
+ const NO_INLINE_STYLES = {
12
+ selector: `${INLINE_STYLE_WRITE}, ${INLINE_STYLE_PROPERTY}`,
13
+ message: 'Scripts do not write inline styles: set a data-* attribute and let the stylesheet re-bind custom properties',
14
+ }
15
+
16
+ function restrictSyntax(files, ignores, restrictions) {
17
+ return { files, ignores, rules: { 'no-restricted-syntax': ['error', ...restrictions] } }
18
+ }
19
+
20
+ const stylingPolicy = [
21
+ restrictSyntax(['src/**'], [], [NO_STATE_CLASSES, NO_INLINE_STYLES]),
22
+ ]
23
+
24
+ export default antfu(
25
+ {
26
+ isInEditor: false,
27
+ typescript: {
28
+ tsconfigPath: './tsconfig.json',
29
+ },
30
+ },
31
+ {
32
+ ignores: ['**/dist/**', '**/node_modules/**', 'pnpm-lock.yaml', 'scripts/construct/*.workflow.mjs'],
33
+ },
34
+ ...stylingPolicy,
35
+ {
36
+ files: ['**/*.test.ts'],
37
+ rules: {
38
+ 'ts/no-unsafe-assignment': 'off',
39
+ 'ts/no-unsafe-call': 'off',
40
+ 'ts/no-unsafe-member-access': 'off',
41
+ 'ts/no-floating-promises': 'off',
42
+ },
43
+ },
44
+ )
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "devDependencies": {
12
+ "happy-dom": "^20.13.0",
13
+ "vite": "^8.2.0"
14
+ }
15
+ }