canon-ds 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/canon.js +6 -0
- package/lib/build-manifest.mjs +54 -0
- package/lib/build.mjs +69 -0
- package/lib/cli.mjs +273 -0
- package/lib/color.js +160 -0
- package/lib/components/_shared.mjs +93 -0
- package/lib/components/accordion.mjs +136 -0
- package/lib/components/account-card.mjs +166 -0
- package/lib/components/activity-feed.mjs +176 -0
- package/lib/components/activity-gauge.mjs +98 -0
- package/lib/components/agent-presence.mjs +156 -0
- package/lib/components/alert.mjs +154 -0
- package/lib/components/app-store-button.mjs +108 -0
- package/lib/components/avatar-group.mjs +128 -0
- package/lib/components/avatar.mjs +225 -0
- package/lib/components/badge-group.mjs +55 -0
- package/lib/components/badge.mjs +174 -0
- package/lib/components/banner.mjs +116 -0
- package/lib/components/breadcrumb.mjs +170 -0
- package/lib/components/button-group.mjs +114 -0
- package/lib/components/button.mjs +197 -0
- package/lib/components/card-header.mjs +115 -0
- package/lib/components/card.mjs +106 -0
- package/lib/components/carousel.mjs +182 -0
- package/lib/components/chart-frame.mjs +101 -0
- package/lib/components/checkbox.mjs +163 -0
- package/lib/components/close-button.mjs +49 -0
- package/lib/components/code.mjs +123 -0
- package/lib/components/color-picker.mjs +122 -0
- package/lib/components/combobox.mjs +210 -0
- package/lib/components/command-palette.mjs +226 -0
- package/lib/components/content-divider.mjs +104 -0
- package/lib/components/counter.mjs +86 -0
- package/lib/components/credit-card.mjs +126 -0
- package/lib/components/date-picker.mjs +202 -0
- package/lib/components/description-list.mjs +88 -0
- package/lib/components/dialog.mjs +140 -0
- package/lib/components/divider.mjs +115 -0
- package/lib/components/drawer.mjs +150 -0
- package/lib/components/empty-state.mjs +112 -0
- package/lib/components/featured-card.mjs +124 -0
- package/lib/components/featured-icon.mjs +67 -0
- package/lib/components/field.mjs +122 -0
- package/lib/components/file-dropzone.mjs +166 -0
- package/lib/components/filter-bar.mjs +179 -0
- package/lib/components/header-navigation.mjs +94 -0
- package/lib/components/icon-button.mjs +179 -0
- package/lib/components/index.mjs +52 -0
- package/lib/components/inline-cta.mjs +139 -0
- package/lib/components/input-group.mjs +164 -0
- package/lib/components/input.mjs +197 -0
- package/lib/components/kanban.mjs +179 -0
- package/lib/components/kbd.mjs +70 -0
- package/lib/components/kicker.mjs +79 -0
- package/lib/components/link.mjs +108 -0
- package/lib/components/list.mjs +117 -0
- package/lib/components/media-frame.mjs +119 -0
- package/lib/components/menu.mjs +190 -0
- package/lib/components/message.mjs +249 -0
- package/lib/components/metric-group.mjs +210 -0
- package/lib/components/mobile-header.mjs +173 -0
- package/lib/components/multi-select.mjs +218 -0
- package/lib/components/notification.mjs +175 -0
- package/lib/components/number-input.mjs +149 -0
- package/lib/components/page-header.mjs +68 -0
- package/lib/components/pagination.mjs +236 -0
- package/lib/components/pin-input.mjs +120 -0
- package/lib/components/popover.mjs +126 -0
- package/lib/components/progress-circle.mjs +115 -0
- package/lib/components/progress.mjs +93 -0
- package/lib/components/prose.mjs +96 -0
- package/lib/components/radio.mjs +160 -0
- package/lib/components/rating.mjs +93 -0
- package/lib/components/rich-text-editor.mjs +178 -0
- package/lib/components/section-header.mjs +62 -0
- package/lib/components/segmented-control.mjs +121 -0
- package/lib/components/select.mjs +163 -0
- package/lib/components/sidebar-nav.mjs +172 -0
- package/lib/components/sidebar.mjs +124 -0
- package/lib/components/skeleton.mjs +66 -0
- package/lib/components/slider.mjs +122 -0
- package/lib/components/social-button.mjs +119 -0
- package/lib/components/spinner.mjs +69 -0
- package/lib/components/stat.mjs +120 -0
- package/lib/components/stepper.mjs +228 -0
- package/lib/components/switch.mjs +159 -0
- package/lib/components/table.mjs +212 -0
- package/lib/components/tabs.mjs +207 -0
- package/lib/components/tag.mjs +196 -0
- package/lib/components/tags-input.mjs +146 -0
- package/lib/components/textarea.mjs +117 -0
- package/lib/components/timeline.mjs +135 -0
- package/lib/components/toast.mjs +115 -0
- package/lib/components/tooltip.mjs +86 -0
- package/lib/components/topbar.mjs +207 -0
- package/lib/components/tree-view.mjs +156 -0
- package/lib/components/video-player.mjs +189 -0
- package/lib/connect.mjs +110 -0
- package/lib/design-files.mjs +175 -0
- package/lib/distribution.mjs +109 -0
- package/lib/editor.js +940 -0
- package/lib/engine.js +339 -0
- package/lib/generators/agents.mjs +68 -0
- package/lib/generators/connection.md +58 -0
- package/lib/generators/css.mjs +2 -0
- package/lib/generators/designmd.mjs +324 -0
- package/lib/generators/documentation.css +82 -0
- package/lib/generators/documentation.html +81 -0
- package/lib/generators/documentation.js +96 -0
- package/lib/generators/documentation.mjs +35 -0
- package/lib/generators/dtcg.mjs +60 -0
- package/lib/generators/preview.css +279 -0
- package/lib/generators/preview.mjs +225 -0
- package/lib/generators/react.mjs +162 -0
- package/lib/generators/tailwind.mjs +114 -0
- package/lib/install.mjs +108 -0
- package/lib/lint.mjs +538 -0
- package/lib/mcp.mjs +268 -0
- package/lib/open.mjs +7 -0
- package/lib/patterns/_app.mjs +56 -0
- package/lib/patterns/app-shell.mjs +25 -0
- package/lib/patterns/audit-log.mjs +37 -0
- package/lib/patterns/auth-pages.mjs +44 -0
- package/lib/patterns/billing-page.mjs +39 -0
- package/lib/patterns/calendar-page.mjs +55 -0
- package/lib/patterns/dashboard-page.mjs +28 -0
- package/lib/patterns/files-page.mjs +30 -0
- package/lib/patterns/first-run.mjs +31 -0
- package/lib/patterns/form-layout.mjs +24 -0
- package/lib/patterns/inbox-page.mjs +58 -0
- package/lib/patterns/index.mjs +29 -0
- package/lib/patterns/list-detail-page.mjs +24 -0
- package/lib/patterns/mobile-app.mjs +46 -0
- package/lib/patterns/modal-flows.mjs +32 -0
- package/lib/patterns/onboarding-page.mjs +42 -0
- package/lib/patterns/permissions-page.mjs +38 -0
- package/lib/patterns/profile-page.mjs +32 -0
- package/lib/patterns/settings-page.mjs +24 -0
- package/lib/project.mjs +28 -0
- package/lib/serve.mjs +279 -0
- package/lib/system.mjs +210 -0
- package/lib/tokens/base.js +251 -0
- package/lib/tokens/canon-preset.mjs +67 -0
- package/lib/tokens/index.mjs +1 -0
- package/lib/tokens/legacy-canon.json +559 -0
- package/lib/tokens/presets.mjs +161 -0
- package/lib/tokens/resolve.mjs +1 -0
- package/lib/types.mjs +4 -0
- package/lib/version.mjs +2 -0
- package/package.json +54 -0
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, relative, dirname, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { installFiles, sourcePath } from "./design-files.mjs";
|
|
5
|
+
import { projectWrite } from "./project.mjs";
|
|
6
|
+
const START = '<!-- canon:start -->', END = '<!-- canon:end -->';
|
|
7
|
+
const shellQuote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
|
|
8
|
+
function managedContent(path, block) {
|
|
9
|
+
let content = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
10
|
+
const s = content.indexOf(START), e = content.indexOf(END);
|
|
11
|
+
if (s !== -1 && e !== -1)
|
|
12
|
+
content = content.slice(0, s) + block.trim() + content.slice(e + END.length);
|
|
13
|
+
else
|
|
14
|
+
content = (content ? content.replace(/\s*$/, '\n\n') : '') + block.trim() + '\n';
|
|
15
|
+
return content;
|
|
16
|
+
}
|
|
17
|
+
/** Prepare references from a build without writing; Save uses its staged build. */
|
|
18
|
+
export function referenceWrites(system, designDir, root, dist = join(designDir, system.meta.out || 'dist')) {
|
|
19
|
+
const relDesign = relative(root, designDir).split(sep).join('/') || '.';
|
|
20
|
+
const text = (file) => readFileSync(join(dist, file), 'utf8').replace(/\bdesign\//g, () => `${relDesign}/`);
|
|
21
|
+
const files = new Map([
|
|
22
|
+
['DESIGN.md', 'DESIGN.md'], ['DESIGN.compact.md', 'DESIGN.compact.md'],
|
|
23
|
+
['.claude/skills/design-system/SKILL.md', 'agents/SKILL.md'],
|
|
24
|
+
['.cursor/rules/design-system.mdc', 'agents/design-system.mdc'],
|
|
25
|
+
]);
|
|
26
|
+
const writes = [...files].map(([target, source]) => ({ root, path: join(root, target), content: Buffer.from(text(source)) }));
|
|
27
|
+
const block = text('agents/AGENTS.md');
|
|
28
|
+
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
|
29
|
+
const path = sourcePath(root, join(root, name), 'file');
|
|
30
|
+
writes.push({ root, path, content: Buffer.from(managedContent(path, block)) });
|
|
31
|
+
}
|
|
32
|
+
return writes;
|
|
33
|
+
}
|
|
34
|
+
function mergeJson(path, patch, log) {
|
|
35
|
+
let j = {};
|
|
36
|
+
if (existsSync(path)) {
|
|
37
|
+
try {
|
|
38
|
+
j = JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
log.push(` ! ${path} is not valid JSON, left untouched`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
patch(j);
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
47
|
+
writeFileSync(path, JSON.stringify(j, null, 2) + '\n');
|
|
48
|
+
log.push(` merged ${path}`);
|
|
49
|
+
}
|
|
50
|
+
export function install(system, designDir, opts) {
|
|
51
|
+
const log = [];
|
|
52
|
+
const root = realpathSync(opts.root);
|
|
53
|
+
designDir = realpathSync(designDir);
|
|
54
|
+
const dist = join(designDir, system.meta.out || 'dist');
|
|
55
|
+
if (!existsSync(join(dist, 'DESIGN.md')))
|
|
56
|
+
throw new Error(`dist not built at ${dist}. Run canon build first.`);
|
|
57
|
+
const binPath = fileURLToPath(new URL('../bin/canon.js', import.meta.url));
|
|
58
|
+
const relDesign = relative(root, designDir) || 'design';
|
|
59
|
+
log.push(`Installing ${system.meta.name} into ${root}`);
|
|
60
|
+
const references = referenceWrites(system, designDir, root, dist);
|
|
61
|
+
installFiles(root, [...references, projectWrite(root, designDir)]);
|
|
62
|
+
for (const write of references)
|
|
63
|
+
log.push(` wrote ${write.path}`);
|
|
64
|
+
mergeJson(join(root, '.mcp.json'), (j) => {
|
|
65
|
+
const servers = (j.mcpServers ??= {});
|
|
66
|
+
servers.canon = { command: 'node', args: [binPath, 'mcp', '--design', relDesign] };
|
|
67
|
+
}, log);
|
|
68
|
+
if (opts.hooks) {
|
|
69
|
+
mergeJson(join(root, '.claude', 'settings.json'), (j) => {
|
|
70
|
+
const hooks = (j.hooks ??= {});
|
|
71
|
+
const post = (hooks.PostToolUse ??= []);
|
|
72
|
+
const cmd = `node ${shellQuote(binPath)} hook --design ${shellQuote(relDesign)}`;
|
|
73
|
+
const legacyCmd = `node ${binPath} hook --design ${relDesign}`;
|
|
74
|
+
let exists = false;
|
|
75
|
+
for (const entry of post) {
|
|
76
|
+
if (entry.matcher !== 'Edit|Write|MultiEdit' || !Array.isArray(entry.hooks))
|
|
77
|
+
continue;
|
|
78
|
+
for (const hook of entry.hooks) {
|
|
79
|
+
if (!hook || typeof hook !== 'object' || hook.type !== 'command')
|
|
80
|
+
continue;
|
|
81
|
+
if (hook.command === legacyCmd)
|
|
82
|
+
hook.command = cmd;
|
|
83
|
+
if (hook.command === cmd)
|
|
84
|
+
exists = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!exists)
|
|
88
|
+
post.push({ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: cmd, timeout: 30 }] });
|
|
89
|
+
}, log);
|
|
90
|
+
log.push(` hook: every Edit/Write is linted; violations are reported back to the agent`);
|
|
91
|
+
}
|
|
92
|
+
// codex: point to AGENTS.md + MCP config hint
|
|
93
|
+
const codexToml = join(root, '.codex', 'config.toml');
|
|
94
|
+
const tomlBlock = `\n# canon design system MCP (added by canon install)\n[mcp_servers.canon]\ncommand = "node"\nargs = ${JSON.stringify([binPath, 'mcp', '--design', relDesign])}\n`;
|
|
95
|
+
mkdirSync(join(root, '.codex'), { recursive: true });
|
|
96
|
+
const cur = existsSync(codexToml) ? readFileSync(codexToml, 'utf8') : '';
|
|
97
|
+
if (!cur.includes('[mcp_servers.canon]')) {
|
|
98
|
+
writeFileSync(codexToml, cur + tomlBlock);
|
|
99
|
+
log.push(` wrote .codex/config.toml (mcp_servers.canon)`);
|
|
100
|
+
}
|
|
101
|
+
log.push('');
|
|
102
|
+
log.push('Next:');
|
|
103
|
+
const relDist = relative(root, dist).split(sep).join('/');
|
|
104
|
+
log.push(` 1. Import the CSS once: \`import ${JSON.stringify(`./${relDist}/${system.meta.prefix}.css`)}\` (or <link>). Tailwind v4: also @import ${JSON.stringify(`./${relDist}/tailwind.theme.css`)}.`);
|
|
105
|
+
log.push(` 2. Open the gallery: canon serve --design ${shellQuote(relDesign)}`);
|
|
106
|
+
log.push(` 3. Agents start with DESIGN.compact.md + AGENTS.md/CLAUDE.md and retrieve relevant specs through Canon MCP or design source. Run \`canon check --design ${shellQuote(relDesign)}\` in CI.`);
|
|
107
|
+
return { log };
|
|
108
|
+
}
|
package/lib/lint.mjs
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative, extname, resolve } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { indexTokens } from "./tokens/resolve.mjs";
|
|
5
|
+
import { parseCssColor, distance } from './color.js';
|
|
6
|
+
const EXT = new Set(['.css', '.scss', '.less', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.html', '.vue', '.svelte', '.astro', '.mdx']);
|
|
7
|
+
const TW_PALETTES = ['slate', 'gray', 'zinc', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose'];
|
|
8
|
+
const SIZE_PROPS = 'font-size|line-height|letter-spacing|padding(?:-(?:top|right|bottom|left|inline|block|inline-start|inline-end|block-start|block-end))?|margin(?:-(?:top|right|bottom|left|inline|block|inline-start|inline-end|block-start|block-end))?|gap|row-gap|column-gap|border-radius|border-(?:top|bottom)-(?:left|right)-radius|border-(?:start|end)-(?:start|end)-radius|inset(?:-[a-z-]+)?|top|right|bottom|left';
|
|
9
|
+
const LAYOUT_PROPS = 'width|height|min-width|max-width|min-height|max-height|flex-basis';
|
|
10
|
+
function globToRe(g) {
|
|
11
|
+
const re = g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\//g, '(?:.*/)?').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '.');
|
|
12
|
+
return new RegExp(`(^|/)${re}$`);
|
|
13
|
+
}
|
|
14
|
+
function walk(dir, exclude, out) {
|
|
15
|
+
if (!existsSync(dir))
|
|
16
|
+
return;
|
|
17
|
+
for (const name of readdirSync(dir)) {
|
|
18
|
+
const p = join(dir, name);
|
|
19
|
+
if (exclude.some((r) => r.test(name) || r.test(p)))
|
|
20
|
+
continue;
|
|
21
|
+
const st = statSync(p);
|
|
22
|
+
if (st.isDirectory())
|
|
23
|
+
walk(p, exclude, out);
|
|
24
|
+
else if (EXT.has(extname(name)))
|
|
25
|
+
out.push(p);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Strip comments but keep line structure so positions stay right. */
|
|
29
|
+
function stripComments(src, ext) {
|
|
30
|
+
let s = src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
31
|
+
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.vue', '.svelte', '.astro'].includes(ext))
|
|
32
|
+
s = s.replace(/(^|[^:'"\\])\/\/[^\n]*/g, (m, pre) => pre + ' '.repeat(m.length - pre.length));
|
|
33
|
+
if (['.html', '.vue', '.svelte', '.astro', '.mdx'].includes(ext))
|
|
34
|
+
s = s.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
export function knownFromSystem(system, idx) {
|
|
38
|
+
const p = system.meta.prefix;
|
|
39
|
+
const classes = new Set();
|
|
40
|
+
const props = new Map();
|
|
41
|
+
for (const c of system.components) {
|
|
42
|
+
classes.add(`${p}-${c.slug}`);
|
|
43
|
+
for (const a of c.anatomy)
|
|
44
|
+
if (a.part !== 'root')
|
|
45
|
+
classes.add(`${p}-${c.slug}__${a.part}`);
|
|
46
|
+
props.set(`${p}-${c.slug}`, Object.fromEntries(Object.entries(c.props).map(([k, v]) => [k, new Set(v.values)])));
|
|
47
|
+
for (const m of (c.extraCss ?? '').matchAll(/\.cn-([a-z0-9-]+(?:__[a-z0-9-]+)?)/g))
|
|
48
|
+
classes.add(`${p}-${m[1]}`);
|
|
49
|
+
}
|
|
50
|
+
for (const k of Object.keys(system.tokens.type))
|
|
51
|
+
classes.add(`${p}-text-${k}`);
|
|
52
|
+
for (const u of ['icon', 'sr-only', 'tabular', 'truncate', 'container', 'prose', 'section', 'stack', 'row', 'grid', 'split', 'center', 'measure', 'mx-auto', 'flex-1', 'hide-mobile', 'show-mobile', 'placeholder'])
|
|
53
|
+
classes.add(`${p}-${u}`);
|
|
54
|
+
for (const pt of system.patterns) {
|
|
55
|
+
for (const m of (pt.css ?? '').matchAll(/\.cn-([a-z0-9-]+(?:__[a-z0-9-]+)?)/g))
|
|
56
|
+
classes.add(`${p}-${m[1]}`);
|
|
57
|
+
for (const m of (pt.html + (pt.variants ?? []).map((v) => v.html).join('')).matchAll(/\bcn-([a-z0-9-]+(?:__[a-z0-9-]+)?)/g))
|
|
58
|
+
classes.add(`${p}-${m[1]}`);
|
|
59
|
+
}
|
|
60
|
+
const colors = [];
|
|
61
|
+
const dims = [];
|
|
62
|
+
for (const t of idx.values()) {
|
|
63
|
+
if (t.group === 'color') {
|
|
64
|
+
const hex = parseCssColor(t.light);
|
|
65
|
+
if (hex)
|
|
66
|
+
colors.push({ name: t.path, hex, ref: t.cssVar });
|
|
67
|
+
}
|
|
68
|
+
if (['space', 'radius'].includes(t.group) || (t.group === 'font' && t.path.startsWith('size')) || (t.group === 'size' && !t.path.startsWith('container'))) {
|
|
69
|
+
const m = t.light.match(/^([\d.]+)px$/);
|
|
70
|
+
if (m)
|
|
71
|
+
dims.push({ ref: t.cssVar, px: +m[1], group: t.group });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { classes, props, colors, dims, prefix: p };
|
|
75
|
+
}
|
|
76
|
+
function nearestColor(known, value) {
|
|
77
|
+
const hex = parseCssColor(value);
|
|
78
|
+
if (!hex)
|
|
79
|
+
return undefined;
|
|
80
|
+
let best = null;
|
|
81
|
+
for (const c of known.colors) {
|
|
82
|
+
if (!/^(color|)/.test(c.name))
|
|
83
|
+
continue;
|
|
84
|
+
const d = distance(hex, c.hex);
|
|
85
|
+
if (!best || d < best.d)
|
|
86
|
+
best = { d, c };
|
|
87
|
+
}
|
|
88
|
+
if (!best)
|
|
89
|
+
return undefined;
|
|
90
|
+
const semantic = known.colors.filter((c) => c.ref.includes('-color-')).map((c) => ({ c, d: distance(hex, c.hex) })).sort((a, b) => a.d - b.d)[0];
|
|
91
|
+
const parts = [`nearest: var(${best.c.ref}) ${best.c.hex}`];
|
|
92
|
+
if (semantic && semantic.c.ref !== best.c.ref && semantic.d < 0.08)
|
|
93
|
+
parts.push(`semantic: var(${semantic.c.ref}) ${semantic.c.hex}`);
|
|
94
|
+
return parts.join(' · ');
|
|
95
|
+
}
|
|
96
|
+
function nearestDim(known, px, prop) {
|
|
97
|
+
const group = /radius/.test(prop) ? 'radius' : /font-size/.test(prop) ? 'font' : 'space';
|
|
98
|
+
const cands = known.dims.filter((d) => d.group === group);
|
|
99
|
+
if (!cands.length)
|
|
100
|
+
return undefined;
|
|
101
|
+
const exact = cands.find((d) => d.px === px);
|
|
102
|
+
if (exact)
|
|
103
|
+
return `use var(${exact.ref})`;
|
|
104
|
+
const near = [...cands].sort((a, b) => Math.abs(a.px - px) - Math.abs(b.px - px))[0];
|
|
105
|
+
return `nearest: var(${near.ref}) = ${near.px}px`;
|
|
106
|
+
}
|
|
107
|
+
/** Skip delimiters inside strings and nested expressions without evaluating code. */
|
|
108
|
+
function quotedEnd(src, start) {
|
|
109
|
+
const quote = src[start];
|
|
110
|
+
let i = start + 1;
|
|
111
|
+
while (i < src.length) {
|
|
112
|
+
if (src[i] === '\\') {
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (quote === '\x60' && src[i] === '$' && src[i + 1] === '{') {
|
|
117
|
+
i = groupEnd(src, i + 1);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (src[i++] === quote)
|
|
121
|
+
return i;
|
|
122
|
+
}
|
|
123
|
+
return i;
|
|
124
|
+
}
|
|
125
|
+
function groupEnd(src, start) {
|
|
126
|
+
const close = { '{': '}', '[': ']', '(': ')' }[src[start]];
|
|
127
|
+
let i = start + 1;
|
|
128
|
+
while (i < src.length) {
|
|
129
|
+
if (/['"\x60]/.test(src[i])) {
|
|
130
|
+
i = quotedEnd(src, i);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if ('{[('.includes(src[i])) {
|
|
134
|
+
i = groupEnd(src, i);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (src[i++] === close)
|
|
138
|
+
return i;
|
|
139
|
+
}
|
|
140
|
+
return i;
|
|
141
|
+
}
|
|
142
|
+
/** Literal fragments inside class expressions, including template interpolations. */
|
|
143
|
+
function literalSpans(span) {
|
|
144
|
+
const out = [];
|
|
145
|
+
const src = span.text;
|
|
146
|
+
for (let i = 0; i < src.length; i++) {
|
|
147
|
+
if (!/['"\x60]/.test(src[i]))
|
|
148
|
+
continue;
|
|
149
|
+
const end = quotedEnd(src, i);
|
|
150
|
+
if (src[i] !== '\x60')
|
|
151
|
+
out.push({ text: src.slice(i + 1, end - 1), start: span.start + i + 1 });
|
|
152
|
+
else {
|
|
153
|
+
let part = i + 1;
|
|
154
|
+
for (let j = part; j < end - 1; j++) {
|
|
155
|
+
if (src[j] === '\\') {
|
|
156
|
+
j++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (src[j] !== '$' || src[j + 1] !== '{')
|
|
160
|
+
continue;
|
|
161
|
+
out.push({ text: src.slice(part, j), start: span.start + part });
|
|
162
|
+
const next = groupEnd(src, j + 1);
|
|
163
|
+
out.push(...literalSpans({ text: src.slice(j + 2, next - 1), start: span.start + j + 2 }));
|
|
164
|
+
part = next;
|
|
165
|
+
j = next - 1;
|
|
166
|
+
}
|
|
167
|
+
out.push({ text: src.slice(part, end - 1), start: span.start + part });
|
|
168
|
+
}
|
|
169
|
+
i = end - 1;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function staticValue(attr) {
|
|
174
|
+
if (!attr.value)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (!attr.expression)
|
|
177
|
+
return attr.value.text;
|
|
178
|
+
const text = attr.value.text.trim();
|
|
179
|
+
if (/^['"\x60]/.test(text) && quotedEnd(text, 0) === text.length && !text.includes('$' + '{'))
|
|
180
|
+
return text.slice(1, -1);
|
|
181
|
+
if (/^(?:true|false|-?\d+(?:\.\d+)?)$/.test(text))
|
|
182
|
+
return text;
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
/** Read complete opening tags; quoted > and JSX expressions never end a tag. */
|
|
186
|
+
function markupContexts(src, script) {
|
|
187
|
+
const tags = [];
|
|
188
|
+
const code = src.split('');
|
|
189
|
+
const blank = (start, end) => { for (let i = start; i < end; i++)
|
|
190
|
+
if (code[i] !== '\n')
|
|
191
|
+
code[i] = ' '; };
|
|
192
|
+
let depth = 0;
|
|
193
|
+
let expressions = 0;
|
|
194
|
+
for (let i = 0; i < src.length;) {
|
|
195
|
+
if (script && (!depth || expressions) && /['"\x60]/.test(src[i])) {
|
|
196
|
+
i = quotedEnd(src, i);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (src[i] === '{' && depth) {
|
|
200
|
+
expressions++;
|
|
201
|
+
i++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (src[i] === '}' && expressions) {
|
|
205
|
+
expressions--;
|
|
206
|
+
i++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const closing = src.slice(i).match(/^<\/[A-Za-z][\w:.-]*\s*>/);
|
|
210
|
+
if (closing) {
|
|
211
|
+
blank(i, i + closing[0].length);
|
|
212
|
+
depth = Math.max(0, depth - 1);
|
|
213
|
+
i += closing[0].length;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const tag = src.slice(i).match(/^<[A-Za-z][\w:.-]*(?=[\s/>])/);
|
|
217
|
+
if (!tag) {
|
|
218
|
+
if (depth && !expressions)
|
|
219
|
+
blank(i, i + 1);
|
|
220
|
+
i++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const start = i;
|
|
224
|
+
i += tag[0].length;
|
|
225
|
+
const attrs = [];
|
|
226
|
+
while (i < src.length && src[i] !== '>') {
|
|
227
|
+
if (/[\s/]/.test(src[i])) {
|
|
228
|
+
i++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (src[i] === '{') {
|
|
232
|
+
i = groupEnd(src, i);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const name = src.slice(i).match(/^[\w:@.-]+/);
|
|
236
|
+
if (!name) {
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const attr = { name: name[0], start: i, expression: false };
|
|
241
|
+
i += name[0].length;
|
|
242
|
+
while (/\s/.test(src[i] ?? ''))
|
|
243
|
+
i++;
|
|
244
|
+
if (src[i] === '=') {
|
|
245
|
+
i++;
|
|
246
|
+
while (/\s/.test(src[i] ?? ''))
|
|
247
|
+
i++;
|
|
248
|
+
if (/['"]/.test(src[i] ?? '')) {
|
|
249
|
+
const end = quotedEnd(src, i);
|
|
250
|
+
attr.value = { text: src.slice(i + 1, end - 1), start: i + 1 };
|
|
251
|
+
i = end;
|
|
252
|
+
}
|
|
253
|
+
else if (src[i] === '{') {
|
|
254
|
+
const end = groupEnd(src, i);
|
|
255
|
+
attr.value = { text: src.slice(i + 1, end - 1), start: i + 1 };
|
|
256
|
+
attr.expression = true;
|
|
257
|
+
i = end;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const value = src.slice(i).match(/^[^\s>]+/)?.[0] ?? '';
|
|
261
|
+
attr.value = { text: value, start: i };
|
|
262
|
+
i += value.length;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
attrs.push(attr);
|
|
266
|
+
}
|
|
267
|
+
if (i >= src.length)
|
|
268
|
+
break;
|
|
269
|
+
if (src[i - 1] !== '/' && !/^<(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i.test(tag[0]))
|
|
270
|
+
depth++;
|
|
271
|
+
tags.push(attrs);
|
|
272
|
+
blank(start, ++i);
|
|
273
|
+
}
|
|
274
|
+
return { tags, code: code.join('') };
|
|
275
|
+
}
|
|
276
|
+
function declarationValues(span, js, visit, template) {
|
|
277
|
+
const src = span.text;
|
|
278
|
+
for (let i = 0; i < src.length;) {
|
|
279
|
+
const start = i;
|
|
280
|
+
let name;
|
|
281
|
+
if (/['"\x60]/.test(src[i])) {
|
|
282
|
+
const end = quotedEnd(src, i);
|
|
283
|
+
name = src.slice(i + 1, end - 1);
|
|
284
|
+
if (template && src[i] === '\x60' && /\b(?:css|keyframes|createGlobalStyle|styled(?:\.[\w$]+|\([^;{}]*\)))\s*$/.test(src.slice(0, i))) {
|
|
285
|
+
template({ text: name, start: span.start + i + 1 });
|
|
286
|
+
}
|
|
287
|
+
i = end;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const word = src.slice(i).match(/^[A-Za-z_-][\w-]*/);
|
|
291
|
+
if (!word) {
|
|
292
|
+
i++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
name = word[0];
|
|
296
|
+
i += name.length;
|
|
297
|
+
}
|
|
298
|
+
let before = start - 1;
|
|
299
|
+
while (before >= 0 && /\s/.test(src[before]))
|
|
300
|
+
before--;
|
|
301
|
+
if (before >= 0 && !(js ? '{,' : '{;}').includes(src[before]))
|
|
302
|
+
continue;
|
|
303
|
+
let colon = i;
|
|
304
|
+
while (/\s/.test(src[colon] ?? ''))
|
|
305
|
+
colon++;
|
|
306
|
+
if (src[colon] !== ':')
|
|
307
|
+
continue;
|
|
308
|
+
let begin = colon + 1;
|
|
309
|
+
while (/\s/.test(src[begin] ?? ''))
|
|
310
|
+
begin++;
|
|
311
|
+
let end = begin;
|
|
312
|
+
while (end < src.length && !(js ? ',;}' : ';}').includes(src[end])) {
|
|
313
|
+
if (/['"\x60]/.test(src[end]))
|
|
314
|
+
end = quotedEnd(src, end);
|
|
315
|
+
else if ('(['.includes(src[end]) || (js && src[end] === '{'))
|
|
316
|
+
end = groupEnd(src, end);
|
|
317
|
+
else if (src[end] === '{')
|
|
318
|
+
break;
|
|
319
|
+
else
|
|
320
|
+
end++;
|
|
321
|
+
}
|
|
322
|
+
if (src[begin] !== '{')
|
|
323
|
+
visit(name, { text: src.slice(begin, end), start: span.start + begin }, span.start + start, js);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function withoutFunctions(src, names) {
|
|
327
|
+
let text = src;
|
|
328
|
+
for (const match of src.matchAll(/\b([\w-]+)\s*\(/g)) {
|
|
329
|
+
if (!names.includes(match[1]))
|
|
330
|
+
continue;
|
|
331
|
+
const end = groupEnd(src, match.index + match[0].lastIndexOf('('));
|
|
332
|
+
text = text.slice(0, match.index) + src.slice(match.index, end).replace(/[^\n]/g, ' ') + text.slice(end);
|
|
333
|
+
}
|
|
334
|
+
return text;
|
|
335
|
+
}
|
|
336
|
+
/** Lint syntax that carries styles or classes, retaining source offsets. */
|
|
337
|
+
export function lintSource(known, file, content, opts = { tailwind: true }) {
|
|
338
|
+
const ext = extname(file) || '.css';
|
|
339
|
+
const src = stripComments(content, ext);
|
|
340
|
+
const rawLines = content.split('\n');
|
|
341
|
+
const lineStarts = [0];
|
|
342
|
+
for (let i = 0; i < content.length; i++)
|
|
343
|
+
if (content[i] === '\n')
|
|
344
|
+
lineStarts.push(i + 1);
|
|
345
|
+
const out = [];
|
|
346
|
+
const push = (offset, rule, severity, message, suggestion) => {
|
|
347
|
+
let low = 0, high = lineStarts.length;
|
|
348
|
+
while (low + 1 < high) {
|
|
349
|
+
const mid = (low + high) >>> 1;
|
|
350
|
+
if (lineStarts[mid] <= offset)
|
|
351
|
+
low = mid;
|
|
352
|
+
else
|
|
353
|
+
high = mid;
|
|
354
|
+
}
|
|
355
|
+
const raw = rawLines[low] ?? '';
|
|
356
|
+
if (/canon-(allow|ignore)/.test(raw) || /canon-(allow|ignore)/.test(rawLines[low - 1] ?? ''))
|
|
357
|
+
return;
|
|
358
|
+
out.push({ file, line: low + 1, col: offset - lineStarts[low] + 1, rule, severity, message, snippet: raw.trim().slice(0, 160), suggestion });
|
|
359
|
+
};
|
|
360
|
+
const p = known.prefix;
|
|
361
|
+
const sizeProps = new RegExp('^(' + SIZE_PROPS + '|' + LAYOUT_PROPS + ')$');
|
|
362
|
+
const layoutProps = new RegExp('^(' + LAYOUT_PROPS + ')$');
|
|
363
|
+
const colorProps = /^(?:color|background(?:-[a-z-]+)?|border(?:-[a-z-]+)?|outline(?:-color)?|fill|stroke|text-decoration(?:-color)?|text-shadow|caret-color|accent-color|stop-color|flood-color|lighting-color|column-rule(?:-color)?|filter)$/;
|
|
364
|
+
const style = (name, value, offset, js) => {
|
|
365
|
+
const prop = name.replace(/[A-Z]/g, (letter) => '-' + letter.toLowerCase());
|
|
366
|
+
const val = value.text.trim();
|
|
367
|
+
const safe = withoutFunctions(value.text, ['url', 'var']);
|
|
368
|
+
if (colorProps.test(prop)) {
|
|
369
|
+
for (const m of safe.matchAll(/(?<![\w&/.-])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b|\b(?:rgba?|hsla?|oklch|oklab|color-mix)\(\s*[^)]*\)/g)) {
|
|
370
|
+
if (/var\(/.test(value.text.slice(m.index, m.index + m[0].length)))
|
|
371
|
+
continue;
|
|
372
|
+
push(value.start + m.index, 'raw-color', 'error', 'Raw color ' + m[0].slice(0, 40), nearestColor(known, m[0]));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (sizeProps.test(prop)) {
|
|
376
|
+
const nums = [...safe.matchAll(/(-?\d+(?:\.\d+)?)(px|rem|em)\b/g)].map((m) => (m[2] === 'px' ? +m[1] : +m[1] * 16));
|
|
377
|
+
if (js && /^-?\d+(?:\.\d+)?$/.test(val))
|
|
378
|
+
nums.push(Number(val));
|
|
379
|
+
if (nums.some((n) => n !== 0 && Math.abs(n) !== 1)) {
|
|
380
|
+
const layout = layoutProps.test(prop);
|
|
381
|
+
push(offset, 'raw-size', layout ? 'warn' : 'error', 'Raw ' + name + ': ' + val, layout ? 'use a container/size token or a token-based calc()' : nearestDim(known, Math.abs(nums.find((n) => n !== 0 && Math.abs(n) !== 1)), prop));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (prop === 'font-family' && !/var\(|\binherit\b/.test(val) && (!js || /^['"\x60]/.test(val))) {
|
|
385
|
+
push(offset, 'raw-font', 'error', 'Raw ' + name + ' ' + val.slice(0, 60), 'use var(--' + p + '-font-family-sans|mono|display)');
|
|
386
|
+
}
|
|
387
|
+
if (prop === 'box-shadow' && !/var\(|\b(?:none|inherit)\b/.test(val) && (!js || /^['"\x60]/.test(val))) {
|
|
388
|
+
push(offset, 'raw-shadow', 'error', 'Raw ' + name, 'use var(--' + p + '-shadow-xs|sm|md|lg|xl|focus)');
|
|
389
|
+
}
|
|
390
|
+
const stylingProperty = colorProps.test(prop) || sizeProps.test(prop) || prop === 'font-family' || prop === 'box-shadow';
|
|
391
|
+
const important = !js || stylingProperty ? safe.indexOf('!important') : -1;
|
|
392
|
+
if (important >= 0)
|
|
393
|
+
push(value.start + important, 'important', 'warn', '!important hides a specificity problem', 'use the component variant/state instead');
|
|
394
|
+
};
|
|
395
|
+
const classes = (span) => {
|
|
396
|
+
const roots = [];
|
|
397
|
+
if (opts.tailwind) {
|
|
398
|
+
const palette = new RegExp('(?<![\\w-])(bg|text|border|ring|from|to|via|fill|stroke|outline|decoration|divide|placeholder|accent|caret|shadow)-(' + TW_PALETTES.join('|') + ')-(25|50|100|200|300|400|500|600|700|800|900|950)(?:/\\d+)?\\b', 'g');
|
|
399
|
+
for (const m of span.text.matchAll(palette)) {
|
|
400
|
+
const primitive = known.colors.some((c) => c.name === m[2] + '.' + m[3]);
|
|
401
|
+
push(span.start + m.index, 'tailwind-palette', primitive ? 'warn' : 'error', (primitive ? 'Primitive color utility ' : 'Tailwind palette class ') + m[0], 'use a semantic color utility');
|
|
402
|
+
}
|
|
403
|
+
for (const m of span.text.matchAll(/(?<![\w-])[a-z-]+-\[(#[0-9a-fA-F]{3,8}|-?\d+(?:\.\d+)?(?:px|rem|em)|rgba?\([^\]]*\)|hsla?\([^\]]*\))\]/g)) {
|
|
404
|
+
push(span.start + m.index, 'tailwind-arbitrary', 'error', 'Arbitrary value ' + m[0], /#|rgb|hsl/.test(m[1]) ? nearestColor(known, m[1]) : 'use a scale utility (p-3, gap-4, text-sm…)');
|
|
405
|
+
}
|
|
406
|
+
for (const m of span.text.matchAll(/(?<![\w-])(text|leading|tracking|p[xytrbl]?|m[xytrbl]?|gap|rounded|w|h|top|left|right|bottom|inset|space-[xy])-\[[^\]]+\]/g)) {
|
|
407
|
+
if (!/#|rgb|hsl|px|rem|em/.test(m[0]))
|
|
408
|
+
push(span.start + m.index, 'tailwind-arbitrary', 'error', 'Arbitrary value ' + m[0], 'use a scale utility');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const classRe = new RegExp('(?<![\\w-])' + p + '-[a-z0-9]+(?:-[a-z0-9]+)*(?:__[a-z0-9]+(?:-[a-z0-9]+)*)?', 'g');
|
|
412
|
+
for (const m of span.text.matchAll(classRe)) {
|
|
413
|
+
const cls = m[0];
|
|
414
|
+
if (known.props.has(cls))
|
|
415
|
+
roots.push(cls);
|
|
416
|
+
if (known.classes.has(cls))
|
|
417
|
+
continue;
|
|
418
|
+
const base = cls.split('__')[0];
|
|
419
|
+
push(span.start + m.index, 'unknown-class', 'error', 'Unknown class ' + cls, known.classes.has(base) ? 'parts of ' + base + ': ' + [...known.classes].filter((k) => k.startsWith(base + '__')).map((k) => k.slice(base.length + 2)).join(', ') : 'known components: ' + [...known.props.keys()].slice(0, 12).join(', ') + '…');
|
|
420
|
+
}
|
|
421
|
+
return roots;
|
|
422
|
+
};
|
|
423
|
+
const css = (span) => {
|
|
424
|
+
declarationValues(span, false, style);
|
|
425
|
+
for (const m of span.text.matchAll(/@apply\s+([^;{}]+)/g))
|
|
426
|
+
classes({ text: m[1], start: span.start + m.index + m[0].indexOf(m[1]) });
|
|
427
|
+
};
|
|
428
|
+
const isCss = ['.css', '.scss', '.less'].includes(ext);
|
|
429
|
+
if (isCss)
|
|
430
|
+
css({ text: src, start: 0 });
|
|
431
|
+
else {
|
|
432
|
+
const isScript = ['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext);
|
|
433
|
+
const contexts = markupContexts(src, isScript);
|
|
434
|
+
if (isScript)
|
|
435
|
+
declarationValues({ text: contexts.code, start: 0 }, true, style, css);
|
|
436
|
+
else
|
|
437
|
+
for (const m of src.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi))
|
|
438
|
+
declarationValues({ text: m[1], start: m.index + m[0].indexOf(m[1]) }, true, style, css);
|
|
439
|
+
for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi))
|
|
440
|
+
css({ text: m[1], start: m.index + m[0].indexOf(m[1]) });
|
|
441
|
+
for (const attrs of contexts.tags) {
|
|
442
|
+
const roots = new Set();
|
|
443
|
+
for (const attr of attrs) {
|
|
444
|
+
if (!attr.value)
|
|
445
|
+
continue;
|
|
446
|
+
if (['class', 'className', ':class'].includes(attr.name)) {
|
|
447
|
+
const spans = attr.expression || attr.name === ':class' ? literalSpans(attr.value) : [attr.value];
|
|
448
|
+
for (const span of spans)
|
|
449
|
+
for (const root of classes(span))
|
|
450
|
+
roots.add(root);
|
|
451
|
+
}
|
|
452
|
+
else if (['style', 'sx', ':style'].includes(attr.name)) {
|
|
453
|
+
if (attr.expression || attr.name !== 'style')
|
|
454
|
+
declarationValues(attr.value, true, style);
|
|
455
|
+
else
|
|
456
|
+
css(attr.value);
|
|
457
|
+
}
|
|
458
|
+
else if (colorProps.test(attr.name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()))) {
|
|
459
|
+
style(attr.name, attr.value, attr.start, attr.expression);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const attr of attrs) {
|
|
463
|
+
if (!attr.name.startsWith('data-'))
|
|
464
|
+
continue;
|
|
465
|
+
const value = staticValue(attr);
|
|
466
|
+
if (value === undefined)
|
|
467
|
+
continue;
|
|
468
|
+
const prop = attr.name.slice(5);
|
|
469
|
+
const applicable = [...roots].filter((root) => known.props.get(root)?.[prop]);
|
|
470
|
+
if (!applicable.length || applicable.some((root) => known.props.get(root)[prop].has(value)))
|
|
471
|
+
continue;
|
|
472
|
+
const allowed = [...new Set(applicable.flatMap((root) => [...known.props.get(root)[prop]]))];
|
|
473
|
+
push(attr.start, 'unknown-prop-value', 'error', applicable.join(', ') + ': ' + attr.name + '="' + value + '" is not allowed', 'allowed: ' + allowed.join(', '));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const seen = new Set();
|
|
478
|
+
return out.filter((v) => { const k = v.line + ':' + v.col + ':' + v.rule; if (seen.has(k))
|
|
479
|
+
return false; seen.add(k); return true; });
|
|
480
|
+
}
|
|
481
|
+
export async function runLint(system, designDir, opts) {
|
|
482
|
+
const idx = indexTokens(system.tokens, system.meta.prefix);
|
|
483
|
+
const known = knownFromSystem(system, idx);
|
|
484
|
+
const exclude = [...system.meta.lint.exclude, 'node_modules', '.git'].map((e) => new RegExp(`(^|/)${e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/|$)`));
|
|
485
|
+
const allow = system.meta.lint.allow.map(globToRe);
|
|
486
|
+
const designAbs = resolve(designDir);
|
|
487
|
+
let files = [];
|
|
488
|
+
if (opts.changed) {
|
|
489
|
+
try {
|
|
490
|
+
const outp = execSync('git diff --name-only HEAD; git ls-files --others --exclude-standard', { cwd: opts.root, encoding: 'utf8' });
|
|
491
|
+
files = [...new Set(outp.split('\n').filter(Boolean))].map((f) => resolve(opts.root, f)).filter((f) => existsSync(f) && EXT.has(extname(f)));
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
files = [];
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else if (opts.paths?.length) {
|
|
498
|
+
for (const pth of opts.paths) {
|
|
499
|
+
const abs = resolve(opts.root, pth);
|
|
500
|
+
if (!existsSync(abs))
|
|
501
|
+
continue;
|
|
502
|
+
if (statSync(abs).isDirectory())
|
|
503
|
+
walk(abs, exclude, files);
|
|
504
|
+
else
|
|
505
|
+
files.push(abs);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
for (const d of system.meta.lint.include)
|
|
510
|
+
walk(resolve(opts.root, d), exclude, files);
|
|
511
|
+
if (!files.length)
|
|
512
|
+
walk(opts.root, exclude, files);
|
|
513
|
+
}
|
|
514
|
+
files = files.filter((f) => !f.startsWith(designAbs + '/') && !allow.some((r) => r.test(f)) && !exclude.some((r) => r.test(relative(opts.root, f))));
|
|
515
|
+
const violations = [];
|
|
516
|
+
for (const f of files) {
|
|
517
|
+
const content = readFileSync(f, 'utf8');
|
|
518
|
+
for (const v of lintSource(known, f, content, { tailwind: system.meta.lint.tailwind }))
|
|
519
|
+
violations.push({ ...v, file: relative(opts.root, f) || f });
|
|
520
|
+
}
|
|
521
|
+
violations.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
|
|
522
|
+
return { files: files.length, violations, summary: { errors: violations.filter((v) => v.severity === 'error').length, warnings: violations.filter((v) => v.severity === 'warn').length } };
|
|
523
|
+
}
|
|
524
|
+
export function formatReport(res) {
|
|
525
|
+
if (!res.violations.length)
|
|
526
|
+
return `✓ canon lint: ${res.files} file(s), no violations`;
|
|
527
|
+
const lines = [];
|
|
528
|
+
let cur = '';
|
|
529
|
+
for (const v of res.violations) {
|
|
530
|
+
if (v.file !== cur) {
|
|
531
|
+
cur = v.file;
|
|
532
|
+
lines.push(`\n${cur}`);
|
|
533
|
+
}
|
|
534
|
+
lines.push(` ${String(v.line).padStart(4)}:${String(v.col).padEnd(3)} ${v.severity === 'error' ? '✗' : '!'} ${v.rule.padEnd(18)} ${v.message}${v.suggestion ? `\n → ${v.suggestion}` : ''}`);
|
|
535
|
+
}
|
|
536
|
+
lines.push(`\n${res.summary.errors} error(s), ${res.summary.warnings} warning(s) in ${res.files} file(s). Rules: DESIGN.md §7. Silence a line with /* canon-allow */.`);
|
|
537
|
+
return lines.join('\n');
|
|
538
|
+
}
|