refactor: isolate old and preview versions, add lint rule

This commit is contained in:
2026-09-05 19:41:31 +05:00
parent e662d1df24
commit e57faddfde
10 changed files with 515 additions and 42 deletions
+33
View File
@@ -0,0 +1,33 @@
/**
* Local ESLint plugin "isolation".
*
* Guarantees full isolation between the old UI branch and the new (preview)
* branch. Unlike no-restricted-imports (which matches the literal import
* specifier string only), these rules RESOLVE the specifier to a real file
* (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
* classify both sides by their actual location on disk.
*
* Why this is needed:
* - old code imports new things via RELATIVE paths (e.g. `../registry-schema`),
* which `$lib/...` glob patterns cannot catch;
* - the same target can be written many ways (`$lib/x`, `../x`, `../../x`),
* so enumerating literal strings is fragile.
*
* Configuration (per rule options):
* - `root`: path from the lint cwd to the source root (default "src");
* - `alias`: mapping from import aliases to dirs relative to root
* (default { "$lib": "lib" });
* - `old`, `new`: glob patterns (gitignore-style, `!` = negation) for the two
* branches. Files matched by neither are "shared" and unrestricted.
*/
import noMixedImports from "./no-mixed-imports.js";
export default {
meta: {
name: "isolation",
version: "1.0.0",
},
rules: {
"no-mixed-imports": noMixedImports,
},
};
@@ -0,0 +1,201 @@
/**
* Isolation rule: forbid mixing the "old" and the "new" (preview) branches.
*
* The rule RESOLVES every import specifier to a real file (handles both the
* `$lib/...` alias and relative `./` / `../` paths), classifies the source
* file and each import target by their actual location, and reports whenever an
* "old" file imports a "new" one or vice versa. Files that match neither side
* are "shared" (core/, i18n/, theme, assets, tests, ...) and can import freely.
*
* Options (object, all optional):
* - root: path from lint cwd to the source dir (default "src");
* - alias: { $lib: "lib" } — alias -> dir relative to root;
* - old, new: gitignore-style glob lists for the two branches. Both default
* to the current layout so that moving a module between branches is a
* one-line config change (e.g. everything old will live under `old/`).
*/
import fs from "node:fs";
import path from "node:path";
const DEFAULT_OLD = [
"routes/(old)/**",
"lib/registry/**",
"lib/registry.ts",
"lib/registry-helpers.ts",
"lib/categories.ts",
"lib/tools/**",
"lib/components/**",
"!lib/components/kit/**",
];
const DEFAULT_NEW = [
"routes/preview/**",
"lib/components/kit/**",
"lib/preview/**",
"lib/registry-new/**",
"lib/registry-schema.ts",
"lib/registry-schema.test.ts",
];
/** Escape everything except glob metacharacters, then handle **, *, ?. */
function globToRegExp(glob) {
let out = "^";
for (let i = 0; i < glob.length; i++) {
const ch = glob[i];
if (ch === "*" && glob[i + 1] === "*") {
out += ".*";
i++;
} else if (ch === "*") {
out += "[^/]*";
} else if (ch === "?") {
out += "[^/]";
} else if ("\\^$.|?*+()[]{}".includes(ch)) {
out += `\\${ch}`;
} else {
out += ch;
}
}
return new RegExp(out + "$");
}
/** gitignore-style: last-match-wins, `!` negates earlier positives. */
function matchesGlobList(patterns, rel) {
let included = false;
for (const raw of patterns) {
const negated = raw.startsWith("!");
const pattern = negated ? raw.slice(1) : raw;
if (globToRegExp(pattern).test(rel)) {
if (negated) return false;
included = true;
}
}
return included;
}
/** First existing candidate when resolving a bare path (no extension known). */
function firstExisting(base) {
const candidates = [
base,
`${base}.ts`,
`${base}.svelte`,
`${base}.svelte.ts`,
`${base}/index.ts`,
`${base}/index.svelte`,
];
for (const c of candidates) {
try {
if (fs.statSync(c).isFile()) return c;
} catch {
// keep trying
}
}
return null;
}
function toPortable(p) {
return p.replace(/\\/g, "/");
}
export default {
meta: {
type: "problem",
docs: {
description:
"Forbid imports between the old UI branch and the new (preview) branch.",
category: "Best Practices",
},
schema: [
{
type: "object",
properties: {
root: { type: "string" },
alias: { type: "object", additionalProperties: { type: "string" } },
old: { type: "array", items: { type: "string" } },
new: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
],
messages: {
noMixed:
'Isolation violation: "{{side}}" file imports "{{target}}" ({{targetRel}}).',
},
},
create(context) {
const options = context.options[0] || {};
const cwd = context.cwd;
const srcRoot = path.resolve(cwd, options.root || "src");
const alias = options.alias || { $lib: "lib" };
const oldPatterns = options.old || DEFAULT_OLD;
const newPatterns = options.new || DEFAULT_NEW;
/** Classify a path (already absolute or portable rel to srcRoot). */
function classify(absOrRel, relativeToRoot) {
const rel = relativeToRoot
? absOrRel
: toPortable(path.relative(srcRoot, absOrRel));
// new wins over old if a path is matched by both (e.g. components/kit
// is != lib/components/** via negation, but keep precedence safe).
if (matchesGlobList(newPatterns, rel)) return "new";
if (matchesGlobList(oldPatterns, rel)) return "old";
return "shared";
}
/** Resolve a specifier to a portable rel path from srcRoot, or null. */
function resolveToRel(spec, currentDir) {
if (spec.startsWith("$")) {
const dot = spec.indexOf("/");
const mapKey = spec.slice(0, dot === -1 ? spec.length : dot);
const dir = alias[mapKey];
if (!dir) return null;
const rest = dot === -1 ? "" : spec.slice(dot + 1);
const base = path.resolve(srcRoot, dir, rest);
const found = firstExisting(base);
if (!found) return null;
return toPortable(path.relative(srcRoot, found));
}
if (spec.startsWith("./") || spec.startsWith("../")) {
const base = path.resolve(currentDir, spec);
const found = firstExisting(base);
if (!found) return null;
const rel = path.relative(srcRoot, found);
if (rel.startsWith("..")) return null; // outside srcRoot
return toPortable(rel);
}
return null; // package import (svelte, vitest, @lucide, ...)
}
const filename = context.filename || context.physicalFilename;
const currentAbs = path.resolve(filename);
const currentRel = toPortable(path.relative(srcRoot, currentAbs));
const currentSide = classify(currentRel, true);
const currentDir = path.dirname(currentAbs);
if (currentSide === "shared") return {};
function checkSource(node) {
const spec = node.source?.value;
if (typeof spec !== "string") return;
const targetRel = resolveToRel(spec, currentDir);
if (!targetRel) return;
const targetSide = classify(targetRel, true);
if (
(currentSide === "old" && targetSide === "new") ||
(currentSide === "new" && targetSide === "old")
) {
context.report({
node,
messageId: "noMixed",
data: { side: currentSide, target: targetSide, targetRel },
});
}
}
return {
ImportDeclaration: checkSource,
ImportExpression: checkSource,
ExportNamedDeclaration: checkSource,
ExportAllDeclaration: checkSource,
};
},
};
+15
View File
@@ -2,6 +2,7 @@ import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js";
import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
@@ -114,6 +115,20 @@ export default tseslint.config(
"prefer-const": "off",
},
},
// ===== Изоляция старого UI (old) и нового preview =====================
// Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
// Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
// пути (и $lib, и относительные) и ругается на old→new / new→old.
// Общее (core/, i18n/, theme) разрешено обоим.
{
files: ["**/*.{ts,svelte}"],
plugins: {
isolation: isolationPlugin,
},
rules: {
"isolation/no-mixed-imports": "error",
},
},
// prettier — последним, чтобы гасить форматирующие правила из recommended.
prettier,
);
+184
View File
@@ -0,0 +1,184 @@
import {
AppWindow,
Binary,
Blend,
CalendarDays,
ClipboardPaste,
Circle,
Contrast,
Crop,
Crosshair,
Dices,
Droplet,
Droplets,
Eye,
FileImage,
FileOutput,
FilePlus2,
Film,
FlipHorizontal2,
Focus,
Frame,
Grid3x3,
Hammer,
Hash,
Image as ImageIcon,
Info,
Layers,
Link2,
Maximize2,
Minimize2,
Moon,
PaintBucket,
Palette,
Rainbow,
Repeat2,
RotateCw,
Ruler,
Scaling,
Scan,
Scissors,
SearchCheck,
FaceSlightlySmiling,
Square,
Stamp,
Star,
Sun,
Type,
WavesHorizontal,
ZoomIn,
ZoomOut,
} from "@lucide/svelte";
export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"resize-png": Scaling,
"crop-png": Crop,
"rotate-png": RotateCw,
"flip-png": FlipHorizontal2,
"grayscale-png": Contrast,
"invert-colors-png": Blend,
"adjust-brightness-contrast-png": Sun,
"convert-png-to-jpg": FileImage,
"convert-png-to-webp": FileImage,
"remove-color-from-png": Scissors,
"png-info": Info,
"jpg-to-png": FileImage,
"webp-to-png": FileImage,
"gif-to-png": Film,
"bmp-to-png": FileImage,
"ico-to-png": AppWindow,
"png-to-bmp": FileOutput,
"png-to-base64": Binary,
"base64-to-png": ClipboardPaste,
"png-to-data-uri": Link2,
"data-uri-to-png": ClipboardPaste,
"png-to-hex": Hash,
"hex-to-png": Hash,
"change-png-opacity": Droplet,
"sepia-png": Palette,
"change-png-hue": Rainbow,
"extract-channel-png": Layers,
"swap-channels-png": Repeat2,
"black-and-white-png": Contrast,
"posterize-png": Layers,
"two-colors-png": Blend,
"invert-alpha-png": Droplets,
"remove-alpha-channel-png": Square,
"set-alpha-channel-png": Droplet,
"extract-alpha-mask-png": Layers,
"round-corners-png": Frame,
"add-padding-png": Maximize2,
"add-border-png": Frame,
"fit-on-background-png": ImageIcon,
"tile-png": Grid3x3,
"center-by-alpha-png": Crosshair,
"create-empty-png": FilePlus2,
"single-color-png": PaintBucket,
"random-noise-png": Dices,
"linear-gradient-png": Sun,
"png-is-grayscale": SearchCheck,
"png-is-transparent": Eye,
"png-orientation": Ruler,
"blur-png": Droplets,
"sharpen-png": Focus,
"remove-background-png": Scissors,
"add-stroke-png": Square,
"add-text-png": Type,
"date-stamp-png": CalendarDays,
"png-to-hsl": Blend,
"png-to-hsv": Droplet,
"png-to-hsi": Focus,
"png-to-cmyk": PaintBucket,
"png-to-ycbcr": Layers,
"png-to-lab": Ruler,
"show-transparent-png": Eye,
"show-grayscale-pixels-png": Contrast,
"show-color-pixels-png": Rainbow,
"light-pixel-mask-png": Sun,
"dark-pixel-mask-png": Moon,
"unique-color-mask-png": Dices,
"extract-color-from-png": Focus,
"quantize-png": Contrast,
"decrease-color-count-png": Scaling,
"custom-palette-png": Palette,
"dithering-png": Dices,
"feather-edges-png": Droplets,
"clean-edges-png": Scissors,
"pixelate-png": Grid3x3,
"randomize-pixels-png": Dices,
"add-noise-png": Hash,
"silhouette-png": Contrast,
"trim-empty-space-png": Crop,
"change-canvas-size-png": Frame,
"change-aspect-ratio-png": Scaling,
"swap-orientation-png": RotateCw,
"symmetric-copy-png": FlipHorizontal2,
"compress-png": Minimize2,
"reduce-to-size-png": Scaling,
"png-file-size": Info,
"png-to-bytes": Binary,
"bytes-to-png": Binary,
"png-to-rgb-values": Hash,
"rgb-values-to-png": Hash,
"verify-is-png": SearchCheck,
"text-to-png": Type,
"emoji-to-png": FaceSlightlySmiling,
"placeholder-png": Frame,
"color-spectrum-png": Rainbow,
"random-colors-png": Dices,
"draw-grid-png": Grid3x3,
"circle-mask-png": Circle,
"square-mask-png": Square,
"star-mask-png": Star,
"wavy-mask-png": WavesHorizontal,
"watermark-tile-png": Stamp,
"watermark-image-png": FileImage,
"color-wheel-png": Rainbow,
"complementary-png": Contrast,
"triadic-png": Hash,
"tetradic-png": Grid3x3,
"analogous-png": Blend,
"monochromatic-png": Droplets,
"shades-png": Sun,
"mix-colors-png": PaintBucket,
"blend-two-png": Layers,
"step-colors-png": Scaling,
"sort-colors-png": SearchCheck,
"find-contour-png": Scan,
"make-thicker-png": ZoomIn,
"make-thinner-png": ZoomOut,
"harden-alpha-png": Sun,
"despeckle-alpha-png": SearchCheck,
"close-holes-png": Hammer,
"skew-png": Repeat2,
"rotate-free-png": RotateCw,
"zoom-png": ZoomIn,
"shift-png": Crosshair,
"vignette-png": Sun,
"jpeg-artifacts-png": Layers,
"gamma-png": Contrast,
"auto-contrast-png": Contrast,
"temperature-png": Sun,
"tint-png": Droplet,
"svg-to-png": FileImage,
};
+1 -3
View File
@@ -1,5 +1,4 @@
import type { CategoryId } from "./categories";
import type { ToolSchema } from "./registry-schema";
import type { CategoryId } from "./categories";
import type { OutputMime } from "./core/io";
import type { PixelImage } from "./core/types";
@@ -63,7 +62,6 @@ export type ToolEntry<P = Record<string, unknown>> = {
category: CategoryId;
sourceMode?: SourceMode;
params: ParamDef[];
schema?: ToolSchema<P>;
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
generate?: (params: P) => Promise<PixelImage> | PixelImage;
toText?: (img: PixelImage, params: P) => Promise<string> | string;
-12
View File
@@ -1,6 +1,5 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { field, toolSchema } from "../registry-schema";
import {
colorMask,
extractAlphaMask,
@@ -33,16 +32,6 @@ import {
import type { Position9 } from "../core/textdraw";
import { num, str } from "../registry-helpers";
interface AddStrokeParams {
color: string;
thickness: number;
}
const addStrokeSchema = toolSchema<AddStrokeParams>({
color: field.color({ default: "#ff0000" }),
thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
});
export function alphaEntries(): ToolEntry[] {
return [
{
@@ -412,7 +401,6 @@ export function alphaEntries(): ToolEntry[] {
description:
"Adds a colored ring outline around the opaque content with the chosen thickness.",
category: "alpha",
schema: addStrokeSchema,
params: [
{
id: "color",
-12
View File
@@ -1,6 +1,5 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { field, toolSchema } from "../registry-schema";
import {
crop,
expandCanvas,
@@ -25,16 +24,6 @@ import {
} from "../core/affine";
import { num, str } from "../registry-helpers";
interface AddBorderParams {
thickness: number;
color: string;
}
const addBorderSchema = toolSchema<AddBorderParams>({
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
color: field.color({ default: "#000000" }),
});
export function geometryEntries(): ToolEntry[] {
return [
{
@@ -241,7 +230,6 @@ export function geometryEntries(): ToolEntry[] {
description:
"Draws a colored frame of the chosen thickness around the image.",
category: "geometry",
schema: addBorderSchema,
params: [
{
id: "thickness",
@@ -1,6 +1,6 @@
<script lang="ts">
import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/preview/catalog";
import { TOOL_ICONS } from "$lib/tools/tool-icons";
import { TOOL_ICONS } from "$lib/preview/tool-icons";
import CatalogHeader from "$lib/components/kit/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/kit/CatalogToolbar.svelte";
import CatalogGroup from "$lib/components/kit/CatalogGroup.svelte";