Skip to content

Commit 9ba2a1c

Browse files
feat: add component and style lint with biome. (#16)
1 parent 9706f33 commit 9ba2a1c

11 files changed

Lines changed: 1102 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ Repository structure:
4848

4949
## CDN and runtime expectations
5050

51-
- Keep dependency loading compatible with existing provider/fallback model in src/cdn.js.
51+
- Keep dependency loading compatible with existing provider/fallback model in src/modules/cdn.js.
52+
- Treat src/modules/cdn.js as the source of truth for CDN-managed runtime libraries; add/update
53+
CDN candidates there instead of hardcoding module URLs in feature modules.
5254
- Prefer extending existing CDN import key patterns instead of ad hoc dynamic imports.
5355
- Maintain graceful fallback behavior when CDN modules fail to load.
5456
- Keep the app usable in local dev without requiring a local bundle step.

docs/build-and-deploy.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This project uses two runtime modes:
44

5-
- Local development mode: dynamic CDN resolution from `src/cdn.js` with esm.sh as default.
5+
- Local development mode: dynamic CDN resolution from `src/modules/cdn.js` with esm.sh as default.
66
- Production mode: CDN-first build artifacts in `dist`, with `build:esm` as the current preferred deploy build.
77

88
## Local Development
@@ -45,8 +45,8 @@ npm run build:importmap-mode
4545
| Mode | Resolver | Import map step | JSPM index needed | Typical use |
4646
| --- | --- | --- | --- | --- |
4747
| `importMap` | Import map in `dist/index.html` | Yes | Yes | Default production mode |
48-
| `esm` | `src/cdn.js` (`esm.sh` primary) | No | No | Stable fallback mode |
49-
| `jspmGa` | `src/cdn.js` (`ga.jspm.io` primary) | No | No | Direct ga.jspm.io testing |
48+
| `esm` | `src/modules/cdn.js` (`esm.sh` primary) | No | No | Stable fallback mode |
49+
| `jspmGa` | `src/modules/cdn.js` (`ga.jspm.io` primary) | No | No | Direct ga.jspm.io testing |
5050
<!-- prettier-ignore-end -->
5151

5252
Mode notes:
@@ -72,7 +72,7 @@ This runs two steps:
7272
- `sass=1.93.2`
7373
- `less=4.4.2`
7474
- Traces generated `dist/prod-imports.js`
75-
- Import specifiers come from `importMap` entries in `src/cdn.js` (`cdnImportSpecs`)
75+
- Import specifiers come from `importMap` entries in `src/modules/cdn.js` (`cdnImportSpecs`)
7676

7777
Preview the built site locally:
7878

@@ -99,18 +99,20 @@ Related docs:
9999

100100
- `docs/code-mirror.md` for CodeMirror CDN integration rules, fallback behavior, and validation checklist.
101101

102+
- `src/modules/cdn.js` is the source of truth for CDN-managed runtime libraries (including fallback candidates). Add/update CDN specs there instead of hardcoding module URLs inside feature modules.
103+
102104
- In production, the current preferred deploy mode is ESM resolution (`window.__KNIGHTED_PRIMARY_CDN__ = "esm"`).
103105
- In `importMap` mode, runtime resolution is import-map first; if a specifier is missing from the generated map, runtime falls back through the CDN
104-
provider chain configured in `src/cdn.js`.
105-
- In `esm` and `jspmGa` modes, runtime resolution is handled entirely by the CDN provider chain configured in `src/cdn.js` without an import map.
106+
provider chain configured in `src/modules/cdn.js`.
107+
- In `esm` and `jspmGa` modes, runtime resolution is handled entirely by the CDN provider chain configured in `src/modules/cdn.js` without an import map.
106108

107109
### Sass Loading Gotchas
108110

109111
- Symptom: switching to Sass mode shows `Unable to load Sass compiler for browser usage: Dynamic require of "url" is not supported`.
110112
- Cause: some `esm.sh` Sass outputs currently include runtime paths that are not browser-safe for this app.
111-
- Current mitigation: `src/cdn.js` keeps `esm.sh` first, then falls back to `unpkg` for Sass via `sass@1.93.2/sass.default.js?module`.
113+
- Current mitigation: `src/modules/cdn.js` keeps `esm.sh` first, then falls back to `unpkg` for Sass via `sass@1.93.2/sass.default.js?module`.
112114
- Important context: this can appear even if the Sass URL has not changed in this repo, because CDN-transformed module output can change upstream.
113115
- If this regresses again:
114-
- Verify Sass import candidates in `src/cdn.js`.
116+
- Verify Sass import candidates in `src/modules/cdn.js`.
115117
- Reproduce directly in browser devtools with `await import('<candidate-url>')`.
116118
- Keep at least one known browser-safe fallback provider in the Sass candidate list.

docs/next-steps.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22

33
Focused follow-up work for `@knighted/develop`.
44

5-
1. **In-browser component/style linting**
6-
- Explore running lint checks for component and style sources directly in the playground.
7-
- Prefer CDN-delivered tooling where possible and preserve graceful fallback behavior when unavailable.
5+
1. **In-browser lint rules review and expansion**
6+
- Review the currently active Biome lint configuration in `src/modules/lint-diagnostics.js`, including rule groups, severities, and any custom suppression behavior.
7+
- Produce a recommended rule profile for component and style linting that balances signal quality with playground ergonomics.
8+
- Evaluate additional Biome rules to enable (or elevate severity) for:
9+
- correctness and suspicious patterns in component code,
10+
- accessibility and style consistency in JSX output,
11+
- CSS quality checks for style sources currently supported by Biome.
12+
- Revisit existing exceptions (for example unused App/View/render bindings) and document clear criteria for when suppression is acceptable.
13+
- Add/update regression coverage for the chosen rule profile in Playwright so diagnostics button/drawer behavior remains stable as rules evolve.
14+
- Document the finalized lint rule strategy in project docs so contributors can reason about why each rule is enabled, disabled, or downgraded.
15+
- Suggested implementation prompt:
16+
- "Audit the current Biome lint rules used by `@knighted/develop`, propose and apply a refined rule profile for component/styles linting, and add/update Playwright coverage to keep diagnostics UX stable under the new rules. Preserve intentional suppressions only when justified and document the reasoning. Validate with `npm run lint`, `npm run build:esm`, and targeted lint diagnostics Playwright tests."
817

918
2. **In-browser component type checking**
1019
- Add editor-linked diagnostics navigation so each issue can jump to the exact line/column in the component source.
@@ -39,3 +48,11 @@ Focused follow-up work for `@knighted/develop`.
3948
- DOM mode still avoids React type graph hydration.
4049
- Suggested implementation prompt:
4150
- "Refactor `src/modules/type-diagnostics.js` to make TypeScript preprocessor parsing (`preProcessFile`) the source of truth for declaration graph discovery in the lazy React type loader. Keep current CDN fallback and lazy hydration semantics. Ensure references from comments are ignored, `*.d.ts`/relative path handling is correct, and candidate fetch ordering minimizes noisy failed requests. Add regression coverage for `global.d.ts` and commented `./user-context` examples. Validate with `npm run lint`, `npm run build:esm`, and targeted React/typecheck Playwright runs."
51+
52+
6. **Deterministic E2E lane in CI**
53+
- Add an integration-style E2E path that uses locally served/pinned copies of CDN runtime dependencies for test execution, while keeping production runtime behavior unchanged.
54+
- Keep the current true CDN-backed E2E path as a separate smoke check, but make the deterministic lane the required gate for pull requests.
55+
- Run this deterministic E2E suite on **every pull request** in CI.
56+
- Ensure the deterministic lane still exercises the same user-facing flows (render, typecheck, lint, diagnostics drawer/button states), only swapping the source of runtime artifacts.
57+
- Suggested implementation prompt:
58+
- "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run."

playwright/app.spec.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ const setStylesEditorSource = async (page: Page, source: string) => {
2929
await editorContent.fill(source)
3030
}
3131

32+
const runTypecheck = async (page: Page) => {
33+
await ensurePanelToolsVisible(page, 'component')
34+
await page.locator('#typecheck-button').click()
35+
}
36+
37+
const runComponentLint = async (page: Page) => {
38+
await ensurePanelToolsVisible(page, 'component')
39+
await page.locator('#lint-component-button').click()
40+
}
41+
3242
const getCollapseButton = (page: Page, panelName: 'component' | 'styles' | 'preview') =>
3343
page.locator(`#collapse-${panelName}`)
3444

@@ -647,3 +657,234 @@ test('clear all diagnostics removes style compile diagnostics', async ({ page })
647657
/diagnostics-toggle--neutral/,
648658
)
649659
})
660+
661+
test('clear styles diagnostics removes style compile diagnostics', async ({ page }) => {
662+
await waitForInitialRender(page)
663+
664+
await ensurePanelToolsVisible(page, 'styles')
665+
666+
await page.locator('#style-mode').selectOption('sass')
667+
await setStylesEditorSource(page, '.card { color: $missing; }')
668+
669+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
670+
/diagnostics-toggle--error/,
671+
)
672+
673+
await page.locator('#diagnostics-toggle').click()
674+
await expect(page.locator('#diagnostics-styles')).toContainText(
675+
'Style compilation failed.',
676+
)
677+
678+
await page.locator('#diagnostics-clear-styles').click()
679+
await expect(page.locator('#diagnostics-component')).toContainText(
680+
'No diagnostics yet.',
681+
)
682+
await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.')
683+
await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics')
684+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
685+
/diagnostics-toggle--neutral/,
686+
)
687+
})
688+
689+
test('typecheck success reports ok diagnostics state in button and drawer', async ({
690+
page,
691+
}) => {
692+
await waitForInitialRender(page)
693+
694+
await runTypecheck(page)
695+
696+
await expect(page.locator('#status')).toHaveText('Rendered')
697+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(/diagnostics-toggle--ok/)
698+
await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics')
699+
700+
await page.locator('#diagnostics-toggle').click()
701+
await expect(page.locator('#diagnostics-component')).toContainText(
702+
'No TypeScript errors found.',
703+
)
704+
})
705+
706+
test('typecheck error reports diagnostics count in button and details in drawer', async ({
707+
page,
708+
}) => {
709+
await waitForInitialRender(page)
710+
711+
await setComponentEditorSource(
712+
page,
713+
["const broken: number = 'oops'", 'const App = () => <button>hello</button>'].join(
714+
'\n',
715+
),
716+
)
717+
718+
await runTypecheck(page)
719+
720+
await expect(page.locator('#status')).toHaveText(/Rendered \(Type errors: [1-9]\d*\)/)
721+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
722+
/diagnostics-toggle--error/,
723+
)
724+
await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/)
725+
726+
await page.locator('#diagnostics-toggle').click()
727+
await expect(page.locator('#diagnostics-component')).toContainText('TypeScript found')
728+
await expect(page.locator('#diagnostics-component')).toContainText('TS')
729+
})
730+
731+
test('component lint error reports diagnostics count and details', async ({ page }) => {
732+
await waitForInitialRender(page)
733+
734+
await setComponentEditorSource(
735+
page,
736+
['const unusedValue = 1', 'const App = () => <button>lint me</button>'].join('\n'),
737+
)
738+
739+
await runComponentLint(page)
740+
741+
await expect(page.locator('#status')).toHaveText(/Rendered \(Lint issues: [1-9]\d*\)/)
742+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
743+
/diagnostics-toggle--error/,
744+
)
745+
await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/)
746+
747+
await page.locator('#diagnostics-toggle').click()
748+
await expect(page.locator('#diagnostics-component')).toContainText(
749+
'Biome reported issues.',
750+
)
751+
})
752+
753+
test('clear component diagnostics resets rendered lint-issue status pill', async ({
754+
page,
755+
}) => {
756+
await waitForInitialRender(page)
757+
758+
await setComponentEditorSource(
759+
page,
760+
[
761+
'const unusedValue = 1',
762+
'const App = () => <button type="button">lint me</button>',
763+
].join('\n'),
764+
)
765+
766+
await runComponentLint(page)
767+
768+
await expect(page.locator('#status')).toHaveText(/Rendered \(Lint issues: [1-9]\d*\)/)
769+
await expect(page.locator('#status')).toHaveClass(/status--error/)
770+
771+
await page.locator('#diagnostics-toggle').click()
772+
await page.locator('#diagnostics-clear-component').click()
773+
774+
await expect(page.locator('#diagnostics-component')).toContainText(
775+
'No diagnostics yet.',
776+
)
777+
await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics')
778+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
779+
/diagnostics-toggle--neutral/,
780+
)
781+
await expect(page.locator('#status')).toHaveText('Rendered')
782+
await expect(page.locator('#status')).toHaveClass(/status--neutral/)
783+
})
784+
785+
test('component lint ignores unused App View and render bindings', async ({ page }) => {
786+
await waitForInitialRender(page)
787+
788+
await setComponentEditorSource(
789+
page,
790+
[
791+
'function App() { return <button type="button">App</button> }',
792+
'function View() { return <section>View</section> }',
793+
'function render() { return null }',
794+
].join('\n'),
795+
)
796+
797+
await runComponentLint(page)
798+
799+
await page.locator('#diagnostics-toggle').click()
800+
await expect(page.locator('#diagnostics-component')).toContainText(
801+
'No Biome issues found.',
802+
)
803+
804+
await expect(page.locator('#status')).toHaveText('Rendered')
805+
await expect(page.locator('#status')).toHaveClass(/status--neutral/)
806+
await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics')
807+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(/diagnostics-toggle--ok/)
808+
809+
const diagnosticsText = await page.locator('#diagnostics-component').innerText()
810+
expect(diagnosticsText).not.toContain('This variable App is unused')
811+
expect(diagnosticsText).not.toContain('This variable View is unused')
812+
expect(diagnosticsText).not.toContain('This variable render is unused')
813+
expect(diagnosticsText).not.toContain('This function App is unused')
814+
expect(diagnosticsText).not.toContain('This function View is unused')
815+
expect(diagnosticsText).not.toContain('This function render is unused')
816+
})
817+
818+
test('component lint with unresolved issues enters pending diagnostics state while typing', async ({
819+
page,
820+
}) => {
821+
await waitForInitialRender(page)
822+
823+
await setComponentEditorSource(
824+
page,
825+
['const unusedValue = 1', 'const App = () => <button>pending</button>'].join('\n'),
826+
)
827+
828+
await runComponentLint(page)
829+
830+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
831+
/diagnostics-toggle--error/,
832+
)
833+
834+
await setComponentEditorSource(
835+
page,
836+
['const unusedValue = 1', 'const App = () => <button>pending now</button>'].join(
837+
'\n',
838+
),
839+
)
840+
841+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
842+
/diagnostics-toggle--pending/,
843+
)
844+
await expect(page.locator('#diagnostics-toggle')).toHaveAttribute('aria-busy', 'true')
845+
846+
await expect(page.locator('#status')).toHaveText(/Rendered \(Lint issues: [1-9]\d*\)/)
847+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
848+
/diagnostics-toggle--error/,
849+
)
850+
await expect(page.locator('#diagnostics-toggle')).toHaveAttribute('aria-busy', 'false')
851+
})
852+
853+
test('changing css dialect resets diagnostics after lint and typecheck runs', async ({
854+
page,
855+
}) => {
856+
await waitForInitialRender(page)
857+
await ensurePanelToolsVisible(page, 'styles')
858+
859+
await setComponentEditorSource(
860+
page,
861+
[
862+
"const broken: number = 'oops'",
863+
'const unusedValue = 1',
864+
'const App = () => <button>reset me</button>',
865+
].join('\n'),
866+
)
867+
868+
await runTypecheck(page)
869+
await runComponentLint(page)
870+
871+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
872+
/diagnostics-toggle--error/,
873+
)
874+
await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/)
875+
876+
await page.locator('#style-mode').selectOption('less')
877+
878+
await expect(page.locator('#status')).toHaveText('Rendered')
879+
await expect(page.locator('#status')).toHaveClass(/status--neutral/)
880+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
881+
/diagnostics-toggle--neutral/,
882+
)
883+
await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics')
884+
885+
await page.locator('#diagnostics-toggle').click()
886+
await expect(page.locator('#diagnostics-component')).toContainText(
887+
'No diagnostics yet.',
888+
)
889+
await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.')
890+
})

0 commit comments

Comments
 (0)