From 79df196843eee396325f789f079cf0507d3f33be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 12:27:18 +0000 Subject: [PATCH 1/2] feat: run stylelint in the browser on the lint-css page Drives stylelint's linting core directly (lintPostcssResult, normalizeAllRuleSettings, createPartialStylelintResult, prepareReturnValue) instead of routing through standalone()/lint(), which statically pulls in globby, cosmiconfig and other Node-only deps. The four internals aren't part of stylelint's public exports map, so they're aliased to the real files on disk in vite.config.js and pinned to stylelint ~17.13.0 via stylelint-internals.d.ts. Replaces the /api/lint-css POST round-trip: the Linter component now fetches CSS (for URL input) via the existing client-side get_css() helper and lints it in-browser with src/lib/stylelint-browser/lint.ts, cutting out a full server round-trip per lint. CSS-scraping stays server-side in /api/get-css (Node-only deps, CORS). Includes browser-safe shims for node:os, node:process and stylelint's own bare-specifier entry point (needed because @projectwallace/stylelint-plugin's rule files import `stylelint` just to reach createPlugin/utils), plus a stub for stylelint's CLI timing/table output which isn't reachable from the browser and doesn't bundle cleanly. --- package.json | 9 +- pnpm-lock.yaml | 24 +++- src/lib/components/Linter.svelte | 50 ++++++-- src/lib/stylelint-browser/lint.test.ts | 40 ++++++ src/lib/stylelint-browser/lint.ts | 118 ++++++++++++++++++ src/lib/stylelint-browser/presets.ts | 17 +++ src/lib/stylelint-browser/shims/node-os.ts | 4 + .../stylelint-browser/shims/node-process.ts | 15 +++ .../shims/stylelint-timing.ts | 15 +++ .../stylelint-internals.d.ts | 80 ++++++++++++ src/lib/stylelint-browser/stylelint-shim.ts | 23 ++++ src/routes/(public)/lint-css/spec.ts | 42 +++---- src/routes/api/lint-css/+server.ts | 100 --------------- vite.config.js | 58 +++++++++ 14 files changed, 452 insertions(+), 143 deletions(-) create mode 100644 src/lib/stylelint-browser/lint.test.ts create mode 100644 src/lib/stylelint-browser/lint.ts create mode 100644 src/lib/stylelint-browser/presets.ts create mode 100644 src/lib/stylelint-browser/shims/node-os.ts create mode 100644 src/lib/stylelint-browser/shims/node-process.ts create mode 100644 src/lib/stylelint-browser/shims/stylelint-timing.ts create mode 100644 src/lib/stylelint-browser/stylelint-internals.d.ts create mode 100644 src/lib/stylelint-browser/stylelint-shim.ts delete mode 100644 src/routes/api/lint-css/+server.ts diff --git a/package.json b/package.json index 3a696cf..5a628ef 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "css-coverage": "css-coverage ./css-coverage --min-coverage=.95 --min-file-coverage=.85 --show-uncovered=all" }, "dependencies": { + "@csstools/css-syntax-patches-for-csstree": "^1.1.4", + "@csstools/css-tokenizer": "^4.0.0", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@oddbird/popover-polyfill": "^0.6.1", @@ -36,29 +38,32 @@ "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^7.2.0", "color-sorter": "^8.0.2", + "css-tree": "^3.2.1", "diff": "^9.0.0", + "fastest-levenshtein": "^1.0.16", "github-slugger": "^2.0.0", "lightningcss": "1.32.0", "linkedom": "^0.18.13", "mdsvex": "^0.12.8", "paneforge": "^1.0.2", + "postcss": "^8.5.15", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "runed": "^0.37.1", + "stylelint": "~17.13.0", "svelte": "^5.56.8", + "table": "^6.9.0", "typescript": "^6.0.3", "vite": "8.0.16" }, "devDependencies": { "@playwright/test": "^1.62.0", - "@projectwallace/stylelint-plugin": "^0.6.0", "@sveltejs/adapter-netlify": "^6.0.4", "@types/node": "^25.9.5", "oxfmt": "0.55.0", "oxlint": "^1.75.0", "oxlint-tsgolint": "^0.23.0", "postcss-html": "^1.8.1", - "stylelint": "~17.13.0", "stylelint-config-standard": "~40.0.0", "svelte-check": "^4.7.3", "vitest": "^4.1.10" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc27a0b..ef91c19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: .: dependencies: + '@csstools/css-syntax-patches-for-csstree': + specifier: ^1.1.4 + version: 1.1.7(css-tree@3.2.1) + '@csstools/css-tokenizer': + specifier: ^4.0.0 + version: 4.0.0 '@melt-ui/pp': specifier: ^0.3.2 version: 0.3.2(@melt-ui/svelte@0.86.6(svelte@5.56.8))(svelte@5.56.8) @@ -68,9 +74,15 @@ importers: color-sorter: specifier: ^8.0.2 version: 8.0.2 + css-tree: + specifier: ^3.2.1 + version: 3.2.1 diff: specifier: ^9.0.0 version: 9.0.0 + fastest-levenshtein: + specifier: ^1.0.16 + version: 1.0.16 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -86,6 +98,9 @@ importers: paneforge: specifier: ^1.0.2 version: 1.0.2(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8)(vite@8.0.16(@types/node@25.9.5)(esbuild@0.28.1)))(svelte@5.56.8)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.5)(esbuild@0.28.1)))(svelte@5.56.8) + postcss: + specifier: ^8.5.15 + version: 8.5.23 rehype-autolink-headings: specifier: ^7.1.0 version: 7.1.0 @@ -95,9 +110,15 @@ importers: runed: specifier: ^0.37.1 version: 0.37.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8)(vite@8.0.16(@types/node@25.9.5)(esbuild@0.28.1)))(svelte@5.56.8)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.5)(esbuild@0.28.1)))(svelte@5.56.8) + stylelint: + specifier: ~17.13.0 + version: 17.13.0(supports-color@10.2.2)(typescript@6.0.3) svelte: specifier: ^5.56.8 version: 5.56.8 + table: + specifier: ^6.9.0 + version: 6.9.0 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -126,9 +147,6 @@ importers: postcss-html: specifier: ^1.8.1 version: 1.8.1 - stylelint: - specifier: ~17.13.0 - version: 17.13.0(supports-color@10.2.2)(typescript@6.0.3) stylelint-config-standard: specifier: ~40.0.0 version: 40.0.0(stylelint@17.13.0(supports-color@10.2.2)(typescript@6.0.3)) diff --git a/src/lib/components/Linter.svelte b/src/lib/components/Linter.svelte index 023564d..30ef4f5 100644 --- a/src/lib/components/Linter.svelte +++ b/src/lib/components/Linter.svelte @@ -13,6 +13,11 @@ import { presets, type Preset, DEFAULT_PRESET } from '$lib/lint-preset' import PanedLayout from './PanedLayout.svelte' import Pane from './Pane.svelte' + import { get_css } from '$lib/get-css' + import { format } from '@projectwallace/format-css' + import stylelintPlugin from '@projectwallace/stylelint-plugin' + import { lint } from '$lib/stylelint-browser/lint' + import { PRESET_MAP } from '$lib/stylelint-browser/presets' let { elements: { root, item } @@ -28,7 +33,6 @@ } duration: number css?: string - rules?: Record } type Props = { @@ -68,21 +72,41 @@ status = 'loading' onloading?.(true) try { - const body = url ? { url, preset, prettify } : { css, preset } - const response = await fetch('/api/lint-css', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }) - if (response.ok) { - lint_result = await response.json() - if (lint_result?.css) { - api_css = lint_result.css + let text: string + if (url) { + const origins = await get_css(url) + text = origins.map((o) => o.css).join('\n') + if (prettify) { + text = format(text) } - status = lint_result?.result.parse_error ? 'lint_error' : 'success' + api_css = text } else { - status = 'error' + text = css + } + + const start = performance.now() + const linter_result = await lint([{ id: url ?? 'input.css', code: text }], { + plugins: stylelintPlugin, + rules: PRESET_MAP[preset] + }) + const duration = performance.now() - start + + const file = linter_result.results.at(0) + if (!file) throw new Error('No lint result') + + const lint_warnings = file.warnings.filter((w) => w.rule !== 'CssSyntaxError') + const parse_error = file.warnings.find((w) => w.rule === 'CssSyntaxError') + + lint_result = { + result: { + errored: file.invalidOptionWarnings.length > 0 || lint_warnings.some((w) => w.severity === 'error'), + parse_error, + warnings: lint_warnings.toSorted((a, b) => (a.line === b.line ? a.column - b.column : a.line - b.line)) + }, + duration: parseFloat(duration.toFixed(1)), + css: url ? text : undefined } + status = lint_result.result.parse_error ? 'lint_error' : 'success' } catch { status = 'error' } finally { diff --git a/src/lib/stylelint-browser/lint.test.ts b/src/lib/stylelint-browser/lint.test.ts new file mode 100644 index 0000000..8d168b7 --- /dev/null +++ b/src/lib/stylelint-browser/lint.test.ts @@ -0,0 +1,40 @@ +import { test, expect } from 'vitest' +import stylelintPlugin from '@projectwallace/stylelint-plugin' +import { lint } from './lint' +import { PRESET_MAP } from './presets' + +// Smoke test for the deep-imported stylelint internals (lintPostcssResult, +// normalizeAllRuleSettings, createPartialStylelintResult, prepareReturnValue). +// If a stylelint upgrade changes their signatures or the shape of the +// `.stylelint` scaffold object they expect, this should fail loudly instead +// of silently linting nothing. +test('flags a known rule violation', async () => { + const result = await lint([{ id: 'input.css', code: 'a { colour: red !important; }' }], { + rules: PRESET_MAP.recommended, + plugins: stylelintPlugin + }) + + const file = result.results.at(0) + expect(file).toBeDefined() + expect(file?.warnings.map((w) => w.rule)).toContain('projectwallace/max-important-ratio') +}) + +test('reports no warnings for clean CSS', async () => { + const result = await lint([{ id: 'input.css', code: 'a { color: red; }' }], { + rules: PRESET_MAP.recommended, + plugins: stylelintPlugin + }) + + const file = result.results.at(0) + expect(file?.warnings).toEqual([]) +}) + +test('reports a CssSyntaxError for invalid CSS', async () => { + const result = await lint([{ id: 'input.css', code: `a { background: url('")}` }], { + rules: PRESET_MAP.recommended, + plugins: stylelintPlugin + }) + + const file = result.results.at(0) + expect(file?.warnings.some((w) => w.rule === 'CssSyntaxError')).toBe(true) +}) diff --git a/src/lib/stylelint-browser/lint.ts b/src/lib/stylelint-browser/lint.ts new file mode 100644 index 0000000..13cf9bd --- /dev/null +++ b/src/lib/stylelint-browser/lint.ts @@ -0,0 +1,118 @@ +// Runs stylelint's linting core directly in the browser, skipping stylelint's +// `standalone()`/`lint()` entry point (and the file-resolution machinery +// behind it: globby, cosmiconfig, write-file-atomic, ...) since we only ever +// lint in-memory CSS against a single, already-resolved config. +// +// This deep-imports a handful of stylelint internals that aren't part of its +// public `exports` map. They're aliased to real files on disk in +// vite.config.js, and their shapes are pinned via stylelint-internals.d.ts. +// Re-verify both against node_modules/stylelint/lib/*.mjs when bumping the +// stylelint version (pinned in package.json). +import postcss from 'postcss' +import lintPostcssResult from 'stylelint/lib/lintPostcssResult.mjs' +import normalizeAllRuleSettings from 'stylelint/lib/normalizeAllRuleSettings.mjs' +import createPartialStylelintResult from 'stylelint/lib/createPartialStylelintResult.mjs' +import prepareReturnValue from 'stylelint/lib/prepareReturnValue.mjs' +import jsonFormatter from 'stylelint/lib/formatters/jsonFormatter.mjs' +// `lib/utils/*` is publicly exported by stylelint, so this one needs no alias. +import getLexer from 'stylelint/lib/utils/getLexer.mjs' +import type { Config, LinterResult, PostcssResult, Rule } from 'stylelint' + +export type LintSource = { + /** Cosmetic label only (becomes `result.source`); a file path or URL both work. */ + id: string + code: string +} + +type PluginRuleDefinition = { ruleName: string; rule: Rule } + +/** + * Re-implementation of stylelint's `addPluginFunctions` (lib/augmentConfig.mjs), + * minus the module-resolution branch: plugins must already be imported objects, + * never string specifiers. + */ +function build_plugin_functions(plugins: Config['plugins']): Record { + const plugin_functions: Record = {} + if (!plugins) return plugin_functions + + const normalized_plugins = ([] as unknown[]).concat(plugins) + + for (const plugin_lookup of normalized_plugins) { + if (typeof plugin_lookup === 'string') { + throw new TypeError( + `Browser stylelint driver only accepts already-imported plugin objects, got a string: "${plugin_lookup}"` + ) + } + + const plugin_import = (plugin_lookup as { default?: unknown }).default ?? plugin_lookup + const rule_definitions = ([] as PluginRuleDefinition[]).concat(plugin_import as PluginRuleDefinition) + + for (const rule_definition of rule_definitions) { + if (!rule_definition.ruleName) { + throw new Error('stylelint requires plugins to expose a ruleName.') + } + if (!rule_definition.ruleName.includes('/')) { + throw new Error( + `stylelint requires plugin rules to be namespaced, i.e. only "plugin-namespace/plugin-rule-name" plugin rule names are supported. The plugin rule "${rule_definition.ruleName}" does not do this.` + ) + } + plugin_functions[rule_definition.ruleName] = rule_definition.rule + } + } + + return plugin_functions +} + +function prepare_config(config: Config): Promise { + const prepared: Config = { + rules: config.rules, + defaultSeverity: config.defaultSeverity, + _pluginFunctions: build_plugin_functions(config.plugins) + } + return normalizeAllRuleSettings(prepared) +} + +async function lint_one(source: LintSource, config: Config) { + try { + const postcss_result = (await postcss().process(source.code, { from: source.id }).async()) as PostcssResult + + const wrapped: PostcssResult = Object.assign(postcss_result, { + stylelint: { + ruleSeverities: {}, + customMessages: {}, + customUrls: {}, + ruleMetadata: {}, + fixersData: {}, + rangesOfComputedEditInfos: [], + disabledRanges: {}, + lexer: getLexer(config), + referenceRoots: [] + } + }) + + await lintPostcssResult({ quietDeprecationWarnings: true }, wrapped, config) + + return createPartialStylelintResult(wrapped) + } catch (error) { + if (error instanceof Error && error.name === 'CssSyntaxError') { + return createPartialStylelintResult(undefined, error as Error & { name: 'CssSyntaxError' }) + } + throw error + } +} + +/** + * Lint one or more in-memory CSS sources against a single config. No + * `extends` resolution, no custom syntaxes, no caching, no ignore files, no + * autofix - see the plan this implements for the full list of trade-offs. + */ +export async function lint(sources: LintSource[], config: Config): Promise { + const prepared_config = await prepare_config(config) + const results = [] + + for (const source of sources) { + results.push(await lint_one(source, prepared_config)) + } + + return prepareReturnValue({ results, maxWarnings: undefined, formatter: jsonFormatter, cwd: '/' }) +} diff --git a/src/lib/stylelint-browser/presets.ts b/src/lib/stylelint-browser/presets.ts new file mode 100644 index 0000000..2fbb45e --- /dev/null +++ b/src/lib/stylelint-browser/presets.ts @@ -0,0 +1,17 @@ +import recommendedConfig from '@projectwallace/stylelint-plugin/configs/recommended' +import performanceConfig from '@projectwallace/stylelint-plugin/configs/performance' +import maintainabilityConfig from '@projectwallace/stylelint-plugin/configs/maintainability' +import correctnessConfig from '@projectwallace/stylelint-plugin/configs/correctness' +import designTokensConfig from '@projectwallace/stylelint-plugin/configs/design-tokens' +import holisticConfig from '@projectwallace/stylelint-plugin/configs/holistic' +import type { Config } from 'stylelint' +import { type Preset } from '$lib/lint-preset' + +export const PRESET_MAP: Record | undefined> = { + recommended: recommendedConfig.rules, + performance: performanceConfig.rules, + maintainability: maintainabilityConfig.rules, + correctness: correctnessConfig.rules, + designtokens: designTokensConfig.rules, + holistic: holisticConfig.rules +} diff --git a/src/lib/stylelint-browser/shims/node-os.ts b/src/lib/stylelint-browser/shims/node-os.ts new file mode 100644 index 0000000..68cbaba --- /dev/null +++ b/src/lib/stylelint-browser/shims/node-os.ts @@ -0,0 +1,4 @@ +// Browser-only stand-in for Node's `node:os` module. Vite aliases the bare +// `node:os` specifier to this file for client bundles (see vite.config.js). +// Stylelint's internals only ever read `EOL`. +export const EOL = '\n' diff --git a/src/lib/stylelint-browser/shims/node-process.ts b/src/lib/stylelint-browser/shims/node-process.ts new file mode 100644 index 0000000..a5dcd9e --- /dev/null +++ b/src/lib/stylelint-browser/shims/node-process.ts @@ -0,0 +1,15 @@ +// Browser-only stand-in for Node's `node:process` module. Vite aliases the +// bare `node:process` specifier to this file for client bundles (see +// vite.config.js). Only the bits stylelint's internals touch are stubbed: +// - `env.TIMING` gates a CLI table printout we never trigger +// - `noDeprecation` toggles inclusion of deprecation messages +// - `emitWarning`/`on` are called defensively but never in normal operation +const process_shim = { + env: {} as Record, + noDeprecation: false, + emitWarning: (..._args: unknown[]) => {}, + on: (..._args: unknown[]) => {}, + hrtime: Object.assign((..._args: unknown[]) => [0, 0], { bigint: () => BigInt(0) }) +} + +export default process_shim diff --git a/src/lib/stylelint-browser/shims/stylelint-timing.ts b/src/lib/stylelint-browser/shims/stylelint-timing.ts new file mode 100644 index 0000000..d93654b --- /dev/null +++ b/src/lib/stylelint-browser/shims/stylelint-timing.ts @@ -0,0 +1,15 @@ +// Browser-only stand-in for stylelint's lib/timing.mjs. Vite redirects that +// relative import to this file for client bundles (see vite.config.js). +// +// The real module is only useful for `TIMING= stylelint ...` CLI runs - +// our `node:process` shim never sets that env var, so `timing.enabled` would +// always be `false` anyway. Pulling in the real module drags in the `table` +// package, which (via `supports-hyperlinks` -> `supports-color`) doesn't +// bundle for the browser. +export default { + display: () => {}, + enabled: false, + getListSize: () => 0, + tableConfig: {}, + time: (_key: string, fn: (...args: unknown[]) => unknown) => fn +} diff --git a/src/lib/stylelint-browser/stylelint-internals.d.ts b/src/lib/stylelint-browser/stylelint-internals.d.ts new file mode 100644 index 0000000..67d7aa9 --- /dev/null +++ b/src/lib/stylelint-browser/stylelint-internals.d.ts @@ -0,0 +1,80 @@ +// stylelint's package.json `exports` map only publishes `.`, `./package.json` +// and `./lib/utils/*`. The files below are unpublished internals we deep- +// import on purpose (see src/lib/stylelint-browser/lint.ts) and alias in +// vite.config.js so the bundler can still find them on disk. TypeScript's +// "bundler" module resolution honours the exports map though, so without +// these ambient declarations it can't see the deep paths exist. +// +// Pinned to stylelint ~17.13.0 (see package.json) - re-check these shapes +// against node_modules/stylelint/lib/*.mjs whenever that version changes. + +declare module 'stylelint/lib/lintPostcssResult.mjs' { + import type { LinterOptions, PostcssResult, Config } from 'stylelint' + export default function lintPostcssResult( + stylelintOptions: Partial, + postcssResult: PostcssResult, + config: Config + ): Promise +} + +declare module 'stylelint/lib/normalizeAllRuleSettings.mjs' { + import type { Config } from 'stylelint' + export default function normalizeAllRuleSettings(config: Config): Promise +} + +declare module 'stylelint/lib/createPartialStylelintResult.mjs' { + import type { LintResult, PostcssResult } from 'stylelint' + export default function createPartialStylelintResult( + postcssResult?: PostcssResult, + cssSyntaxError?: Error & { name: 'CssSyntaxError' } + ): LintResult +} + +declare module 'stylelint/lib/prepareReturnValue.mjs' { + import type { LinterResult, LintResult, Formatter } from 'stylelint' + export default function prepareReturnValue(args: { + results: LintResult[] + maxWarnings?: number + formatter: Formatter + cwd: string + }): LinterResult +} + +declare module 'stylelint/lib/formatters/jsonFormatter.mjs' { + import type { Formatter } from 'stylelint' + const jsonFormatter: Formatter + export default jsonFormatter +} + +// stylelint's `./lib/utils/*` export is public, but since stylelint ships a +// "types" condition for its main entry, TypeScript treats the whole package +// as self-typed and stops falling back to `allowJs` inference for subpaths +// that lack their own declaration file - these four need the same manual +// pinning as the internals above. + +declare module 'stylelint/lib/utils/getLexer.mjs' { + import type { Config } from 'stylelint' + export default function getLexer(config: Config, additionalSyntax?: unknown): unknown +} + +declare module 'stylelint/lib/utils/report.mjs' { + import type { Problem } from 'stylelint' + export default function report(problem: Problem): void +} + +declare module 'stylelint/lib/utils/ruleMessages.mjs' { + export default function ruleMessages>(ruleName: string, messages: T): T +} + +declare module 'stylelint/lib/utils/validateOptions.mjs' { + import type { PostcssResult } from 'stylelint' + export default function validateOptions( + result: PostcssResult, + ruleName: string, + ...optionDescriptions: unknown[] + ): boolean +} + +declare module 'stylelint/lib/utils/checkAgainstRule.mjs' { + export default function checkAgainstRule(options: unknown, callback: (...args: unknown[]) => void): Promise +} diff --git a/src/lib/stylelint-browser/stylelint-shim.ts b/src/lib/stylelint-browser/stylelint-shim.ts new file mode 100644 index 0000000..901588c --- /dev/null +++ b/src/lib/stylelint-browser/stylelint-shim.ts @@ -0,0 +1,23 @@ +// Browser-only stand-in for the `stylelint` package itself. Vite aliases the +// bare `stylelint` specifier to this file for client bundles (see +// vite.config.js), so that plugin packages doing `import stylelint from +// 'stylelint'` (only to reach `stylelint.createPlugin`/`stylelint.utils`) +// don't drag in `standalone.mjs` and its Node-only deps (globby, cosmiconfig, +// write-file-atomic, ...). +// +// The four utils below live under stylelint's public `./lib/utils/*` export, +// so they resolve without any extra aliasing. +import report from 'stylelint/lib/utils/report.mjs' +import ruleMessages from 'stylelint/lib/utils/ruleMessages.mjs' +import validateOptions from 'stylelint/lib/utils/validateOptions.mjs' +import checkAgainstRule from 'stylelint/lib/utils/checkAgainstRule.mjs' +import type { Rule } from 'stylelint' + +function createPlugin(ruleName: string, rule: Rule) { + return { ruleName, rule } +} + +export default { + createPlugin, + utils: { report, ruleMessages, validateOptions, checkAgainstRule } +} diff --git a/src/routes/(public)/lint-css/spec.ts b/src/routes/(public)/lint-css/spec.ts index f820c6a..9914781 100644 --- a/src/routes/(public)/lint-css/spec.ts +++ b/src/routes/(public)/lint-css/spec.ts @@ -1,25 +1,17 @@ import { test, expect } from '../../../../tests/fixtures' -const LINT_CSS_API = '**/api/lint-css' - -const mock_lint_response = { - result: { - errored: false, - parse_error: undefined, - warnings: [ - { - line: 1, - column: 5, - endLine: 1, - endColumn: 11, - text: 'Unexpected unknown property "colour" (property-no-unknown)', - rule: 'property-no-unknown' - } - ] - }, - duration: 10, - css: 'a {\n\tcolour: red;\n}\n' -} +// Linting itself now runs client-side (see src/lib/stylelint-browser/lint.ts), +// so only the CSS-fetching API is mocked here; warnings below reflect real +// stylelint output for the given CSS. +const GET_CSS_API = '**/api/get-css*' + +const mock_origins = [ + { + type: 'file', + href: 'https://example.com/style.css', + css: 'a { colour: red !important; }' + } +] test('does SEO well', async ({ page }) => { await page.goto('/lint-css', { waitUntil: 'domcontentloaded' }) @@ -32,9 +24,9 @@ test('does SEO well', async ({ page }) => { test.describe('URL input mode', () => { test.beforeEach(async ({ page }) => { - await page.route(LINT_CSS_API, async (route) => { - if (route.request().method() === 'POST') { - await route.fulfill({ status: 200, json: mock_lint_response }) + await page.route(GET_CSS_API, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: mock_origins }) } else { await route.continue() } @@ -49,14 +41,14 @@ test.describe('URL input mode', () => { await expect.soft(page.getByRole('table')).toBeVisible() expect.soft(await page.getByRole('table').getByRole('row').count()).toBeGreaterThanOrEqual(2) - await expect.soft(page.getByRole('cell', { name: /Unexpected unknown property/ })).toBeVisible() + await expect.soft(page.getByRole('cell', { name: /important ratio/i })).toBeVisible() }) test('shows the fetched CSS in the CSS input pane', async ({ page }) => { await page.getByLabel('Website URL').fill('example.com') await page.getByRole('button', { name: 'Analyze URL' }).click() - await expect.soft(page.getByTestId('pre-css')).toContainText(mock_lint_response.css) + await expect.soft(page.getByTestId('pre-css')).toContainText('colour: red !important;') }) }) diff --git a/src/routes/api/lint-css/+server.ts b/src/routes/api/lint-css/+server.ts deleted file mode 100644 index 92dd9db..0000000 --- a/src/routes/api/lint-css/+server.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { error, json } from '@sveltejs/kit' -import stylelint, { type Config } from 'stylelint' -import recommendedConfig from '@projectwallace/stylelint-plugin/configs/recommended' -import performanceConfig from '@projectwallace/stylelint-plugin/configs/performance' -import maintainabilityConfig from '@projectwallace/stylelint-plugin/configs/maintainability' -import correctnessConfig from '@projectwallace/stylelint-plugin/configs/correctness' -import designTokensConfig from '@projectwallace/stylelint-plugin/configs/design-tokens' -import holisticConfig from '@projectwallace/stylelint-plugin/configs/holistic' -import stylelintPlugin from '@projectwallace/stylelint-plugin' -import { get_css } from '../get-css/get-css' -import { format } from '@projectwallace/format-css' -import type { RequestHandler } from './$types' -import { presets, type Preset, DEFAULT_PRESET } from '$lib/lint-preset' - -export type { Preset } - -const PRESET_MAP: Record | undefined> = { - recommended: recommendedConfig.rules, - performance: performanceConfig.rules, - maintainability: maintainabilityConfig.rules, - correctness: correctnessConfig.rules, - designtokens: designTokensConfig.rules, - holistic: holisticConfig.rules -} - -type RequestBody = { url?: string; css?: string; preset?: string; prettify?: boolean } - -export const POST: RequestHandler = async ({ request, setHeaders }) => { - const body = (await request.json()) as RequestBody - const raw_preset = body.preset?.toString() - - let css: string - let url_css: string | undefined - - if (body.url) { - const origins = await get_css(body.url.toString()) - if ('error' in origins) { - error(400, origins.error.message) - } - css = origins.map((o) => o.css).join('\n') - if (body.prettify) { - css = format(css) - } - url_css = css - } else { - css = body.css?.toString() ?? '' - } - const preset: Preset = - raw_preset && (presets as readonly string[]).includes(raw_preset) ? (raw_preset as Preset) : DEFAULT_PRESET - - let rules: NonNullable = PRESET_MAP[preset] ?? (Object.create(null) as NonNullable) - - const start = performance.now() - const lint_result = await stylelint.lint({ - config: { - plugins: stylelintPlugin, - rules - }, - code: css?.toString(), - configBasedir: process.cwd() - }) - - const file = lint_result.results.at(0) - - if (!file) { - error(500) - } - - const lint_warnings = file.warnings.filter((w) => w.rule !== 'CssSyntaxError') - const parse_error = file.warnings.find((w) => w.rule === 'CssSyntaxError') - - const return_data = { - errored: file.invalidOptionWarnings.length > 0 || lint_warnings.some((w) => w.severity === 'error'), - parse_error, - warnings: lint_warnings - .toSorted((a, b) => { - if (a.line === b.line) { - return a.column - b.column - } - return a.line - b.line - }) - .map(({ line, column, endLine, endColumn, text, rule }) => ({ - line, - column, - endLine, - endColumn, - text, - rule - })) - } - - const duration = performance.now() - start - setHeaders({ 'Server-Timing': `lint;dur=${duration.toFixed(1)}` }) - - return json({ - result: return_data, - duration: parseFloat(duration.toFixed(1)), - css: url_css - }) -} diff --git a/vite.config.js b/vite.config.js index e427ae5..1eb7165 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,68 @@ import { sveltekit } from '@sveltejs/kit/vite' import { enhancedImages } from '@sveltejs/enhanced-img' import { Features } from 'lightningcss' +import { fileURLToPath } from 'node:url' + +const stylelint_lib_dir = fileURLToPath(new URL('./node_modules/stylelint/lib/', import.meta.url)) +const stylelint_browser_dir = fileURLToPath(new URL('./src/lib/stylelint-browser/', import.meta.url)) + +// stylelint's package.json `exports` map only publishes `.`, `./package.json` +// and `./lib/utils/*`. src/lib/stylelint-browser/lint.ts deep-imports a few +// unpublished internals on purpose (see that file's header comment) - point +// those specifiers straight at the real files on disk so bundling and +// type-checking (via stylelint-internals.d.ts) both work. +// +// These use `resolve.alias` (not a custom plugin `resolveId` hook) because +// Vite's dependency optimizer pre-bundles reachable node_modules packages +// with esbuild directly, ahead of the normal plugin pipeline, and only +// honours declarative aliases - a custom resolver here would be silently +// skipped during that pre-bundling pass. +// +// Nothing server-side imports real `stylelint`/`node:os`/`node:process` +// anymore (linting is client-only), so these aliases apply everywhere rather +// than being scoped to the client build - simpler, and harmless for SSR +// since this code only ever executes in the browser (see the `browser` guard +// in Linter.svelte) and just needs to *resolve* for the SSR bundle. +const config_alias = [ + // Unpublished internals, aliased to the real files on disk. + { find: 'stylelint/lib/lintPostcssResult.mjs', replacement: stylelint_lib_dir + 'lintPostcssResult.mjs' }, + { + find: 'stylelint/lib/normalizeAllRuleSettings.mjs', + replacement: stylelint_lib_dir + 'normalizeAllRuleSettings.mjs' + }, + { + find: 'stylelint/lib/createPartialStylelintResult.mjs', + replacement: stylelint_lib_dir + 'createPartialStylelintResult.mjs' + }, + { find: 'stylelint/lib/prepareReturnValue.mjs', replacement: stylelint_lib_dir + 'prepareReturnValue.mjs' }, + { + find: 'stylelint/lib/formatters/jsonFormatter.mjs', + replacement: stylelint_lib_dir + 'formatters/jsonFormatter.mjs' + }, + + // lintPostcssResult.mjs's own `./timing.mjs` import drags in the `table` + // package (for `TIMING= stylelint ...` CLI output we never trigger), + // which doesn't bundle for the browser. See shims/stylelint-timing.ts. + { find: './timing.mjs', replacement: stylelint_browser_dir + 'shims/stylelint-timing.ts' }, + + // Node-free stand-ins for bare specifiers stylelint's internals (and our + // plugin package) import. + { find: 'node:os', replacement: stylelint_browser_dir + 'shims/node-os.ts' }, + { find: 'node:process', replacement: stylelint_browser_dir + 'shims/node-process.ts' }, + // `find` must be an exact-match regex here, not the plain string + // shorthand: Vite's aliasing treats a bare string `find` as a *prefix* + // (so it'd also rewrite real subpaths like `stylelint/lib/utils/report.mjs` + // to `.../stylelint-shim.ts/lib/utils/report.mjs`), which those subpaths + // need to keep resolving to the real, publicly-exported package files. + { find: /^stylelint$/, replacement: stylelint_browser_dir + 'stylelint-shim.ts' } +] /** @type {import('vite').UserConfig} */ const config = { plugins: [enhancedImages(), sveltekit()], + resolve: { + alias: config_alias + }, css: { transformer: 'lightningcss', lightningcss: { From da3d8e646426fffc7091488e2e754f5baf152d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:14:01 +0000 Subject: [PATCH 2/2] docs: pin and document stylelint's own transitive deps, drop unused table Added a dedicated pinned-transitive-deps.ts, imported for its side effect from lint.ts, that imports css-tree, @csstools/css-syntax-patches-for-csstree, @csstools/css-tokenizer and fastest-levenshtein - packages stylelint's own internals (getLexer.mjs, assignDisabledRanges.mjs, reportUnknownRuleNames.mjs) import by name but our own code never did directly. Without a real usage site, dependency-usage tooling has no way to tell they're needed rather than stray entries, and pnpm's strict node_modules requires them declared in our own package.json regardless (they're normally hoisted transitively, but only for packages we actually import from). Also drops `table` from dependencies: it was added while investigating why stylelint/lib/timing.mjs doesn't bundle for the browser, but the fix ended up aliasing that whole relative import away (see vite.config.js), so the real `table` package was never reachable to begin with. --- package.json | 1 - pnpm-lock.yaml | 3 -- src/lib/stylelint-browser/lint.ts | 3 ++ .../pinned-transitive-deps.ts | 35 +++++++++++++++++++ .../stylelint-internals.d.ts | 7 ++++ 5 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 src/lib/stylelint-browser/pinned-transitive-deps.ts diff --git a/package.json b/package.json index 5a628ef..36363a6 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "runed": "^0.37.1", "stylelint": "~17.13.0", "svelte": "^5.56.8", - "table": "^6.9.0", "typescript": "^6.0.3", "vite": "8.0.16" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef91c19..152810f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,9 +116,6 @@ importers: svelte: specifier: ^5.56.8 version: 5.56.8 - table: - specifier: ^6.9.0 - version: 6.9.0 typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/src/lib/stylelint-browser/lint.ts b/src/lib/stylelint-browser/lint.ts index 13cf9bd..b49ec3c 100644 --- a/src/lib/stylelint-browser/lint.ts +++ b/src/lib/stylelint-browser/lint.ts @@ -17,6 +17,9 @@ import jsonFormatter from 'stylelint/lib/formatters/jsonFormatter.mjs' // `lib/utils/*` is publicly exported by stylelint, so this one needs no alias. import getLexer from 'stylelint/lib/utils/getLexer.mjs' import type { Config, LinterResult, PostcssResult, Rule } from 'stylelint' +// Pins package.json deps that only stylelint's own internals above import by +// name - see that file's header comment for why this needs to exist at all. +import './pinned-transitive-deps.js' export type LintSource = { /** Cosmetic label only (becomes `result.source`); a file path or URL both work. */ diff --git a/src/lib/stylelint-browser/pinned-transitive-deps.ts b/src/lib/stylelint-browser/pinned-transitive-deps.ts new file mode 100644 index 0000000..a3c7f60 --- /dev/null +++ b/src/lib/stylelint-browser/pinned-transitive-deps.ts @@ -0,0 +1,35 @@ +// pnpm's strict node_modules layout only installs packages declared +// directly in this repo's package.json - it doesn't hoist a dependency's +// own dependencies just because we deep-import files from inside that +// dependency. +// +// ./lint.ts deep-imports internals from `stylelint` itself (aliased to the +// real files on disk in vite.config.js, since they aren't part of +// stylelint's public `exports` map - see lint.ts's header comment). Those +// internals statically import a few packages that stylelint installs for +// its own use, but that our own code never references by name: +// +// - `css-tree` and `@csstools/css-syntax-patches-for-csstree`, used by +// stylelint/lib/utils/getLexer.mjs, which lint.ts calls directly to +// build the `.stylelint.lexer` scaffold field. +// - `@csstools/css-tokenizer`, used by stylelint/lib/assignDisabledRanges.mjs, +// which lintPostcssResult.mjs calls unconditionally on every lint. +// - `fastest-levenshtein`, used by stylelint/lib/reportUnknownRuleNames.mjs, +// statically imported by lintPostcssResult.mjs (only executes on an +// unknown-rule-name error, but still needs to resolve at bundle time). +// +// Without them declared in package.json, pnpm won't install them at the top +// level and the browser bundle fails to resolve (see the "MISSING dep" notes +// in git history for the exact errors). This file's only job is to give +// dependency-usage tools (knip, depcheck, ...) - and future readers - a real, +// traceable usage site instead of a mysteriously "unused" dependency. Follow +// the imports above into node_modules/stylelint/lib to see the actual call +// sites; keep the versions here in sync with stylelint's own `dependencies` +// (node_modules/stylelint/package.json) when bumping the pinned stylelint +// version in package.json. +import { fork } from 'css-tree' +import syntaxPatches from '@csstools/css-syntax-patches-for-csstree' with { type: 'json' } +import { tokenize } from '@csstools/css-tokenizer' +import { distance } from 'fastest-levenshtein' + +export const stylelint_transitive_deps = { fork, syntaxPatches, tokenize, distance } diff --git a/src/lib/stylelint-browser/stylelint-internals.d.ts b/src/lib/stylelint-browser/stylelint-internals.d.ts index 67d7aa9..f540296 100644 --- a/src/lib/stylelint-browser/stylelint-internals.d.ts +++ b/src/lib/stylelint-browser/stylelint-internals.d.ts @@ -78,3 +78,10 @@ declare module 'stylelint/lib/utils/validateOptions.mjs' { declare module 'stylelint/lib/utils/checkAgainstRule.mjs' { export default function checkAgainstRule(options: unknown, callback: (...args: unknown[]) => void): Promise } + +// `css-tree` (a stylelint dependency, pinned in pinned-transitive-deps.ts) +// ships no types and has no `@types/css-tree` we've pulled in - only `fork` +// is referenced, so that's all that's declared here. +declare module 'css-tree' { + export function fork(config: unknown): unknown +}