@dorsk/tsumikit 0.81.0 → 0.82.1
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/README.md +180 -2
- package/dist/breakpoints.d.ts +15 -0
- package/dist/breakpoints.js +14 -0
- package/dist/components/atoms/Badge.svelte +1 -0
- package/dist/components/atoms/Button.svelte +16 -5
- package/dist/components/atoms/Button.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +1 -0
- package/dist/components/atoms/Slider.svelte +1 -1
- package/dist/components/atoms/Text.svelte +4 -4
- package/dist/components/atoms/Text.svelte.d.ts +1 -1
- package/dist/components/layouts/AppShell.svelte +22 -3
- package/dist/components/layouts/AppShell.svelte.d.ts +3 -2
- package/dist/components/layouts/Backdrop.svelte +152 -0
- package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
- package/dist/components/layouts/NavSection.svelte +1 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -1
- package/dist/components/molecules/CapBar.svelte +1 -1
- package/dist/components/molecules/Composer.svelte +1 -1
- package/dist/components/molecules/Dropzone.svelte +1 -1
- package/dist/components/molecules/EmptyState.svelte +13 -6
- package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
- package/dist/components/molecules/Field.svelte +1 -1
- package/dist/components/molecules/FilterInput.svelte +5 -3
- package/dist/components/molecules/Meter.svelte +1 -1
- package/dist/components/molecules/Metric.svelte +3 -1
- package/dist/components/molecules/Modal.svelte +8 -5
- package/dist/components/molecules/Pagination.svelte +26 -2
- package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
- package/dist/components/molecules/Prose.svelte +1 -0
- package/dist/components/molecules/RadioGroup.svelte +2 -2
- package/dist/components/molecules/RichSelect.svelte +8 -1
- package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
- package/dist/components/molecules/SectionHeader.svelte +1 -1
- package/dist/components/molecules/SegmentedControl.svelte +55 -22
- package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
- package/dist/components/molecules/SortableList.svelte +1 -1
- package/dist/components/molecules/StatusBar.svelte +2 -2
- package/dist/components/molecules/Tabs.svelte +32 -0
- package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
- package/dist/components/molecules/ThemePicker.svelte +3 -3
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
- package/dist/components/organisms/DataTable.svelte +2 -2
- package/dist/dev-audit.d.ts +19 -0
- package/dist/dev-audit.js +259 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/media.svelte.d.ts +8 -0
- package/dist/media.svelte.js +26 -0
- package/dist/pending.svelte.d.ts +16 -0
- package/dist/pending.svelte.js +75 -0
- package/dist/stores/toast.svelte.d.ts +22 -0
- package/dist/stores/toast.svelte.js +54 -2
- package/dist/styles/app.css +2 -1
- package/dist/styles/font.css +19 -0
- package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
- package/dist/styles/fonts/inter.woff2 +0 -0
- package/dist/styles/themes.css +58 -58
- package/dist/styles/tokens.css +15 -9
- package/dist/url-state.svelte.d.ts +22 -0
- package/dist/url-state.svelte.js +64 -0
- package/lint/breakpoints.mjs +33 -0
- package/lint/cli.mjs +99 -0
- package/lint/context.mjs +242 -0
- package/lint/index.mjs +97 -0
- package/lint/rules/breakpoints.mjs +25 -0
- package/lint/rules/img-alt.mjs +13 -0
- package/lint/rules/motion.mjs +85 -0
- package/lint/rules/no-raw-color.mjs +59 -0
- package/lint/rules/no-raw-control.mjs +23 -0
- package/lint/rules/no-raw-font.mjs +64 -0
- package/lint/rules/one-primary.mjs +74 -0
- package/lint/rules/spacing-scale.mjs +66 -0
- package/package.json +14 -3
- package/principles/01-visibility-of-status.md +32 -0
- package/principles/02-one-primary-action.md +31 -0
- package/principles/03-emphasise-by-de-emphasising.md +31 -0
- package/principles/04-labels-last-resort.md +30 -0
- package/principles/05-tint-the-greys.md +30 -0
- package/principles/06-depth-from-shade.md +30 -0
- package/principles/07-one-accent.md +31 -0
- package/principles/08-spacing-scale.md +30 -0
- package/principles/09-group-with-space.md +31 -0
- package/principles/10-real-typeface-fixed-scale.md +33 -0
- package/principles/11-mono-only-for-code.md +30 -0
- package/principles/12-measure.md +29 -0
- package/principles/13-imagery-sets-the-mood.md +32 -0
- package/principles/14-instant-calm-feedback.md +32 -0
- package/principles/15-undo-over-confirm.md +30 -0
- package/principles/16-empty-states.md +31 -0
- package/principles/17-errors-in-plain-words.md +31 -0
- package/principles/18-match-the-real-world.md +31 -0
- package/principles/19-consistency-and-standards.md +32 -0
- package/principles/20-error-prevention.md +31 -0
- package/principles/21-recognition-over-recall.md +31 -0
- package/principles/22-flexibility-and-shortcuts.md +31 -0
- package/principles/23-url-is-state.md +31 -0
- package/principles/24-links-are-links.md +31 -0
- package/principles/25-visible-focus.md +32 -0
- package/principles/26-generous-targets.md +31 -0
- package/principles/27-cheap-motion-respect-preferences.md +32 -0
- package/principles/28-less-but-better.md +31 -0
- package/principles/29-colour-is-not-the-only-cue.md +31 -0
- package/principles/30-help-where-needed.md +31 -0
- package/principles/README.md +62 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's breakpoint contract. Media queries in consumer apps should only use
|
|
3
|
+
* these widths (min-width form) or their max-width complements (one unit below,
|
|
4
|
+
* e.g. 39.999rem / 639px / 639.98px).
|
|
5
|
+
*/
|
|
6
|
+
export const BREAKPOINTS = [
|
|
7
|
+
{ name: 'sm', rem: 40, px: 640 },
|
|
8
|
+
{ name: 'md', rem: 48, px: 768 },
|
|
9
|
+
{ name: 'lg', rem: 64, px: 1024 }
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const LENGTH = /^(-?\d*\.?\d+)(px|rem|em)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when a media-query width token (e.g. "640px", "47.999rem") is one of the
|
|
16
|
+
* kit's breakpoints or a max-width complement of one. `em` in a media query
|
|
17
|
+
* resolves against the root font size, so it is treated like `rem`.
|
|
18
|
+
* @param {string} token
|
|
19
|
+
*/
|
|
20
|
+
export function isAllowedBreakpointWidth(token) {
|
|
21
|
+
const m = LENGTH.exec(token.trim());
|
|
22
|
+
if (!m) return false;
|
|
23
|
+
const value = Number(m[1]);
|
|
24
|
+
const key = m[2] === 'px' ? 'px' : 'rem';
|
|
25
|
+
return BREAKPOINTS.some((bp) => {
|
|
26
|
+
const target = bp[key];
|
|
27
|
+
return value === target || (value >= target - 1 && value < target);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function describeBreakpoints() {
|
|
32
|
+
return BREAKPOINTS.map((bp) => `${bp.rem}rem/${bp.px}px`).join(', ');
|
|
33
|
+
}
|
package/lint/cli.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { RULES, lintPaths, selectRules } from './index.mjs';
|
|
4
|
+
|
|
5
|
+
const HELP = `tsumikit-lint [options] [paths...]
|
|
6
|
+
|
|
7
|
+
Flags drift from the kit's design rules in .svelte and .css files.
|
|
8
|
+
Defaults to ./src when no path is given.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--json machine-readable output
|
|
12
|
+
--rule <id> run only this rule (repeatable, comma-separated)
|
|
13
|
+
--disable <id> skip this rule (repeatable, comma-separated)
|
|
14
|
+
--list-rules print the rule ids and exit
|
|
15
|
+
-h, --help show this help
|
|
16
|
+
|
|
17
|
+
Suppress a line with a comment on the line above:
|
|
18
|
+
/* tsumikit-lint-ignore spacing-scale */ (css)
|
|
19
|
+
<!-- tsumikit-lint-ignore no-raw-control --> (markup)
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
${RULES.map((r) => ` ${r.id.padEnd(16)} ${r.description}`).join('\n')}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const opts = { json: false, only: [], disable: [], paths: [] };
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const arg = argv[i];
|
|
29
|
+
const next = () => {
|
|
30
|
+
if (i + 1 >= argv.length) throw new Error(`${arg} needs a value`);
|
|
31
|
+
return argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
|
|
32
|
+
};
|
|
33
|
+
if (arg === '--json') opts.json = true;
|
|
34
|
+
else if (arg === '--rule') opts.only.push(...next());
|
|
35
|
+
else if (arg.startsWith('--rule=')) opts.only.push(...arg.slice(7).split(','));
|
|
36
|
+
else if (arg === '--disable') opts.disable.push(...next());
|
|
37
|
+
else if (arg.startsWith('--disable=')) opts.disable.push(...arg.slice(10).split(','));
|
|
38
|
+
else if (arg === '--list-rules') opts.list = true;
|
|
39
|
+
else if (arg === '-h' || arg === '--help') opts.help = true;
|
|
40
|
+
else if (arg.startsWith('-')) throw new Error(`unknown option ${arg}`);
|
|
41
|
+
else opts.paths.push(arg);
|
|
42
|
+
}
|
|
43
|
+
return opts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatText({ files, findings }) {
|
|
47
|
+
const lines = findings.map(
|
|
48
|
+
(f) => `${f.file}:${f.line}:${f.column} ${f.rule.padEnd(16)} ${f.message}`
|
|
49
|
+
);
|
|
50
|
+
const byRule = new Map();
|
|
51
|
+
for (const f of findings) byRule.set(f.rule, (byRule.get(f.rule) ?? 0) + 1);
|
|
52
|
+
const summary = findings.length
|
|
53
|
+
? `${findings.length} problem${findings.length === 1 ? '' : 's'} in ${files} file${files === 1 ? '' : 's'} (${[...byRule].map(([r, n]) => `${r}: ${n}`).join(', ')})`
|
|
54
|
+
: `no problems in ${files} file${files === 1 ? '' : 's'}`;
|
|
55
|
+
return [...lines, '', summary].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
let opts;
|
|
60
|
+
try {
|
|
61
|
+
opts = parseArgs(process.argv.slice(2));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
process.stderr.write(`tsumikit-lint: ${e.message}\n\n${HELP}`);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
if (opts.help) {
|
|
67
|
+
process.stdout.write(HELP);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
if (opts.list) {
|
|
71
|
+
process.stdout.write(`${RULES.map((r) => r.id).join('\n')}\n`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
let rules;
|
|
75
|
+
try {
|
|
76
|
+
rules = selectRules({ only: opts.only, disable: opts.disable });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
process.stderr.write(`tsumikit-lint: ${e.message}\n`);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const paths = opts.paths.length ? opts.paths : existsSync('src') ? ['src'] : ['.'];
|
|
82
|
+
for (const p of paths) {
|
|
83
|
+
if (!existsSync(p)) {
|
|
84
|
+
process.stderr.write(`tsumikit-lint: no such path ${p}\n`);
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const result = await lintPaths(paths, { rules });
|
|
89
|
+
if (opts.json) {
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
`${JSON.stringify({ files: result.files, errorCount: result.findings.length, findings: result.findings }, null, 2)}\n`
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
process.stdout.write(`${formatText(result)}\n`);
|
|
95
|
+
}
|
|
96
|
+
return result.findings.length ? 1 : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
process.exitCode = await main();
|
package/lint/context.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { parse } from 'svelte/compiler';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('svelte/compiler').AST.Fragment} Fragment
|
|
5
|
+
* @typedef {import('svelte/compiler').AST.ElementLike} ElementLike
|
|
6
|
+
* @typedef {import('svelte/compiler').AST.Attribute} Attribute
|
|
7
|
+
* @typedef {import('svelte/compiler').AST.StyleDirective} StyleDirective
|
|
8
|
+
* @typedef {Fragment['nodes'][number]} MarkupNode
|
|
9
|
+
* @typedef {import('svelte/compiler').AST.CSS.Declaration} Declaration
|
|
10
|
+
* @typedef {import('svelte/compiler').AST.CSS.Atrule} Atrule
|
|
11
|
+
* @typedef {import('svelte/compiler').AST.CSS.Rule} CssRule
|
|
12
|
+
* @typedef {{ property: string, value: string, start: number }} DeclarationLike
|
|
13
|
+
* @typedef {{ file: string, line: number, column: number, rule: string, message: string }} Finding
|
|
14
|
+
* @typedef {ReturnType<typeof createContext>} Context
|
|
15
|
+
* @typedef {{ id: string, description: string, check: (ctx: Context) => void }} LintRule
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const IGNORE_RE = /tsumikit-lint-ignore(?:\s+([\w-]+(?:[\s,]+[\w-]+)*))?/g;
|
|
19
|
+
|
|
20
|
+
/** @param {string} source */
|
|
21
|
+
function lineStarts(source) {
|
|
22
|
+
const starts = [0];
|
|
23
|
+
for (let i = 0; i < source.length; i++) {
|
|
24
|
+
if (source.charCodeAt(i) === 10) starts.push(i + 1);
|
|
25
|
+
}
|
|
26
|
+
return starts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {number[]} starts @param {number} offset */
|
|
30
|
+
function positionOf(starts, offset) {
|
|
31
|
+
let lo = 0;
|
|
32
|
+
let hi = starts.length - 1;
|
|
33
|
+
while (lo < hi) {
|
|
34
|
+
const mid = (lo + hi + 1) >> 1;
|
|
35
|
+
if (starts[mid] <= offset) lo = mid;
|
|
36
|
+
else hi = mid - 1;
|
|
37
|
+
}
|
|
38
|
+
return { line: lo + 1, column: offset - starts[lo] + 1 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Collects `tsumikit-lint-ignore [rule ...]` comments. A comment suppresses the
|
|
43
|
+
* named rules (all rules when none is given) on the following line, and on its
|
|
44
|
+
* own line when code precedes it.
|
|
45
|
+
* @param {string} source
|
|
46
|
+
* @param {number[]} starts
|
|
47
|
+
*/
|
|
48
|
+
function collectIgnores(source, starts) {
|
|
49
|
+
/** @type {Map<number, 'all' | Set<string>>} */
|
|
50
|
+
const ignores = new Map();
|
|
51
|
+
/** @param {number} line @param {'all' | string[]} rules */
|
|
52
|
+
const add = (line, rules) => {
|
|
53
|
+
const existing = ignores.get(line);
|
|
54
|
+
if (existing === 'all') return;
|
|
55
|
+
if (rules === 'all') ignores.set(line, 'all');
|
|
56
|
+
else ignores.set(line, new Set([...(existing ?? []), ...rules]));
|
|
57
|
+
};
|
|
58
|
+
for (const m of source.matchAll(IGNORE_RE)) {
|
|
59
|
+
const { line } = positionOf(starts, m.index);
|
|
60
|
+
const rules = m[1] ? m[1].split(/[\s,]+/).filter(Boolean) : 'all';
|
|
61
|
+
const lineText = source.slice(starts[line - 1], m.index);
|
|
62
|
+
if (/\S/.test(lineText.replace(/(\/\*|<!--|\/\/)\s*$/, ''))) add(line, rules);
|
|
63
|
+
add(line + 1, rules);
|
|
64
|
+
}
|
|
65
|
+
return ignores;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parses a .svelte or .css source into a lint context. CSS is wrapped in a
|
|
70
|
+
* <style> element so the Svelte CSS parser can be reused; offsets are shifted
|
|
71
|
+
* back when reporting.
|
|
72
|
+
* @param {string} file
|
|
73
|
+
* @param {string} source
|
|
74
|
+
*/
|
|
75
|
+
export function createContext(file, source) {
|
|
76
|
+
const isCss = /\.css$/i.test(file);
|
|
77
|
+
const shift = isCss ? '<style>'.length : 0;
|
|
78
|
+
const parsed = parse(isCss ? `<style>${source}</style>` : source, { modern: true });
|
|
79
|
+
const starts = lineStarts(source);
|
|
80
|
+
const ignores = collectIgnores(source, starts);
|
|
81
|
+
/** @type {Finding[]} */
|
|
82
|
+
const findings = [];
|
|
83
|
+
const parseSource = isCss ? `<style>${source}</style>` : source;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
file,
|
|
87
|
+
source,
|
|
88
|
+
isCss,
|
|
89
|
+
css: parsed.css,
|
|
90
|
+
fragment: isCss ? null : parsed.fragment,
|
|
91
|
+
/** @param {{ start: number, end: number }} node */
|
|
92
|
+
text(node) {
|
|
93
|
+
return parseSource.slice(node.start, node.end);
|
|
94
|
+
},
|
|
95
|
+
/** @param {number} offset */
|
|
96
|
+
loc(offset) {
|
|
97
|
+
return positionOf(starts, Math.max(0, offset - shift));
|
|
98
|
+
},
|
|
99
|
+
/** @param {string} rule @param {number} offset @param {string} message */
|
|
100
|
+
report(rule, offset, message) {
|
|
101
|
+
const { line, column } = this.loc(offset);
|
|
102
|
+
const ignored = ignores.get(line);
|
|
103
|
+
if (ignored === 'all' || ignored?.has(rule)) return;
|
|
104
|
+
findings.push({ file, line, column, rule, message });
|
|
105
|
+
},
|
|
106
|
+
findings
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Visits every CSS declaration with its enclosing selectors and at-rules.
|
|
112
|
+
* `cb(declaration, { selectors, atrules })` where `selectors` are the selector
|
|
113
|
+
* texts of enclosing rules (outermost first) and `atrules` the enclosing
|
|
114
|
+
* at-rule nodes.
|
|
115
|
+
* @param {Context} ctx
|
|
116
|
+
* @param {(declaration: Declaration, scope: { selectors: string[], atrules: Atrule[] }) => void} cb
|
|
117
|
+
*/
|
|
118
|
+
export function walkDeclarations(ctx, cb) {
|
|
119
|
+
if (!ctx.css) return;
|
|
120
|
+
/**
|
|
121
|
+
* @param {Array<Declaration | CssRule | Atrule>} nodes
|
|
122
|
+
* @param {string[]} selectors
|
|
123
|
+
* @param {Atrule[]} atrules
|
|
124
|
+
*/
|
|
125
|
+
const visit = (nodes, selectors, atrules) => {
|
|
126
|
+
for (const node of nodes) {
|
|
127
|
+
if (node.type === 'Declaration') cb(node, { selectors, atrules });
|
|
128
|
+
else if (node.type === 'Rule') {
|
|
129
|
+
visit(node.block.children, [...selectors, ctx.text(node.prelude)], atrules);
|
|
130
|
+
} else if (node.type === 'Atrule' && node.block) {
|
|
131
|
+
visit(node.block.children, selectors, [...atrules, node]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
visit(ctx.css.children, [], []);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {Context} ctx @param {(node: Atrule) => void} cb */
|
|
139
|
+
export function walkAtrules(ctx, cb) {
|
|
140
|
+
if (!ctx.css) return;
|
|
141
|
+
/** @param {Array<Declaration | CssRule | Atrule>} nodes */
|
|
142
|
+
const visit = (nodes) => {
|
|
143
|
+
for (const node of nodes) {
|
|
144
|
+
if (node.type === 'Atrule') {
|
|
145
|
+
cb(node);
|
|
146
|
+
if (node.block) visit(node.block.children);
|
|
147
|
+
} else if (node.type === 'Rule') visit(node.block.children);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
visit(ctx.css.children);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const CHILD_KEYS = ['fragment', 'consequent', 'alternate', 'body', 'fallback', 'pending', 'then', 'catch'];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Visits every markup node (elements, components, blocks) depth-first.
|
|
157
|
+
* @param {Context} ctx
|
|
158
|
+
* @param {(node: MarkupNode) => void} cb
|
|
159
|
+
*/
|
|
160
|
+
export function walkMarkup(ctx, cb) {
|
|
161
|
+
if (!ctx.fragment) return;
|
|
162
|
+
/** @param {MarkupNode | Fragment | null | undefined} node */
|
|
163
|
+
const visit = (node) => {
|
|
164
|
+
if (!node || typeof node !== 'object') return;
|
|
165
|
+
if (node.type === 'Fragment') {
|
|
166
|
+
for (const child of node.nodes) visit(child);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
cb(node);
|
|
170
|
+
const children = /** @type {Partial<Record<string, Fragment | null>>} */ (/** @type {unknown} */ (node));
|
|
171
|
+
for (const key of CHILD_KEYS) {
|
|
172
|
+
if (children[key]) visit(children[key]);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
visit(ctx.fragment);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Joined text of a static attribute value, or null when any part is dynamic.
|
|
180
|
+
* @param {Attribute['value'] | StyleDirective['value']} value
|
|
181
|
+
*/
|
|
182
|
+
function staticText(value) {
|
|
183
|
+
if (value === true) return '';
|
|
184
|
+
let out = '';
|
|
185
|
+
for (const v of Array.isArray(value) ? value : [value]) {
|
|
186
|
+
if (v.type !== 'Text') return null;
|
|
187
|
+
out += v.data;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Static text of an attribute, or null when it is dynamic or absent.
|
|
194
|
+
* @param {ElementLike} node
|
|
195
|
+
* @param {string} name
|
|
196
|
+
*/
|
|
197
|
+
export function staticAttribute(node, name) {
|
|
198
|
+
const attr = node.attributes.find((a) => a.type === 'Attribute' && a.name === name);
|
|
199
|
+
if (!attr || attr.type !== 'Attribute') return null;
|
|
200
|
+
return staticText(attr.value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @param {ElementLike} node @param {string} name */
|
|
204
|
+
export function hasAttribute(node, name) {
|
|
205
|
+
return node.attributes.some((a) => a.type === 'Attribute' && a.name === name);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param {ElementLike} node */
|
|
209
|
+
export function hasSpread(node) {
|
|
210
|
+
return node.attributes.some((a) => a.type === 'SpreadAttribute');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Declarations written inline on an element (`style="…"` and `style:prop`
|
|
215
|
+
* directives) with static values.
|
|
216
|
+
* @param {ElementLike} node
|
|
217
|
+
* @returns {DeclarationLike[]}
|
|
218
|
+
*/
|
|
219
|
+
export function inlineDeclarations(node) {
|
|
220
|
+
/** @type {DeclarationLike[]} */
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const attr of node.attributes) {
|
|
223
|
+
if (attr.type === 'Attribute' && attr.name === 'style') {
|
|
224
|
+
const text = staticText(attr.value);
|
|
225
|
+
if (text == null) continue;
|
|
226
|
+
for (const chunk of text.split(';')) {
|
|
227
|
+
const i = chunk.indexOf(':');
|
|
228
|
+
if (i < 0) continue;
|
|
229
|
+
out.push({
|
|
230
|
+
property: chunk.slice(0, i).trim(),
|
|
231
|
+
value: chunk.slice(i + 1).trim(),
|
|
232
|
+
start: attr.start
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} else if (attr.type === 'StyleDirective' && attr.value !== true) {
|
|
236
|
+
const value = staticText(attr.value);
|
|
237
|
+
if (value == null) continue;
|
|
238
|
+
out.push({ property: attr.name, value, start: attr.start });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
package/lint/index.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { createContext } from './context.mjs';
|
|
4
|
+
import breakpoints from './rules/breakpoints.mjs';
|
|
5
|
+
import imgAlt from './rules/img-alt.mjs';
|
|
6
|
+
import motion from './rules/motion.mjs';
|
|
7
|
+
import noRawColor from './rules/no-raw-color.mjs';
|
|
8
|
+
import noRawControl from './rules/no-raw-control.mjs';
|
|
9
|
+
import noRawFont from './rules/no-raw-font.mjs';
|
|
10
|
+
import onePrimary from './rules/one-primary.mjs';
|
|
11
|
+
import spacingScale from './rules/spacing-scale.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {import('./context.mjs').Finding} Finding
|
|
15
|
+
* @typedef {import('./context.mjs').LintRule} LintRule
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { BREAKPOINTS, isAllowedBreakpointWidth } from './breakpoints.mjs';
|
|
19
|
+
|
|
20
|
+
/** @type {LintRule[]} */
|
|
21
|
+
export const RULES = [
|
|
22
|
+
noRawColor,
|
|
23
|
+
spacingScale,
|
|
24
|
+
noRawControl,
|
|
25
|
+
onePrimary,
|
|
26
|
+
noRawFont,
|
|
27
|
+
breakpoints,
|
|
28
|
+
motion,
|
|
29
|
+
imgAlt
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const RULE_IDS = RULES.map((r) => r.id);
|
|
33
|
+
|
|
34
|
+
const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', 'dist', 'build', '.git', 'coverage']);
|
|
35
|
+
|
|
36
|
+
/** @param {{ only?: string[], disable?: string[] }} [options] */
|
|
37
|
+
export function selectRules({ only = [], disable = [] } = {}) {
|
|
38
|
+
const unknown = [...only, ...disable].filter((id) => !RULE_IDS.includes(id));
|
|
39
|
+
if (unknown.length) throw new Error(`unknown rule(s): ${unknown.join(', ')}`);
|
|
40
|
+
return RULES.filter((r) => (only.length ? only.includes(r.id) : true) && !disable.includes(r.id));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lints one source string; returns `{ findings, error }`.
|
|
45
|
+
* @param {string} file
|
|
46
|
+
* @param {string} source
|
|
47
|
+
* @param {LintRule[]} [rules]
|
|
48
|
+
*/
|
|
49
|
+
export function lintSource(file, source, rules = RULES) {
|
|
50
|
+
let ctx;
|
|
51
|
+
try {
|
|
52
|
+
ctx = createContext(file, source);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const e = /** @type {import('svelte/compiler').CompileError} */ (err);
|
|
55
|
+
return {
|
|
56
|
+
findings: [{ file, line: e.start?.line ?? 1, column: e.start?.column ?? 1, rule: 'parse-error', message: e.message }],
|
|
57
|
+
error: e
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
for (const rule of rules) rule.check(ctx);
|
|
61
|
+
ctx.findings.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
62
|
+
return { findings: ctx.findings, error: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {string[]} paths */
|
|
66
|
+
export async function collectFiles(paths) {
|
|
67
|
+
/** @type {string[]} */
|
|
68
|
+
const files = [];
|
|
69
|
+
/** @param {string} path */
|
|
70
|
+
const visit = async (path) => {
|
|
71
|
+
const info = await stat(path);
|
|
72
|
+
if (info.isDirectory()) {
|
|
73
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
74
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
|
75
|
+
await visit(join(path, entry.name));
|
|
76
|
+
}
|
|
77
|
+
} else if (/\.(svelte|css)$/i.test(path)) files.push(path);
|
|
78
|
+
};
|
|
79
|
+
for (const p of paths) await visit(p);
|
|
80
|
+
return files.sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string[]} paths
|
|
85
|
+
* @param {{ rules?: LintRule[], cwd?: string }} [options]
|
|
86
|
+
*/
|
|
87
|
+
export async function lintPaths(paths, { rules = RULES, cwd = process.cwd() } = {}) {
|
|
88
|
+
const files = await collectFiles(paths);
|
|
89
|
+
/** @type {Finding[]} */
|
|
90
|
+
const findings = [];
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const source = await readFile(file, 'utf8');
|
|
93
|
+
const result = lintSource(relative(cwd, file) || file, source, rules);
|
|
94
|
+
findings.push(...result.findings);
|
|
95
|
+
}
|
|
96
|
+
return { files: files.length, findings };
|
|
97
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describeBreakpoints, isAllowedBreakpointWidth } from '../breakpoints.mjs';
|
|
2
|
+
import { walkAtrules } from '../context.mjs';
|
|
3
|
+
|
|
4
|
+
const FEATURE = /\(([^()]*width[^()]*)\)/gi;
|
|
5
|
+
const LENGTH = /-?\d*\.?\d+(?:px|rem|em)\b/g;
|
|
6
|
+
|
|
7
|
+
export default /** @type {import('../context.mjs').LintRule} */ ({
|
|
8
|
+
id: 'breakpoints',
|
|
9
|
+
description: `media-query widths outside the kit's breakpoints (${describeBreakpoints()})`,
|
|
10
|
+
check(ctx) {
|
|
11
|
+
walkAtrules(ctx, (node) => {
|
|
12
|
+
if (node.name !== 'media' || !node.prelude) return;
|
|
13
|
+
for (const feature of node.prelude.matchAll(FEATURE)) {
|
|
14
|
+
for (const width of feature[1].match(LENGTH) ?? []) {
|
|
15
|
+
if (isAllowedBreakpointWidth(width)) continue;
|
|
16
|
+
ctx.report(
|
|
17
|
+
'breakpoints',
|
|
18
|
+
node.start,
|
|
19
|
+
`media width ${width} is not a kit breakpoint; use ${describeBreakpoints()} (or the max-width form one unit below)`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { hasAttribute, hasSpread, walkMarkup } from '../context.mjs';
|
|
2
|
+
|
|
3
|
+
export default /** @type {import('../context.mjs').LintRule} */ ({
|
|
4
|
+
id: 'img-alt',
|
|
5
|
+
description: '<img> without alt',
|
|
6
|
+
check(ctx) {
|
|
7
|
+
walkMarkup(ctx, (node) => {
|
|
8
|
+
if (node.type !== 'RegularElement' || node.name !== 'img') return;
|
|
9
|
+
if (hasAttribute(node, 'alt') || hasSpread(node)) return;
|
|
10
|
+
ctx.report('img-alt', node.start, '<img> without alt; add alt text, or alt="" when decorative');
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { walkDeclarations } from '../context.mjs';
|
|
2
|
+
|
|
3
|
+
export const ANIMATABLE = new Set([
|
|
4
|
+
'transform',
|
|
5
|
+
'translate',
|
|
6
|
+
'scale',
|
|
7
|
+
'rotate',
|
|
8
|
+
'opacity',
|
|
9
|
+
'color',
|
|
10
|
+
'background-color',
|
|
11
|
+
'background',
|
|
12
|
+
'border-color',
|
|
13
|
+
'outline-color',
|
|
14
|
+
'text-decoration-color',
|
|
15
|
+
'fill',
|
|
16
|
+
'stroke',
|
|
17
|
+
'box-shadow',
|
|
18
|
+
'filter',
|
|
19
|
+
'backdrop-filter',
|
|
20
|
+
'visibility',
|
|
21
|
+
'display',
|
|
22
|
+
'overlay',
|
|
23
|
+
'none'
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const TIMING = /^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end|steps|cubic-bezier|var|allow-discrete|normal)$|^-?\d*\.?\d+m?s$|^-?\d*\.?\d+$/;
|
|
27
|
+
|
|
28
|
+
/** @param {string} value */
|
|
29
|
+
function splitTopLevel(value) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
let depth = 0;
|
|
32
|
+
let current = '';
|
|
33
|
+
for (const ch of value) {
|
|
34
|
+
if (ch === '(') depth++;
|
|
35
|
+
if (ch === ')') depth--;
|
|
36
|
+
if (ch === ',' && depth === 0) {
|
|
37
|
+
parts.push(current);
|
|
38
|
+
current = '';
|
|
39
|
+
} else current += ch;
|
|
40
|
+
}
|
|
41
|
+
parts.push(current);
|
|
42
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} value */
|
|
46
|
+
export function transitionedProperties(value) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const item of splitTopLevel(value)) {
|
|
49
|
+
for (const word of item.split(/\s+/)) {
|
|
50
|
+
const name = word.replace(/\(.*$/, '');
|
|
51
|
+
if (TIMING.test(name) || word.startsWith('var(')) continue;
|
|
52
|
+
out.push(word.toLowerCase());
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default /** @type {import('../context.mjs').LintRule} */ ({
|
|
60
|
+
id: 'motion',
|
|
61
|
+
description: 'transition/animation on properties other than transform/opacity/color/background-color/box-shadow/filter',
|
|
62
|
+
check(ctx) {
|
|
63
|
+
walkDeclarations(ctx, (d, { atrules }) => {
|
|
64
|
+
if (d.property === 'transition' || d.property === 'transition-property') {
|
|
65
|
+
for (const prop of transitionedProperties(d.value)) {
|
|
66
|
+
if (ANIMATABLE.has(prop) || prop.startsWith('--')) continue;
|
|
67
|
+
ctx.report(
|
|
68
|
+
'motion',
|
|
69
|
+
d.start,
|
|
70
|
+
prop === 'all'
|
|
71
|
+
? 'transition: all animates layout; list transform/opacity/color/background-color/box-shadow/filter explicitly'
|
|
72
|
+
: `transition on "${prop}" animates layout; animate only transform/opacity/color/background-color/box-shadow/filter`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (atrules.some((a) => a.name === 'keyframes') && !ANIMATABLE.has(d.property) && !/^(--|animation-)/.test(d.property)) {
|
|
77
|
+
ctx.report(
|
|
78
|
+
'motion',
|
|
79
|
+
d.start,
|
|
80
|
+
`keyframe animates "${d.property}"; animate only transform/opacity/color/background-color/box-shadow/filter`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { inlineDeclarations, walkDeclarations, walkMarkup } from '../context.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../context.mjs').Context} Context
|
|
5
|
+
* @typedef {import('../context.mjs').DeclarationLike} DeclarationLike
|
|
6
|
+
* @typedef {import('../context.mjs').Atrule} Atrule
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const THEME_SELECTOR = /:root|:host|\[data-theme|\[data-look|@theme/;
|
|
10
|
+
const HEX = /#[0-9a-f]{3,8}\b/i;
|
|
11
|
+
const FUNCTIONS = /\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(([^()]*(?:\([^()]*\)[^()]*)*)\)/gi;
|
|
12
|
+
|
|
13
|
+
/** @param {string} value */
|
|
14
|
+
function stripUrls(value) {
|
|
15
|
+
return value.replace(/url\((?:[^)(]|\([^)(]*\))*\)/gi, 'url()');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {string} value */
|
|
19
|
+
export function rawColorIn(value) {
|
|
20
|
+
const text = stripUrls(value);
|
|
21
|
+
const hex = HEX.exec(text);
|
|
22
|
+
if (hex) return hex[0];
|
|
23
|
+
for (const m of text.matchAll(FUNCTIONS)) {
|
|
24
|
+
if (!/var\(|\bfrom\b/.test(m[2])) return m[0];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Context} ctx
|
|
31
|
+
* @param {DeclarationLike} declaration
|
|
32
|
+
* @param {string[]} selectors
|
|
33
|
+
* @param {Atrule[]} atrules
|
|
34
|
+
*/
|
|
35
|
+
function check(ctx, declaration, selectors, atrules) {
|
|
36
|
+
if (declaration.property.startsWith('--')) return;
|
|
37
|
+
if (selectors.some((s) => THEME_SELECTOR.test(s))) return;
|
|
38
|
+
if (atrules.some((a) => a.name === 'property')) return;
|
|
39
|
+
const hit = rawColorIn(declaration.value);
|
|
40
|
+
if (hit) {
|
|
41
|
+
ctx.report(
|
|
42
|
+
'no-raw-color',
|
|
43
|
+
declaration.start,
|
|
44
|
+
`raw color ${hit} in "${declaration.property}"; use a theme token (var(--…)) or define it in a theme block`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default /** @type {import('../context.mjs').LintRule} */ ({
|
|
50
|
+
id: 'no-raw-color',
|
|
51
|
+
description: 'hex/rgb/hsl literals outside a theme block',
|
|
52
|
+
check(ctx) {
|
|
53
|
+
walkDeclarations(ctx, (d, { selectors, atrules }) => check(ctx, d, selectors, atrules));
|
|
54
|
+
walkMarkup(ctx, (node) => {
|
|
55
|
+
if (node.type !== 'RegularElement' && node.type !== 'Component') return;
|
|
56
|
+
for (const d of inlineDeclarations(node)) check(ctx, d, [], []);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { staticAttribute, walkMarkup } from '../context.mjs';
|
|
2
|
+
|
|
3
|
+
/** @type {Partial<Record<string, string>>} */
|
|
4
|
+
const CONTROLS = {
|
|
5
|
+
button: 'Button',
|
|
6
|
+
input: 'Input / Checkbox / Switch / Radio',
|
|
7
|
+
select: 'Select',
|
|
8
|
+
textarea: 'Textarea'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default /** @type {import('../context.mjs').LintRule} */ ({
|
|
12
|
+
id: 'no-raw-control',
|
|
13
|
+
description: 'raw <button>/<input>/<select>/<textarea> instead of the atoms',
|
|
14
|
+
check(ctx) {
|
|
15
|
+
walkMarkup(ctx, (node) => {
|
|
16
|
+
if (node.type !== 'RegularElement') return;
|
|
17
|
+
const atom = CONTROLS[node.name];
|
|
18
|
+
if (!atom) return;
|
|
19
|
+
if (node.name === 'input' && staticAttribute(node, 'type') === 'hidden') return;
|
|
20
|
+
ctx.report('no-raw-control', node.start, `raw <${node.name}>; use the ${atom} atom`);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|