mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-06-18 10:19:03 -04:00
645 lines
17 KiB
TypeScript
645 lines
17 KiB
TypeScript
/**
|
|
* no-raw-ui Svelte lint script
|
|
*
|
|
* Enforces the convention that UI code uses wrapper components from `$ui/*`
|
|
* rather than raw HTML elements. Parses `.svelte` files with the official
|
|
* Svelte 5 compiler AST and walks the template tree looking for banned tags.
|
|
*
|
|
* Scope: `.svelte` files under `src/routes/` and `src/lib/client/`, excluding
|
|
* `src/lib/client/ui/` (those ARE the wrapper components and may use raw HTML).
|
|
*
|
|
* Banned tags: <button>, <input>, <select>, <textarea>, <dialog>, <table>.
|
|
*
|
|
* Exemption: <input type="hidden"> when the `type` attribute is a static string
|
|
* literal equal to `"hidden"`. Hidden inputs are SvelteKit form-action plumbing,
|
|
* not visible UI, and there are ~140 of them in the codebase.
|
|
*
|
|
* Escape hatch: an HTML comment placed immediately before the element:
|
|
*
|
|
* <!-- lint-disable-next-line no-raw-ui -- reason goes here -->
|
|
* <table class="w-full">
|
|
*
|
|
* The reason (after `--`) is required and must be non-empty. Malformed
|
|
* directives (missing reason, wrong rule name, missing `--`) produce an error
|
|
* diagnostic instead of silently exempting the element.
|
|
*
|
|
* Usage:
|
|
* deno task lint:ui
|
|
*
|
|
* Issue: https://github.com/Dictionarry-Hub/profilarr/issues/298
|
|
*/
|
|
|
|
import { parse } from 'svelte/compiler';
|
|
import {
|
|
buildLineOffsets,
|
|
classifyDirective,
|
|
collectFiles,
|
|
type Colorizer,
|
|
offsetToLineCol,
|
|
pickColorizer
|
|
} from './_lib.ts';
|
|
|
|
// ============================================================================
|
|
// CONFIGURATION
|
|
// ============================================================================
|
|
|
|
const RULE_NAME = 'no-raw-ui';
|
|
|
|
const BANNED_TAGS = new Set(['button', 'input', 'select', 'textarea', 'dialog', 'table']);
|
|
|
|
const SCOPE_ROOTS = ['src/routes', 'src/lib/client'];
|
|
const EXCLUDED_PREFIX = 'src/lib/client/ui/';
|
|
|
|
// Fast pre-filter: if none of these tag names appear in a file's raw source,
|
|
// the parse can be skipped entirely.
|
|
const BANNED_SCAN_RE = /<(button|input|select|textarea|dialog|table)\b/;
|
|
|
|
// ============================================================================
|
|
// SUGGESTIONS
|
|
// ============================================================================
|
|
|
|
interface Suggestion {
|
|
component: string;
|
|
note?: string;
|
|
}
|
|
|
|
const SIMPLE_SUGGESTIONS: Record<string, Suggestion> = {
|
|
button: { component: '$ui/button/Button.svelte' },
|
|
select: { component: '$ui/dropdown/DropdownSelect.svelte' },
|
|
textarea: { component: '$ui/form/MarkdownInput.svelte' },
|
|
dialog: { component: '$ui/modal/Modal.svelte' },
|
|
table: { component: '$ui/table/Table.svelte' }
|
|
};
|
|
|
|
const INPUT_SUGGESTIONS: Record<string, Suggestion> = {
|
|
checkbox: {
|
|
component: '$ui/toggle/Toggle.svelte',
|
|
note: 'or $ui/form/IconCheckbox.svelte'
|
|
},
|
|
number: { component: '$ui/form/NumberInput.svelte' },
|
|
date: { component: '$ui/form/DateInput.svelte' },
|
|
time: { component: '$ui/form/TimeInput.svelte' }
|
|
};
|
|
|
|
const INPUT_DEFAULT: Suggestion = { component: '$ui/form/FormInput.svelte' };
|
|
|
|
// ============================================================================
|
|
// TYPES
|
|
// ============================================================================
|
|
|
|
interface BaseViolation {
|
|
file: string;
|
|
line: number;
|
|
column: number;
|
|
}
|
|
|
|
interface RawUiViolation extends BaseViolation {
|
|
kind: 'raw-ui';
|
|
tag: string;
|
|
suggestion: Suggestion;
|
|
}
|
|
|
|
interface MalformedDirectiveViolation extends BaseViolation {
|
|
kind: 'malformed-directive';
|
|
reason: string;
|
|
}
|
|
|
|
interface ParseErrorViolation extends BaseViolation {
|
|
kind: 'parse-error';
|
|
message: string;
|
|
}
|
|
|
|
interface ReadErrorViolation extends BaseViolation {
|
|
kind: 'read-error';
|
|
message: string;
|
|
}
|
|
|
|
type Violation =
|
|
| RawUiViolation
|
|
| MalformedDirectiveViolation
|
|
| ParseErrorViolation
|
|
| ReadErrorViolation;
|
|
|
|
interface WalkContext {
|
|
file: string;
|
|
offsets: number[];
|
|
out: Violation[];
|
|
}
|
|
|
|
// Svelte AST types - we pin just the shapes we touch, since importing the full
|
|
// `AST` namespace from `npm:svelte/compiler` through Deno's compat shim is
|
|
// fiddly. Keeping these minimal avoids coupling to compiler internals.
|
|
|
|
interface BaseNode {
|
|
type: string;
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
interface TextNode extends BaseNode {
|
|
type: 'Text';
|
|
data: string;
|
|
}
|
|
|
|
interface CommentNode extends BaseNode {
|
|
type: 'Comment';
|
|
data: string;
|
|
}
|
|
|
|
interface Fragment {
|
|
type: 'Fragment';
|
|
nodes: AnyNode[];
|
|
}
|
|
|
|
interface AttributeNode extends BaseNode {
|
|
type: 'Attribute';
|
|
name: string;
|
|
value: true | TextNode[] | AnyNode[];
|
|
}
|
|
|
|
interface RegularElement extends BaseNode {
|
|
type: 'RegularElement';
|
|
name: string;
|
|
attributes: AnyNode[];
|
|
fragment: Fragment;
|
|
}
|
|
|
|
interface ElementLike extends BaseNode {
|
|
type: string;
|
|
fragment?: Fragment;
|
|
}
|
|
|
|
interface IfBlock extends BaseNode {
|
|
type: 'IfBlock';
|
|
consequent: Fragment;
|
|
alternate: Fragment | null;
|
|
}
|
|
|
|
interface EachBlock extends BaseNode {
|
|
type: 'EachBlock';
|
|
body: Fragment;
|
|
fallback: Fragment | null;
|
|
}
|
|
|
|
interface AwaitBlock extends BaseNode {
|
|
type: 'AwaitBlock';
|
|
pending: Fragment | null;
|
|
then: Fragment | null;
|
|
catch: Fragment | null;
|
|
}
|
|
|
|
interface KeyBlock extends BaseNode {
|
|
type: 'KeyBlock';
|
|
fragment: Fragment;
|
|
}
|
|
|
|
interface SnippetBlock extends BaseNode {
|
|
type: 'SnippetBlock';
|
|
body: Fragment;
|
|
}
|
|
|
|
// deno-lint-ignore no-explicit-any
|
|
type AnyNode = any;
|
|
|
|
interface SvelteRoot {
|
|
type: 'Root';
|
|
fragment: Fragment;
|
|
}
|
|
|
|
// ============================================================================
|
|
// FILE DISCOVERY
|
|
// ============================================================================
|
|
|
|
function collectSvelteFiles(): Promise<string[]> {
|
|
return collectFiles({
|
|
roots: SCOPE_ROOTS,
|
|
acceptFile: (rel) => rel.endsWith('.svelte') && !rel.startsWith(EXCLUDED_PREFIX)
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// ATTRIBUTE HELPERS
|
|
// ============================================================================
|
|
|
|
function getStaticAttrValue(attr: AttributeNode): string | null {
|
|
if (attr.value === true) return '';
|
|
if (!Array.isArray(attr.value)) return null;
|
|
let out = '';
|
|
for (const part of attr.value) {
|
|
if (!part || part.type !== 'Text') return null;
|
|
out += (part as TextNode).data;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function getInputTypeAttr(el: RegularElement): { isStatic: boolean; value: string | null } {
|
|
for (const attr of el.attributes) {
|
|
if (!attr || attr.type !== 'Attribute') continue;
|
|
const a = attr as AttributeNode;
|
|
if (a.name.toLowerCase() !== 'type') continue;
|
|
const str = getStaticAttrValue(a);
|
|
return { isStatic: str !== null, value: str };
|
|
}
|
|
return { isStatic: true, value: null };
|
|
}
|
|
|
|
function suggestionFor(el: RegularElement): Suggestion {
|
|
if (el.name !== 'input') {
|
|
return SIMPLE_SUGGESTIONS[el.name];
|
|
}
|
|
const { isStatic, value } = getInputTypeAttr(el);
|
|
if (isStatic && value && INPUT_SUGGESTIONS[value]) {
|
|
return INPUT_SUGGESTIONS[value];
|
|
}
|
|
return INPUT_DEFAULT;
|
|
}
|
|
|
|
// ============================================================================
|
|
// ESCAPE-HATCH COMMENT
|
|
// ============================================================================
|
|
|
|
function precedingCommentFor(siblings: AnyNode[], index: number): CommentNode | null {
|
|
for (let i = index - 1; i >= 0; i--) {
|
|
const prev = siblings[i];
|
|
if (!prev) continue;
|
|
if (prev.type === 'Comment') return prev as CommentNode;
|
|
if (prev.type === 'Text') {
|
|
const data = (prev as TextNode).data;
|
|
if (/^\s*$/.test(data)) continue;
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ============================================================================
|
|
// WALKER
|
|
// ============================================================================
|
|
|
|
function walkFragment(nodes: AnyNode[], ctx: WalkContext): void {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
if (!node || typeof node.type !== 'string') continue;
|
|
|
|
switch (node.type) {
|
|
case 'RegularElement': {
|
|
const el = node as RegularElement;
|
|
if (BANNED_TAGS.has(el.name)) {
|
|
checkElement(el, nodes, i, ctx);
|
|
}
|
|
if (el.fragment?.nodes) {
|
|
walkFragment(el.fragment.nodes, ctx);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'Component':
|
|
case 'TitleElement':
|
|
case 'SlotElement':
|
|
case 'SvelteComponent':
|
|
case 'SvelteElement':
|
|
case 'SvelteBody':
|
|
case 'SvelteBoundary':
|
|
case 'SvelteDocument':
|
|
case 'SvelteFragment':
|
|
case 'SvelteHead':
|
|
case 'SvelteSelf':
|
|
case 'SvelteWindow': {
|
|
const el = node as ElementLike;
|
|
if (el.fragment?.nodes) {
|
|
walkFragment(el.fragment.nodes, ctx);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'IfBlock': {
|
|
const b = node as IfBlock;
|
|
if (b.consequent?.nodes) walkFragment(b.consequent.nodes, ctx);
|
|
if (b.alternate?.nodes) walkFragment(b.alternate.nodes, ctx);
|
|
break;
|
|
}
|
|
|
|
case 'EachBlock': {
|
|
const b = node as EachBlock;
|
|
if (b.body?.nodes) walkFragment(b.body.nodes, ctx);
|
|
if (b.fallback?.nodes) walkFragment(b.fallback.nodes, ctx);
|
|
break;
|
|
}
|
|
|
|
case 'AwaitBlock': {
|
|
const b = node as AwaitBlock;
|
|
if (b.pending?.nodes) walkFragment(b.pending.nodes, ctx);
|
|
if (b.then?.nodes) walkFragment(b.then.nodes, ctx);
|
|
if (b.catch?.nodes) walkFragment(b.catch.nodes, ctx);
|
|
break;
|
|
}
|
|
|
|
case 'KeyBlock': {
|
|
const b = node as KeyBlock;
|
|
if (b.fragment?.nodes) walkFragment(b.fragment.nodes, ctx);
|
|
break;
|
|
}
|
|
|
|
case 'SnippetBlock': {
|
|
const b = node as SnippetBlock;
|
|
if (b.body?.nodes) walkFragment(b.body.nodes, ctx);
|
|
break;
|
|
}
|
|
|
|
// Text, Comment, ExpressionTag, HtmlTag, ConstTag, DebugTag,
|
|
// RenderTag, AttachTag: no descent needed.
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkElement(
|
|
el: RegularElement,
|
|
siblings: AnyNode[],
|
|
idx: number,
|
|
ctx: WalkContext
|
|
): void {
|
|
// Hidden-input exemption (static only).
|
|
if (el.name === 'input') {
|
|
const t = getInputTypeAttr(el);
|
|
if (t.isStatic && t.value === 'hidden') return;
|
|
}
|
|
|
|
// Escape-hatch comment (immediate preceding sibling, skipping whitespace).
|
|
const comment = precedingCommentFor(siblings, idx);
|
|
if (comment) {
|
|
const status = classifyDirective(comment.data, RULE_NAME);
|
|
if (status.kind === 'valid') return;
|
|
if (status.kind === 'malformed') {
|
|
const { line, column } = offsetToLineCol(ctx.offsets, el.start);
|
|
ctx.out.push({
|
|
kind: 'malformed-directive',
|
|
file: ctx.file,
|
|
line,
|
|
column,
|
|
reason: status.reason
|
|
});
|
|
return;
|
|
}
|
|
// kind === 'none' falls through to normal violation.
|
|
}
|
|
|
|
const suggestion = suggestionFor(el);
|
|
const { line, column } = offsetToLineCol(ctx.offsets, el.start);
|
|
ctx.out.push({
|
|
kind: 'raw-ui',
|
|
file: ctx.file,
|
|
line,
|
|
column,
|
|
tag: el.name,
|
|
suggestion
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// PER-FILE LINTER
|
|
// ============================================================================
|
|
|
|
function lintFile(file: string, source: string): Violation[] {
|
|
const out: Violation[] = [];
|
|
let root: SvelteRoot;
|
|
try {
|
|
// deno-lint-ignore no-explicit-any
|
|
root = parse(source, { modern: true, filename: file } as any) as SvelteRoot;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
out.push({
|
|
kind: 'parse-error',
|
|
file,
|
|
line: 1,
|
|
column: 1,
|
|
message
|
|
});
|
|
return out;
|
|
}
|
|
|
|
if (!root?.fragment?.nodes) return out;
|
|
|
|
const offsets = buildLineOffsets(source);
|
|
walkFragment(root.fragment.nodes, { file, offsets, out });
|
|
return out;
|
|
}
|
|
|
|
// ============================================================================
|
|
// REPORT FORMATTER
|
|
// ============================================================================
|
|
|
|
function violationLabel(v: Violation): string {
|
|
switch (v.kind) {
|
|
case 'raw-ui':
|
|
return `<${v.tag}>`;
|
|
case 'malformed-directive':
|
|
return 'malformed directive';
|
|
case 'parse-error':
|
|
return 'parse error';
|
|
case 'read-error':
|
|
return 'read error';
|
|
}
|
|
}
|
|
|
|
function violationDetail(v: Violation, c: Colorizer): string {
|
|
switch (v.kind) {
|
|
case 'raw-ui': {
|
|
const arrow = c.dim('\u2192');
|
|
const component = c.cyan(v.suggestion.component);
|
|
const note = v.suggestion.note ? ` ${c.dim(`(${v.suggestion.note})`)}` : '';
|
|
return `${arrow} ${component}${note}`;
|
|
}
|
|
case 'malformed-directive':
|
|
return c.dim(v.reason);
|
|
case 'parse-error':
|
|
case 'read-error':
|
|
return c.dim(v.message);
|
|
}
|
|
}
|
|
|
|
interface SummaryRow {
|
|
count: number;
|
|
label: string;
|
|
suggestion: string | null;
|
|
isExtra: boolean;
|
|
}
|
|
|
|
function buildSummaryRows(violations: Violation[]): SummaryRow[] {
|
|
const tagCounts = new Map<string, { count: number; suggestion: Suggestion }>();
|
|
let malformedCount = 0;
|
|
let parseErrorCount = 0;
|
|
let readErrorCount = 0;
|
|
|
|
for (const v of violations) {
|
|
switch (v.kind) {
|
|
case 'raw-ui': {
|
|
const entry = tagCounts.get(v.tag);
|
|
if (entry) {
|
|
entry.count += 1;
|
|
} else {
|
|
tagCounts.set(v.tag, { count: 1, suggestion: v.suggestion });
|
|
}
|
|
break;
|
|
}
|
|
case 'malformed-directive':
|
|
malformedCount += 1;
|
|
break;
|
|
case 'parse-error':
|
|
parseErrorCount += 1;
|
|
break;
|
|
case 'read-error':
|
|
readErrorCount += 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const rows: SummaryRow[] = [];
|
|
const sortedTags = [...tagCounts.entries()].sort((a, b) => {
|
|
if (b[1].count !== a[1].count) return b[1].count - a[1].count;
|
|
return a[0].localeCompare(b[0]);
|
|
});
|
|
for (const [tag, entry] of sortedTags) {
|
|
rows.push({
|
|
count: entry.count,
|
|
label: `<${tag}>`,
|
|
suggestion: entry.suggestion.note
|
|
? `${entry.suggestion.component} (${entry.suggestion.note})`
|
|
: entry.suggestion.component,
|
|
isExtra: false
|
|
});
|
|
}
|
|
if (malformedCount > 0) {
|
|
rows.push({
|
|
count: malformedCount,
|
|
label: 'malformed directive',
|
|
suggestion: null,
|
|
isExtra: true
|
|
});
|
|
}
|
|
if (parseErrorCount > 0) {
|
|
rows.push({ count: parseErrorCount, label: 'parse error', suggestion: null, isExtra: true });
|
|
}
|
|
if (readErrorCount > 0) {
|
|
rows.push({ count: readErrorCount, label: 'read error', suggestion: null, isExtra: true });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function formatReport(violations: Violation[], c: Colorizer): string {
|
|
const lines: string[] = [];
|
|
|
|
// Bucket by file.
|
|
const byFile = new Map<string, Violation[]>();
|
|
for (const v of violations) {
|
|
const arr = byFile.get(v.file);
|
|
if (arr) arr.push(v);
|
|
else byFile.set(v.file, [v]);
|
|
}
|
|
|
|
const total = violations.length;
|
|
const fileCount = byFile.size;
|
|
const totalWord = total === 1 ? 'violation' : 'violations';
|
|
const fileWord = fileCount === 1 ? 'file' : 'files';
|
|
|
|
// ---- Header ----
|
|
lines.push(
|
|
`${c.bold(`${total} ${totalWord}`)} across ${c.bold(`${fileCount} ${fileWord}`)} ${c.dim(`(${RULE_NAME})`)}`
|
|
);
|
|
lines.push('');
|
|
|
|
// ---- Summary table ----
|
|
const rows = buildSummaryRows(violations);
|
|
if (rows.length > 0) {
|
|
const countWidth = Math.max(...rows.map((r) => String(r.count).length));
|
|
const labelWidth = Math.max(...rows.map((r) => r.label.length));
|
|
for (const row of rows) {
|
|
const countStr = String(row.count).padStart(countWidth);
|
|
const labelStr = row.label.padEnd(labelWidth);
|
|
const coloredLabel = row.isExtra ? c.yellow(labelStr) : c.red(labelStr);
|
|
const suggestionStr = row.suggestion ? ` ${c.cyan(row.suggestion)}` : '';
|
|
lines.push(` ${c.yellow(countStr)} ${coloredLabel}${suggestionStr}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
// ---- Separator ----
|
|
lines.push(c.dim('\u2500'.repeat(60)));
|
|
lines.push('');
|
|
|
|
// ---- Per-file groups ----
|
|
// Sort files by violation count (desc), ties broken alphabetically.
|
|
const fileEntries = [...byFile.entries()].sort((a, b) => {
|
|
if (b[1].length !== a[1].length) return b[1].length - a[1].length;
|
|
return a[0].localeCompare(b[0]);
|
|
});
|
|
|
|
for (let fi = 0; fi < fileEntries.length; fi++) {
|
|
const [file, fileViolations] = fileEntries[fi];
|
|
const sorted = [...fileViolations].sort((a, b) => {
|
|
if (a.line !== b.line) return a.line - b.line;
|
|
return a.column - b.column;
|
|
});
|
|
|
|
lines.push(`${c.bold(file)} ${c.dim(`(${fileViolations.length})`)}`);
|
|
|
|
const maxLocWidth = Math.max(...sorted.map((v) => `${v.line}:${v.column}`.length));
|
|
const maxLabelWidth = Math.max(...sorted.map((v) => violationLabel(v).length));
|
|
|
|
for (const v of sorted) {
|
|
const locStr = `${v.line}:${v.column}`.padStart(maxLocWidth);
|
|
const labelRaw = violationLabel(v).padEnd(maxLabelWidth);
|
|
const coloredLabel = v.kind === 'raw-ui' ? c.red(labelRaw) : c.yellow(labelRaw);
|
|
const detail = violationDetail(v, c);
|
|
lines.push(` ${c.dim(locStr)} ${coloredLabel} ${detail}`);
|
|
}
|
|
|
|
if (fi < fileEntries.length - 1) lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN
|
|
// ============================================================================
|
|
|
|
async function main(): Promise<void> {
|
|
const files = await collectSvelteFiles();
|
|
const all: Violation[] = [];
|
|
|
|
for (const file of files) {
|
|
let source: string;
|
|
try {
|
|
source = await Deno.readTextFile(file);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
all.push({
|
|
kind: 'read-error',
|
|
file,
|
|
line: 1,
|
|
column: 1,
|
|
message
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!BANNED_SCAN_RE.test(source)) continue;
|
|
all.push(...lintFile(file, source));
|
|
}
|
|
|
|
const c = pickColorizer();
|
|
|
|
if (all.length === 0) {
|
|
console.log(`${c.green('\u2713')} no raw UI element violations ${c.dim(`(${RULE_NAME})`)}`);
|
|
Deno.exit(0);
|
|
}
|
|
|
|
console.log(formatReport(all, c));
|
|
Deno.exit(1);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
await main();
|
|
}
|