Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -36,29 +38,31 @@
"@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",
"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"
Expand Down
21 changes: 18 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 37 additions & 13 deletions src/lib/components/Linter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -28,7 +33,6 @@
}
duration: number
css?: string
rules?: Record<string, unknown>
}

type Props = {
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions src/lib/stylelint-browser/lint.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
121 changes: 121 additions & 0 deletions src/lib/stylelint-browser/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// 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'
// 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. */
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<string, Rule> {
const plugin_functions: Record<string, Rule> = {}
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<Config> {
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<LinterResult> {
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: '/' })
}
35 changes: 35 additions & 0 deletions src/lib/stylelint-browser/pinned-transitive-deps.ts
Original file line number Diff line number Diff line change
@@ -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 }
17 changes: 17 additions & 0 deletions src/lib/stylelint-browser/presets.ts
Original file line number Diff line number Diff line change
@@ -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<Preset, NonNullable<Config['rules']> | undefined> = {
recommended: recommendedConfig.rules,
performance: performanceConfig.rules,
maintainability: maintainabilityConfig.rules,
correctness: correctnessConfig.rules,
designtokens: designTokensConfig.rules,
holistic: holisticConfig.rules
}
4 changes: 4 additions & 0 deletions src/lib/stylelint-browser/shims/node-os.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading