@erclx/canon 4.55.0 → 4.57.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +7 -0
- package/claude/skills/claude-autoship/SKILL.md +17 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -4
- package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +2 -2
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-worktree/SKILL.md +9 -3
- package/claude/skills/context-draft/SKILL.md +1 -1
- package/claude/skills/docs-draft/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +22 -5
- package/claude/skills/setup-init/SKILL.md +2 -1
- package/claude/skills/wireframe-draft/SKILL.md +2 -2
- package/docs/agents/commands.md +79 -79
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +27 -1
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/src/commands/labels.ts +51 -3
- package/src/commands/tasks.ts +97 -0
- package/src/gate/measures.ts +77 -2
- package/src/gate/stages.ts +11 -0
- package/src/labels/format.ts +58 -0
- package/src/shipped/references.ts +74 -7
- package/src/tasks/answers.ts +42 -11
- package/src/tasks/branch.ts +78 -0
- package/src/web/readme-citations.ts +90 -0
- package/standards/branch.md +3 -1
- package/standards/wireframes.md +4 -2
- package/tooling/base/configs/commitlint.config.js +26 -0
- package/tooling/nextjs/configs/eslint.config.js +90 -0
- package/tooling/nextjs/configs/next.config.ts +10 -0
- package/tooling/nextjs/configs/playwright.config.ts +27 -0
- package/tooling/nextjs/configs/vitest.config.ts +28 -0
- package/tooling/nextjs/manifest.toml +19 -0
- package/tooling/nextjs/reference.md +47 -0
- package/tooling/nextjs/seeds/.cspell/tech-stack.txt +3 -0
- package/tooling/web/configs/scripts/screenshot.sh +2 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { type AnswersRefused, resolvePlanReference } from '@/tasks/answers'
|
|
3
|
+
|
|
4
|
+
const PLAN_PREFIX = 'feature-'
|
|
5
|
+
const MARKDOWN = '.md'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The type every plan-derived branch takes. It is a constant rather than a
|
|
9
|
+
* reading, because determinism is the whole property that makes the dispatch
|
|
10
|
+
* gate and the worker agree, and prose reading is the judgment that produced
|
|
11
|
+
* three strings for one plan. A wrong type is cheap because a branch type is
|
|
12
|
+
* cosmetic, since a commit's type and a pull request's title are both read off
|
|
13
|
+
* the diff. Nothing renames it later, whatever three shipped bodies used to say.
|
|
14
|
+
*/
|
|
15
|
+
export const PLAN_BRANCH_TYPE = 'feat'
|
|
16
|
+
|
|
17
|
+
/** The description cap in `standards/branch.md`, in kebab-separated words. */
|
|
18
|
+
export const DESCRIPTION_WORD_CAP = 4
|
|
19
|
+
|
|
20
|
+
/** The branch length cap in `standards/branch.md`, in characters. */
|
|
21
|
+
export const BRANCH_LENGTH_CAP = 50
|
|
22
|
+
|
|
23
|
+
export interface PlanBranch {
|
|
24
|
+
readonly ok: true
|
|
25
|
+
readonly plan: string
|
|
26
|
+
readonly type: string
|
|
27
|
+
readonly slug: string
|
|
28
|
+
readonly branch: string
|
|
29
|
+
readonly words: number
|
|
30
|
+
readonly conforms: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BranchOutcome = PlanBranch | AnswersRefused
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Takes the slug off a plan filename. The `feature-` prefix and the extension
|
|
37
|
+
* are the two things every plan filename carries and no branch name does, so
|
|
38
|
+
* both come off and whatever is left is the description.
|
|
39
|
+
*/
|
|
40
|
+
function slugOf(path: string): string {
|
|
41
|
+
const stem = basename(path, MARKDOWN)
|
|
42
|
+
|
|
43
|
+
return stem.startsWith(PLAN_PREFIX) ? stem.slice(PLAN_PREFIX.length) : stem
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Derives the branch a dispatch checks and a worker takes, from the plan both
|
|
48
|
+
* of them name. It is the one derivation, so the collision check and the
|
|
49
|
+
* worktree entry it gates cannot hold two answers for one plan.
|
|
50
|
+
*
|
|
51
|
+
* Conformance covers both caps `standards/branch.md` states, being the word
|
|
52
|
+
* count of the description and the length of the whole branch. A slug is a
|
|
53
|
+
* plan's own filename rather than a name anyone chose for a branch, so a plan
|
|
54
|
+
* can name a branch this refuses to grade as conforming, and reporting that is
|
|
55
|
+
* the point: the caller hands the row to a person rather than shipping a
|
|
56
|
+
* rename that parts the branch slug from the plan slug.
|
|
57
|
+
*/
|
|
58
|
+
export function planBranch(root: string, reference: string): BranchOutcome {
|
|
59
|
+
const resolved = resolvePlanReference(root, reference)
|
|
60
|
+
if (!resolved.ok) return resolved
|
|
61
|
+
|
|
62
|
+
const slug = slugOf(resolved.path)
|
|
63
|
+
const branch = `${PLAN_BRANCH_TYPE}/${slug}`
|
|
64
|
+
const words = slug.split('-').filter((word) => word.length > 0).length
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
plan: resolved.plan,
|
|
69
|
+
type: PLAN_BRANCH_TYPE,
|
|
70
|
+
slug,
|
|
71
|
+
branch,
|
|
72
|
+
words,
|
|
73
|
+
conforms:
|
|
74
|
+
words > 0 &&
|
|
75
|
+
words <= DESCRIPTION_WORD_CAP &&
|
|
76
|
+
branch.length <= BRANCH_LENGTH_CAP,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isMarked } from '@/exempt-marker'
|
|
2
|
+
|
|
3
|
+
export const README_PARAPHRASE_MARKER = 'canon-allow-readme-paraphrase'
|
|
4
|
+
|
|
5
|
+
export type ReadmeCitationKind = 'quoted' | 'paraphrase' | 'bare'
|
|
6
|
+
|
|
7
|
+
export interface ReadmeCitation {
|
|
8
|
+
readonly file: string
|
|
9
|
+
/** One-based, matching the `file:line` form a reader clicks. */
|
|
10
|
+
readonly line: number
|
|
11
|
+
readonly kind: ReadmeCitationKind
|
|
12
|
+
/** The citation comment as written, so a report names the line to fix. */
|
|
13
|
+
readonly text: string
|
|
14
|
+
/** Verbatim phrases to check against `README.md`, set only for `kind: 'quoted'`. */
|
|
15
|
+
readonly phrases: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ANCHOR = /README\.md:\s*(.*)$/
|
|
19
|
+
const QUOTED_PHRASE = /"([^"]+)"/g
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every `README.md:` anchor comment in one file, classified by shape.
|
|
23
|
+
*
|
|
24
|
+
* `quoted` carries one or more verbatim phrases a caller checks against the
|
|
25
|
+
* current `README.md` text, which is what replaces a line number that drifts
|
|
26
|
+
* silently the moment the cited line moves. A quote is checked whether or not
|
|
27
|
+
* the line also carries `README_PARAPHRASE_MARKER`, since a marker documents
|
|
28
|
+
* that part of a string is synthesized and asserts nothing about a phrase the
|
|
29
|
+
* same line puts in quotes: quoting a borrow verbatim and then never checking
|
|
30
|
+
* it would let the exact drift this file exists to catch survive inside its
|
|
31
|
+
* own escape hatch. `paraphrase` is what a marked line falls to only once it
|
|
32
|
+
* carries no quote of its own, muted by `README_PARAPHRASE_MARKER` the way
|
|
33
|
+
* `isMarked` mutes every other exemption in this repository. `bare` is the
|
|
34
|
+
* retired `README.md:<n>` form, reported rather than accepted so the fragile
|
|
35
|
+
* convention this replaces cannot come back on a later edit.
|
|
36
|
+
*
|
|
37
|
+
* Modeled on `clientCommandCitationsIn` in `src/client-commands.ts`, including
|
|
38
|
+
* its use of `isMarked` for the exemption.
|
|
39
|
+
*/
|
|
40
|
+
export function readmeCitationsIn(
|
|
41
|
+
file: string,
|
|
42
|
+
text: string,
|
|
43
|
+
): ReadmeCitation[] {
|
|
44
|
+
const lines = text.split('\n')
|
|
45
|
+
const citations: ReadmeCitation[] = []
|
|
46
|
+
|
|
47
|
+
for (const [index, line] of lines.entries()) {
|
|
48
|
+
const match = ANCHOR.exec(line)
|
|
49
|
+
if (match === null) continue
|
|
50
|
+
|
|
51
|
+
const rest = (match[1] ?? '').trim()
|
|
52
|
+
|
|
53
|
+
const phrases = [...rest.matchAll(QUOTED_PHRASE)].map(
|
|
54
|
+
(found) => found[1] ?? '',
|
|
55
|
+
)
|
|
56
|
+
if (phrases.length > 0) {
|
|
57
|
+
citations.push({
|
|
58
|
+
file,
|
|
59
|
+
line: index + 1,
|
|
60
|
+
kind: 'quoted',
|
|
61
|
+
text: line.trim(),
|
|
62
|
+
phrases,
|
|
63
|
+
})
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isMarked(lines, index, README_PARAPHRASE_MARKER)) {
|
|
68
|
+
citations.push({
|
|
69
|
+
file,
|
|
70
|
+
line: index + 1,
|
|
71
|
+
kind: 'paraphrase',
|
|
72
|
+
text: line.trim(),
|
|
73
|
+
phrases: [],
|
|
74
|
+
})
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (/^\d/.test(rest)) {
|
|
79
|
+
citations.push({
|
|
80
|
+
file,
|
|
81
|
+
line: index + 1,
|
|
82
|
+
kind: 'bare',
|
|
83
|
+
text: line.trim(),
|
|
84
|
+
phrases: [],
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return citations
|
|
90
|
+
}
|
package/standards/branch.md
CHANGED
|
@@ -21,11 +21,13 @@ Does not govern:
|
|
|
21
21
|
- Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
|
|
22
22
|
- Length: 50 characters maximum
|
|
23
23
|
- Casing: kebab-case only, no underscores or camelCase
|
|
24
|
-
- Description: 2 words maximum,
|
|
24
|
+
- Description: 2 words maximum, up to 4 only when genuinely needed for specificity
|
|
25
25
|
- Capture the core change, not the commit message verbatim
|
|
26
26
|
- For branches with multiple commits, use the unifying concern as the description.
|
|
27
27
|
- Do not duplicate type in description (e.g., `feat/feature-login`)
|
|
28
28
|
|
|
29
|
+
The upper bound reads 4 rather than 3 for every branch, whoever named it. It was widened on 2026-09-06 so that a branch taking its description from a planning document's own filename stops being renamed at ship, since a rename there is a third derivation and parts the branch from the filename that later tooling reads back to find the document. Nothing can tell such a name from any other, so the wider bound holds for all of them and 2 words stays the target.
|
|
30
|
+
|
|
29
31
|
## Types
|
|
30
32
|
|
|
31
33
|
- `feat`: new feature or capability
|
package/standards/wireframes.md
CHANGED
|
@@ -26,7 +26,7 @@ A wireframe works when someone can rebuild the surface from it without opening t
|
|
|
26
26
|
|
|
27
27
|
- What is on screen, and where does it sit relative to everything else?
|
|
28
28
|
- Which states can a visitor reach, and what does each one look like?
|
|
29
|
-
- What does
|
|
29
|
+
- What does its structural copy say, word for word, and where does its long-form content come from?
|
|
30
30
|
|
|
31
31
|
A wireframe that fails these is non-conforming regardless of whether it satisfies every section rule below. The fences are the means. These three questions are the test.
|
|
32
32
|
|
|
@@ -60,7 +60,8 @@ Both fields feed `.claude/wireframes/index.md` when regenerated.
|
|
|
60
60
|
|
|
61
61
|
## Copy
|
|
62
62
|
|
|
63
|
-
- Carry
|
|
63
|
+
- Carry short structural text verbatim, in the ASCII block or a short list below it: labels, headings, empty-state strings, and nav or footer copy. The wireframe is the source of truth for this text.
|
|
64
|
+
- Cite the source file for long-form or article-body content instead of duplicating it. A lede sentence or a body paragraph pulled into an ASCII figure drifts the moment its source changes, and nothing checks a fenced block against prose elsewhere. A citation elsewhere in the file does not excuse the block itself from still carrying the duplicate. Trim the block once the citation exists.
|
|
64
65
|
- Mark copy that is dynamic or templated so a reader does not treat a placeholder as final text.
|
|
65
66
|
|
|
66
67
|
## Behavior
|
|
@@ -113,6 +114,7 @@ description: <when and where the surface appears>
|
|
|
113
114
|
|
|
114
115
|
- <on-screen text, word for word>
|
|
115
116
|
- <text the surface templates>: <marked so a reader does not read it as final>
|
|
117
|
+
- <long-form content>: cited at <its source file>, not duplicated here
|
|
116
118
|
|
|
117
119
|
## Behavior
|
|
118
120
|
|
|
@@ -1,7 +1,33 @@
|
|
|
1
|
+
const SUBJECT_STARTS_WITH_LETTER = /^[A-Za-z]/
|
|
2
|
+
const SUBJECT_LEADING_LETTERS = /^[A-Za-z]+/
|
|
3
|
+
|
|
4
|
+
// Checks the first word alone, unlike the built-in subject-case rule, which
|
|
5
|
+
// tests the whole subject and would reject a legitimate capitalized proper
|
|
6
|
+
// noun anywhere past the first word.
|
|
7
|
+
const subjectFirstWordCase = (parsed) => {
|
|
8
|
+
const { subject } = parsed
|
|
9
|
+
|
|
10
|
+
if (
|
|
11
|
+
typeof subject !== 'string' ||
|
|
12
|
+
!SUBJECT_STARTS_WITH_LETTER.test(subject)
|
|
13
|
+
) {
|
|
14
|
+
return [true]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const leadingWord = SUBJECT_LEADING_LETTERS.exec(subject)[0]
|
|
18
|
+
|
|
19
|
+
return [
|
|
20
|
+
leadingWord === leadingWord.toLowerCase(),
|
|
21
|
+
'subject must start with a lowercase word',
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
1
25
|
const config = {
|
|
2
26
|
extends: ['@commitlint/config-conventional'],
|
|
27
|
+
plugins: [{ rules: { 'subject-first-word-case': subjectFirstWordCase } }],
|
|
3
28
|
rules: {
|
|
4
29
|
'subject-case': [0],
|
|
30
|
+
'subject-first-word-case': [2, 'always'],
|
|
5
31
|
'header-max-length': [2, 'always', 72],
|
|
6
32
|
'scope-case': [2, 'always', 'lower-case'],
|
|
7
33
|
'subject-full-stop': [2, 'never', '.'],
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
3
|
+
import prettier from 'eslint-config-prettier'
|
|
4
|
+
import checkFile from 'eslint-plugin-check-file'
|
|
5
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
6
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
7
|
+
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
|
8
|
+
import vitest from 'eslint-plugin-vitest'
|
|
9
|
+
import globals from 'globals'
|
|
10
|
+
import tseslint from 'typescript-eslint'
|
|
11
|
+
|
|
12
|
+
export default defineConfig([
|
|
13
|
+
globalIgnores([
|
|
14
|
+
'.next',
|
|
15
|
+
'next-env.d.ts',
|
|
16
|
+
'dist',
|
|
17
|
+
'dist-ssr',
|
|
18
|
+
'coverage',
|
|
19
|
+
'release',
|
|
20
|
+
'.claude',
|
|
21
|
+
'.vscode',
|
|
22
|
+
'.husky',
|
|
23
|
+
'test-results',
|
|
24
|
+
'playwright-report',
|
|
25
|
+
'blob-report',
|
|
26
|
+
'playwright/.cache',
|
|
27
|
+
]),
|
|
28
|
+
js.configs.recommended,
|
|
29
|
+
...tseslint.configs.recommended,
|
|
30
|
+
{
|
|
31
|
+
files: ['**/*.{ts,tsx,js,jsx}'],
|
|
32
|
+
plugins: {
|
|
33
|
+
'react-hooks': reactHooks,
|
|
34
|
+
'react-refresh': reactRefresh,
|
|
35
|
+
'simple-import-sort': simpleImportSort,
|
|
36
|
+
'check-file': checkFile,
|
|
37
|
+
},
|
|
38
|
+
languageOptions: {
|
|
39
|
+
globals: {
|
|
40
|
+
...globals.browser,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
rules: {
|
|
44
|
+
...reactHooks.configs.recommended.rules,
|
|
45
|
+
'react-refresh/only-export-components': [
|
|
46
|
+
'warn',
|
|
47
|
+
{ allowConstantExport: true },
|
|
48
|
+
],
|
|
49
|
+
'simple-import-sort/imports': 'error',
|
|
50
|
+
'simple-import-sort/exports': 'error',
|
|
51
|
+
'@typescript-eslint/no-unused-vars': [
|
|
52
|
+
'error',
|
|
53
|
+
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
|
|
54
|
+
],
|
|
55
|
+
'check-file/filename-naming-convention': [
|
|
56
|
+
'error',
|
|
57
|
+
{ '**/*.{ts,tsx}': 'KEBAB_CASE' },
|
|
58
|
+
{ ignoreMiddleExtensions: true },
|
|
59
|
+
],
|
|
60
|
+
'check-file/folder-naming-convention': [
|
|
61
|
+
'error',
|
|
62
|
+
{ 'src/**/!(__tests__)': 'KEBAB_CASE' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// App Router route and layout files export non-component values (metadata, route handlers), which this rule flags as violations.
|
|
68
|
+
files: ['src/app/**/*.{ts,tsx}'],
|
|
69
|
+
rules: {
|
|
70
|
+
'react-refresh/only-export-components': 'off',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
|
75
|
+
...vitest.configs.recommended,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
files: [
|
|
79
|
+
'*.config.{js,mjs,cjs,ts}',
|
|
80
|
+
'vitest.config.ts',
|
|
81
|
+
'playwright.config.ts',
|
|
82
|
+
],
|
|
83
|
+
languageOptions: {
|
|
84
|
+
globals: {
|
|
85
|
+
...globals.node,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
prettier,
|
|
90
|
+
])
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
const isCI = !!process.env.CI
|
|
4
|
+
const baseURL = `http://localhost:${3000 + (Number(process.env.WORKTREE_PORT_OFFSET) || 0)}`
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
testDir: 'e2e',
|
|
8
|
+
forbidOnly: isCI,
|
|
9
|
+
retries: isCI ? 2 : 0,
|
|
10
|
+
reporter: isCI ? 'list' : 'html',
|
|
11
|
+
use: {
|
|
12
|
+
trace: 'on-first-retry',
|
|
13
|
+
baseURL,
|
|
14
|
+
},
|
|
15
|
+
projects: [
|
|
16
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
17
|
+
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
|
18
|
+
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
|
19
|
+
],
|
|
20
|
+
webServer: {
|
|
21
|
+
command: process.env.DIST_PREBUILT
|
|
22
|
+
? 'bun run preview'
|
|
23
|
+
: 'bun run build && bun run preview',
|
|
24
|
+
url: baseURL,
|
|
25
|
+
reuseExistingServer: false,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from 'vitest/config'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
resolve: {
|
|
7
|
+
alias: {
|
|
8
|
+
'@': path.resolve(__dirname, 'src'),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
test: {
|
|
12
|
+
environment: 'jsdom',
|
|
13
|
+
globals: true,
|
|
14
|
+
setupFiles: ['src/test/setup.ts'],
|
|
15
|
+
passWithNoTests: true,
|
|
16
|
+
exclude: [
|
|
17
|
+
'**/node_modules/**',
|
|
18
|
+
'**/.next/**',
|
|
19
|
+
'**/e2e/**',
|
|
20
|
+
'**/.{idea,git,cache,output,temp}/**',
|
|
21
|
+
],
|
|
22
|
+
coverage: {
|
|
23
|
+
provider: 'v8',
|
|
24
|
+
reporter: ['text', 'json', 'html'],
|
|
25
|
+
exclude: ['node_modules/', 'src/test/setup.ts', 'e2e/'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[stack]
|
|
2
|
+
name = "nextjs"
|
|
3
|
+
extends = "web"
|
|
4
|
+
runtime = "bun"
|
|
5
|
+
scaffold = "bunx create-next-app@latest {{name}} --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" --use-bun --skip-install --disable-git --no-agents-md --yes"
|
|
6
|
+
|
|
7
|
+
[dependencies.dev]
|
|
8
|
+
packages = [
|
|
9
|
+
"next",
|
|
10
|
+
"@tailwindcss/postcss",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[scripts]
|
|
14
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next start --port $((3000 + WORKTREE_PORT_OFFSET))"
|
|
15
|
+
"typecheck" = "next typegen && tsc --noEmit"
|
|
16
|
+
|
|
17
|
+
[scripts.override]
|
|
18
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
|
|
19
|
+
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Tooling nextjs reference
|
|
2
|
+
|
|
3
|
+
> Extends: `web`. Apply web stack first.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The nextjs stack covers Next.js + TypeScript projects using the App Router. It ships golden configs for `next.config.ts`, `vitest.config.ts` (plain `defineConfig`, no bundler merge), `playwright.config.ts` (build-then-preview), and an `eslint.config.js` that extends the web layer's config with `.next` ignores and an App Router override. Shared web tooling (ESLint base, screenshot template, VS Code, CI, verify script) comes from the `web` layer.
|
|
8
|
+
|
|
9
|
+
## Scaffold checklist
|
|
10
|
+
|
|
11
|
+
1. Scaffold with `bunx create-next-app@latest <name> --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-bun --skip-install --disable-git --no-agents-md --yes`. `--no-agents-md` skips the scaffold-time `AGENTS.md`/`CLAUDE.md` write, which otherwise duplicates the root `CLAUDE.md`.
|
|
12
|
+
2. Install web tooling: `canon tooling sync web . --write`
|
|
13
|
+
3. Install nextjs configs: `canon tooling sync nextjs . --write`
|
|
14
|
+
4. Extend the `ci` and `development` context entries under `.claude/context/` per the web reference's extend sections plus the nextjs rows below.
|
|
15
|
+
5. Run `bun run lint:fix` then `bun run check`.
|
|
16
|
+
|
|
17
|
+
## What ships as golden configs
|
|
18
|
+
|
|
19
|
+
- `next.config.ts`: `agentRules: false` stops Next from regenerating `AGENTS.md`/`CLAUDE.md` on every run, which would otherwise compete with the root `CLAUDE.md`. `turbopack.root: import.meta.dirname` pins the workspace root, silencing Next's multi-lockfile inference warning in any checkout carrying more than one lockfile above the project, a worktree included.
|
|
20
|
+
- `vitest.config.ts`: plain `defineConfig`, no `mergeConfig` or `getViteConfig` since Next has no Vite config to merge from. jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `.next/**` in test excludes.
|
|
21
|
+
- `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `3000` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. `DIST_PREBUILT` drops the `build` half, matching the astro stack's flag.
|
|
22
|
+
- `eslint.config.js`: overrides the web layer with `.next` and `next-env.d.ts` added to `globalIgnores`, keeping `react-hooks`/`react-refresh` since this stack still ships React components. `react-refresh/only-export-components` is `off` for `src/app/**`, since route and layout files export non-component values the rule would otherwise flag.
|
|
23
|
+
|
|
24
|
+
## Port
|
|
25
|
+
|
|
26
|
+
Next has no config-file port hook, unlike `astro.config.mjs`'s `server.port` or `vite.config.ts`'s `server.port`. The `${base} + WORKTREE_PORT_OFFSET` formula is computed twice instead of once: in `playwright.config.ts` as a JS expression, and in `[scripts.override]` for `dev` and `preview` as shell arithmetic passed to `next`'s own `--port` flag. Default port is `3000`, Next's own default.
|
|
27
|
+
|
|
28
|
+
## Typecheck
|
|
29
|
+
|
|
30
|
+
`typecheck` runs `next typegen && tsc --noEmit`. The App Router's route-level types (`LayoutProps`, `PageProps`) are generated into `.next/types/`, gitignored and absent from a fresh checkout, and `tsc` fails on them unresolved without the typegen step first.
|
|
31
|
+
|
|
32
|
+
## No golden `tsconfig.json`
|
|
33
|
+
|
|
34
|
+
`create-next-app`'s own default `tsconfig.json` needs no changes beyond project-specific path aliases, so a golden copy here would ship nothing the scaffold does not already write.
|
|
35
|
+
|
|
36
|
+
## Development docs (extend)
|
|
37
|
+
|
|
38
|
+
Append to the `## Scripts` table:
|
|
39
|
+
|
|
40
|
+
| `bun run dev` | Start the Next dev server on port 3000, plus this worktree's port offset. |
|
|
41
|
+
| `bun run build` | Build the production bundle. |
|
|
42
|
+
| `bun run preview` | Serve the built bundle locally, on port 3000 plus the worktree offset. |
|
|
43
|
+
| `bun run typecheck` | Run `next typegen` then `tsc --noEmit`. |
|
|
44
|
+
|
|
45
|
+
## CI docs (extend)
|
|
46
|
+
|
|
47
|
+
In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `next typegen && tsc --noEmit` passes ``.
|
|
@@ -11,7 +11,8 @@ fi
|
|
|
11
11
|
bun run build
|
|
12
12
|
bun run preview >/dev/null 2>&1 &
|
|
13
13
|
PREVIEW_PID=$!
|
|
14
|
-
|
|
14
|
+
# shellcheck disable=SC2154 # pid is bound by the for loop inside the single-quoted trap body, which shellcheck does not track there
|
|
15
|
+
trap 'kill "$PREVIEW_PID" 2>/dev/null || true; if command -v lsof >/dev/null 2>&1; then for pid in $(lsof -ti tcp:"$PREVIEW_PORT" 2>/dev/null); do kill "$pid" 2>/dev/null || true; done; else echo "lsof not found on PATH; cannot confirm the preview port is clear of a detached grandchild." >&2; fi; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
|
|
15
16
|
|
|
16
17
|
for _ in $(seq 1 40); do
|
|
17
18
|
if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
|