add lint script

This commit is contained in:
Tomáš Mládek 2025-07-11 21:17:32 +02:00
parent e9516556e2
commit b6e6d3e23b
3 changed files with 1481 additions and 12 deletions

572
lint_intro.ts Normal file
View file

@ -0,0 +1,572 @@
#!/usr/bin/env node
import * as fs from "fs";
import * as path from "path";
import { Command } from "@commander-js/extra-typings";
import { DOMParser, Document, Element } from "@xmldom/xmldom";
interface ElementInfo {
type: string;
element: Element;
properties: Record<string, string>;
descriptions: string[];
}
interface LintWarning {
code: string;
message: string;
element?: ElementInfo;
}
interface LintContext {
filePath: string;
fileDir: string;
svgDoc: Document;
elements: ElementInfo[];
warnings: LintWarning[];
}
// Setup the command line interface
const program = new Command()
.name("svg-linter")
.description("Lints SVG files according to our constraints")
.version("0.1.0")
.argument("<file>", "Path to the SVG file to lint")
.option(
"--inspect",
"Inspect mode - outputs all SVG elements and their properties"
)
.parse();
const options = program.opts();
/**
* Reads and parses an SVG file
* @param filePath Path to the SVG file
* @returns The parsed SVG document
*/
function parseSvgFile(filePath: string): Document {
try {
const resolvedPath = path.resolve(filePath);
const svgContent = fs.readFileSync(resolvedPath, "utf-8");
const parser = new DOMParser();
return parser.parseFromString(svgContent, "image/svg+xml");
} catch (error) {
console.error(
`Error reading or parsing SVG file: ${
error instanceof Error ? error.message : String(error)
}`
);
process.exit(1);
}
}
/**
* Extracts properties from an SVG element
* @param element The SVG element to extract properties from
* @returns Record of property name to value
*/
function extractElementProperties(element: Element): Record<string, string> {
const properties: Record<string, string> = {};
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes[i];
properties[attr.name] = attr.value;
}
return properties;
}
/**
* Extracts all elements of a specific type from an SVG document
* @param doc The SVG document
* @param elementType The element type to extract
* @returns Array of elements with their properties
*/
/**
* Extract desc elements from a parent element
* @param element The parent element
* @returns Array of text content from desc elements
*/
function extractDescElements(element: Element): string[] {
const descriptions: string[] = [];
const descElements = element.getElementsByTagName("desc");
for (let i = 0; i < descElements.length; i++) {
const descElement = descElements[i];
const textContent = descElement.textContent;
if (textContent) {
descriptions.push(textContent.trim());
}
}
return descriptions;
}
function extractElements(doc: Document, elementType: string): ElementInfo[] {
const elements = doc.getElementsByTagName(elementType);
const result: ElementInfo[] = [];
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
result.push({
type: elementType,
element: element,
properties: extractElementProperties(element),
descriptions: extractDescElements(element),
});
}
return result;
}
/**
* Linting functions
*/
// Lint: There should be exactly one element with an id "start", and it should be a rect
function lintStartRect(context: LintContext): void {
const startElements = context.elements.filter(
(el) => el.properties.id === "start"
);
if (startElements.length === 0) {
context.warnings.push({
code: "START_MISSING",
message:
"No element with id 'start' found. One rect with id='start' is required.",
});
return;
}
if (startElements.length > 1) {
context.warnings.push({
code: "START_DUPLICATE",
message: `Found ${startElements.length} elements with id 'start'. Only one is allowed.`,
});
}
const nonRectStarts = startElements.filter((el) => el.type !== "rect");
if (nonRectStarts.length > 0) {
context.warnings.push({
code: "START_NOT_RECT",
message: `Element with id 'start' must be a rect, but found: ${nonRectStarts
.map((el) => el.type)
.join(", ")}.`,
element: nonRectStarts[0],
});
}
}
// Lint: For circles with a description element, the description should point to a relative path to an existing file
function lintCircleDescriptions(context: LintContext): void {
const circlesWithDesc = context.elements.filter(
(el) => el.type === "circle" && el.descriptions.length > 0
);
circlesWithDesc.forEach((circle) => {
circle.descriptions.forEach((desc) => {
// Check if description appears to be a file path
if (desc.includes("/") || desc.includes(".")) {
// Try to resolve the path relative to the SVG file's directory
const descPath = path.resolve(context.fileDir, desc);
if (!fs.existsSync(descPath)) {
context.warnings.push({
code: "CIRCLE_DESC_FILE_MISSING",
message: `Circle (id=${circle.properties.id ||
"no-id"}) has description pointing to non-existent file: ${desc}`,
element: circle,
});
}
}
});
});
}
// Lint: If there's an ellipse with any description, emit a warning
function lintEllipseDescriptions(context: LintContext): void {
const ellipsesWithDesc = context.elements.filter(
(el) => el.type === "ellipse" && el.descriptions.length > 0
);
ellipsesWithDesc.forEach((ellipse) => {
// Calculate average radius
const rx = parseFloat(ellipse.properties.rx || "0");
const ry = parseFloat(ellipse.properties.ry || "0");
const avgRadius = (rx + ry) / 2;
context.warnings.push({
code: "ELLIPSE_WITH_DESC",
message: `Ellipse (id=${ellipse.properties.id ||
"no-id"}) has a description. It will be processed as a circle with radius ${avgRadius} (average of rx=${rx} and ry=${ry}).`,
element: ellipse,
});
});
}
// Lint: Check that hyperlinks starting with "anchor" point to existing anchors and all anchors are referenced
function lintHyperlinkAnchors(context: LintContext): void {
// Find all anchor elements (rect elements with IDs starting with "anchor")
const anchorElements = context.elements.filter(
(el) => el.type === "rect" && el.properties.id?.startsWith("anchor")
);
// Find all hyperlinks (a elements)
const aElements = Array.from(context.svgDoc.getElementsByTagName("a"));
// Track anchors that have been referenced
const referencedAnchors = new Set<string>();
// Check all hyperlinks for valid anchor references
aElements.forEach((aElement) => {
const href = aElement.getAttribute("xlink:href");
if (!href) return;
if (href.startsWith("anchor")) {
// This is an anchor reference - check if the anchor exists
const anchorId = href.startsWith("#") ? href.substring(1) : href;
const targetAnchor = anchorElements.find(
(el) => el.properties.id === anchorId
);
if (!targetAnchor) {
// Hyperlink points to a non-existent anchor
context.warnings.push({
code: "BROKEN_ANCHOR_LINK",
message: `Hyperlink points to non-existent anchor: ${href}`,
});
} else {
// Mark this anchor as referenced
referencedAnchors.add(anchorId);
}
}
});
// Check for unreferenced anchors
anchorElements.forEach((anchor) => {
if (!referencedAnchors.has(anchor.properties.id)) {
context.warnings.push({
code: "UNREFERENCED_ANCHOR",
message: `Anchor element with id '${anchor.properties.id}' is not referenced by any hyperlink.`,
element: anchor,
});
}
});
}
// Lint: Validate that image elements with descriptions follow the proper format for video scrolls
function lintVideoScrollPaths(context: LintContext): void {
const imagesWithDesc = context.elements.filter(
(el) => el.type === "image" && el.descriptions.length > 0
);
imagesWithDesc.forEach((image) => {
const desc = image.descriptions[0];
const descLines = desc.split("\n").filter((line) => line.trim().length > 0);
// Check that the description has at least 2 lines
if (descLines.length < 2) {
context.warnings.push({
code: "INVALID_SCROLL_DESC_FORMAT",
message: `Image (id=${image.properties.id ||
"no-id"}) has a description that doesn't follow the required format: first line for direction, second line for file path.`,
element: image,
});
return;
}
// First line should be a valid direction
const directionLine = descLines[0];
const validDirections = [
"up",
"down",
"left",
"right",
"up left",
"up right",
"down left",
"down right",
];
// Check if any valid direction is in the direction line
const hasValidDirection = validDirections.some((dir) =>
directionLine.toLowerCase().includes(dir)
);
if (!hasValidDirection) {
context.warnings.push({
code: "INVALID_SCROLL_DIRECTION",
message: `Image (id=${image.properties.id ||
"no-id"}) has a description with invalid scroll direction: "${directionLine}". Valid directions: ${validDirections.join(
", "
)}.`,
element: image,
});
}
// Second line should point to an existing file
const filePath = descLines[1].replace(/^\//, "");
try {
const fullPath = path.resolve(context.fileDir, filePath);
if (!fs.existsSync(fullPath)) {
context.warnings.push({
code: "SCROLL_FILE_MISSING",
message: `Image (id=${image.properties.id ||
"no-id"}) references a non-existent file in description: ${filePath}`,
element: image,
});
}
} catch (error) {
// Path resolution error
context.warnings.push({
code: "SCROLL_PATH_ERROR",
message: `Error checking path for image (id=${image.properties.id ||
"no-id"}): ${error instanceof Error ? error.message : String(error)}`,
element: image,
});
}
// Also check if xlink:href exists and points to a valid directory
const xlinkHref = image.properties["xlink:href"];
if (!xlinkHref) {
context.warnings.push({
code: "SCROLL_MISSING_HREF",
message: `Image (id=${image.properties.id ||
"no-id"}) with scroll description is missing xlink:href attribute`,
element: image,
});
} else if (!xlinkHref.includes("/")) {
context.warnings.push({
code: "INVALID_SCROLL_PATH",
message: `Image (id=${image.properties.id ||
"no-id"}) has an xlink:href that doesn't point to a directory: ${xlinkHref}`,
element: image,
});
}
});
}
// Lint: Check that all element IDs are unique across the SVG
function lintIdUniqueness(context: LintContext): void {
// Create a map to track IDs and their elements
const idMap = new Map<string, ElementInfo[]>();
// Collect all elements with IDs
context.elements.forEach((element) => {
if (element.properties.id) {
const elementsWithId = idMap.get(element.properties.id) || [];
elementsWithId.push(element);
idMap.set(element.properties.id, elementsWithId);
}
});
// Check for duplicates
idMap.forEach((elements, id) => {
if (elements.length > 1) {
context.warnings.push({
code: "DUPLICATE_ID",
message: `ID '${id}' is used by ${
elements.length
} elements: ${elements.map((e) => e.type).join(", ")}.`,
element: elements[0],
});
}
});
}
// Lint: Check that files referenced in videoscroll file lists exist
function lintVideoScrollFileContents(context: LintContext): void {
// Find all image elements with descriptions that follow the videoscroll format
const imagesWithDesc = context.elements.filter(
(el) => el.type === "image" && el.descriptions.length > 0
);
// Process only images with valid descriptions (at least 2 lines)
imagesWithDesc.forEach((image) => {
const desc = image.descriptions[0];
const descLines = desc.split("\n").filter((line) => line.trim().length > 0);
// Skip if the description doesn't have at least 2 lines
if (descLines.length < 2) return;
// Get the file list path from the second line
const fileListPath = descLines[1].replace(/^\//, "");
try {
// Resolve the full path to the file list
const fullFileListPath = path.resolve(context.fileDir, fileListPath);
// Skip if the file list doesn't exist (already checked in lintVideoScrollPaths)
if (!fs.existsSync(fullFileListPath)) return;
// Read the file list contents
const fileListContent = fs.readFileSync(fullFileListPath, "utf-8");
const referencedFiles = fileListContent
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
// Get the directory containing the file list
const fileListDir = path.dirname(fullFileListPath);
// Check if each referenced file exists
const missingFiles: string[] = [];
referencedFiles.forEach((referencedFile) => {
const fullFilePath = path.resolve(fileListDir, referencedFile);
if (!fs.existsSync(fullFilePath)) {
missingFiles.push(referencedFile);
}
});
// If there are missing files, emit a warning
if (missingFiles.length > 0) {
context.warnings.push({
code: "SCROLL_MISSING_FILES",
message: `Image (id=${image.properties.id ||
"no-id"}) references a file list (${fileListPath}) that contains ${
missingFiles.length
} missing files: ${missingFiles.join(", ")}.`,
element: image,
});
}
} catch (error) {
// Error reading or parsing the file list
context.warnings.push({
code: "SCROLL_FILE_LIST_ERROR",
message: `Error reading file list for image (id=${image.properties.id ||
"no-id"}): ${error instanceof Error ? error.message : String(error)}`,
element: image,
});
}
});
}
// Run all lint checks
function runAllLints(context: LintContext): void {
lintStartRect(context);
lintCircleDescriptions(context);
lintEllipseDescriptions(context);
lintHyperlinkAnchors(context);
lintVideoScrollPaths(context);
lintIdUniqueness(context);
lintVideoScrollFileContents(context);
}
// Output warnings
function outputWarnings(context: LintContext): void {
if (context.warnings.length === 0) {
console.log("✅ SVG file passes all lints.");
return;
}
console.log("\n⚠ Lint Warnings:");
console.log("=================\n");
context.warnings.forEach((warning, index) => {
console.log(`${index + 1}. [${warning.code}] ${warning.message}`);
if (warning.element) {
console.log(
` Element: ${warning.element.type}${
warning.element.properties.id
? ` (id=${warning.element.properties.id})`
: ""
}`
);
}
console.log("");
});
console.log(`Total warnings: ${context.warnings.length}`);
}
// Output inspect details
function outputInspectDetails(elements: ElementInfo[]): void {
console.log("\nElements found in the SVG:");
console.log("========================\n");
if (elements.length === 0) {
console.log(
"No matching elements (image, rect, ellipse, circle) found in the SVG."
);
return;
}
elements.forEach((element, index) => {
console.log(`${index + 1}. Type: ${element.type}`);
console.log(" Properties:");
Object.entries(element.properties).forEach(([name, value]) => {
if (value.length > 128) {
console.log(` - ${name}: ${value.substring(0, 128)}...`);
} else {
console.log(` - ${name}: ${value}`);
}
});
// Output descriptions if any exist
if (element.descriptions.length > 0) {
console.log(" Descriptions:");
element.descriptions.forEach((desc, descIndex) => {
if (desc.length > 128) {
console.log(` - ${descIndex + 1}: ${desc.substring(0, 128)}...`);
} else {
console.log(` - ${descIndex + 1}: ${desc}`);
}
});
}
console.log("");
});
console.log(`Total elements found: ${elements.length}`);
}
/**
* Main function to process the SVG file
*/
function main() {
const options = program.opts();
const file = program.args[0];
console.log(`Processing SVG file: ${file}`);
const svgDoc = parseSvgFile(file);
const fileDir = path.dirname(path.resolve(file));
// Extract elements of interest
const elementTypes = ["image", "rect", "ellipse", "circle"];
const allElements: ElementInfo[] = [];
elementTypes.forEach((type) => {
const elements = extractElements(svgDoc, type);
allElements.push(...elements);
});
// Create lint context
const context: LintContext = {
filePath: file,
fileDir: fileDir,
svgDoc: svgDoc,
elements: allElements,
warnings: [],
};
// Run all lint checks
runAllLints(context);
// Output inspection details if requested
if (options.inspect) {
outputInspectDetails(allElements);
}
// Output warnings
outputWarnings(context);
// Exit with appropriate code
if (context.warnings.length > 0) {
process.exit(1);
} else {
process.exit(0);
}
}
// Execute the main function
main();

917
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,12 +17,16 @@
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@types/stats.js": "^0.17.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@xmldom/xmldom": "^0.9.8",
"commander": "^14.0.0",
"tsx": "^4.20.3",
"typescript": "~3.9.3"
}
}