fix(gate): knip sees the watch entry, the build must emit watch.html, and two hand copies are held
- The raw-source contract tests read files from disk (rawSources.testkit) instead of import.meta.glob, which knip counted as importing every src file — its unused-files lane could never name an orphan. knip now names src/watchMain.tsx as an entry (registered in docs/gates.md). - vite build fails when index.html or watch.html is not emitted. - The watch-entry import walk follows dynamic import() too. - audit_metrics.py's plan-code patterns are held equal to the guard's. - CrashBoundary is domain-free: it moves into components/ui.
This commit is contained in:
@@ -112,6 +112,7 @@ project checks needs a row.
|
||||
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
|
||||
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
|
||||
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
|
||||
|
||||
## Accepted findings
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
// The shared link's page (watch.html): the Vite plugin reads only index.html for entries.
|
||||
"entry": ["src/watchMain.tsx"],
|
||||
"project": ["**/*.{ts,tsx,js}"],
|
||||
"playwright": {
|
||||
"config": ["playwright.config.ts"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { CrashBoundary, type CrashCopy } from "./CrashBoundary";
|
||||
import { CrashBoundary, type CrashCopy } from "./ui/CrashBoundary";
|
||||
|
||||
const copy = (): CrashCopy => ({
|
||||
title: i18n.t("errors.boundary.title"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "../lib/rawSources.testkit";
|
||||
|
||||
// Every rail and every card of a card list is walkable by the keyboard (lib/listNav). Two
|
||||
// opt-in halves, both checked here, because either one missing silently leaves the keyboard out —
|
||||
@@ -79,7 +80,7 @@ export function inList(code: string, pos: number): boolean {
|
||||
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
|
||||
|
||||
function offenders(): { hits: string[]; tags: number; exempted: Set<string> } {
|
||||
const files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
|
||||
const files = readSources(__dirname, "./**/*.tsx");
|
||||
const hits: string[] = [];
|
||||
const exempted = new Set<string>();
|
||||
let tags = 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
import { Button } from "./ui/Button";
|
||||
import { errorAttrs, log } from "../../lib/log";
|
||||
import { reloadingForNewBuild } from "../../lib/staleBuild";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface CrashCopy {
|
||||
title: string;
|
||||
|
||||
@@ -41,7 +41,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -13,15 +13,12 @@
|
||||
// z-overlay`) was never checked at all, and a new viewer copying it would have slipped past
|
||||
// (found by a later review).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readSources } from "../../lib/rawSources.testkit";
|
||||
|
||||
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
|
||||
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
|
||||
// (found by a later review).
|
||||
const sources = import.meta.glob(["../**/*.tsx", "./*.tsx"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const sources = readSources(__dirname, ["../**/*.tsx", "./*.tsx"]);
|
||||
|
||||
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
|
||||
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "../../lib/rawSources.testkit";
|
||||
|
||||
// docs/DESIGN.md "Background menu": which pages have one is the owner's call (2026-09-26, scope A) —
|
||||
// the list pages whose toolbar sorts, filters or switches a view: the feed, the Plex library, the
|
||||
// channel manager, a playlist and the notification center. The tab-only pages (settings, config,
|
||||
// stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until
|
||||
// that decision is taken again.
|
||||
const files = import.meta.glob(["../**/*.tsx", "!../**/*.test.*", "!../design-lab/**", "!./**"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
const files = readSources(__dirname, [
|
||||
"../**/*.tsx",
|
||||
"!../**/*.test.*",
|
||||
"!../design-lab/**",
|
||||
"!./**",
|
||||
]) as Record<string, string>;
|
||||
|
||||
describe("the pages with a background menu", () => {
|
||||
it("are the five the owner chose", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "../../lib/rawSources.testkit";
|
||||
|
||||
// docs/DESIGN.md "Text stays selectable": a click surface whose text is a title or a name (a card, a
|
||||
// row, a person, a channel, a playlist, a log line) is `<Pressable selectable`, so its words select and
|
||||
@@ -8,11 +9,11 @@ import { describe, expect, it } from "vitest";
|
||||
// pinned here with the reason: a new Pressable, or one that lost its flag, fails this test until
|
||||
// someone decides.
|
||||
|
||||
const files = import.meta.glob(["../**/*.tsx", "!../**/*.test.*", "!../design-lab/**"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
const files = readSources(__dirname, [
|
||||
"../**/*.tsx",
|
||||
"!../**/*.test.*",
|
||||
"!../design-lab/**",
|
||||
]) as Record<string, string>;
|
||||
|
||||
/** file → [how many plain Pressables, why they are not titles]. */
|
||||
const PLAIN: Record<string, [number, string]> = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "../../lib/rawSources.testkit";
|
||||
|
||||
// The platform layer (`components/ui/`) may not import a feature component or a domain API slice —
|
||||
// eslint's `no-restricted-imports` enforces that. It cannot see an i18n KEY, though, and two
|
||||
@@ -17,11 +18,7 @@ const PLATFORM_NAMESPACES = new Set([
|
||||
"time",
|
||||
]);
|
||||
|
||||
const files = import.meta.glob("./**/*.{ts,tsx}", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const files = readSources(__dirname, "./**/*.{ts,tsx}");
|
||||
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
|
||||
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "./rawSources.testkit";
|
||||
|
||||
// Every `useMutation` NAMES what failed (owner decision: the toast's title says what, the
|
||||
// line under it why). The app-wide reporter (lib/queryClient) reports a mutation that names nothing
|
||||
@@ -36,11 +37,7 @@ export function mutationCalls(source: string): string[] {
|
||||
}
|
||||
|
||||
function unnamed(): { hits: string[]; mutations: number } {
|
||||
const files = import.meta.glob("../**/*.{ts,tsx}", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const files = readSources(__dirname, "../**/*.{ts,tsx}");
|
||||
const hits: string[] = [];
|
||||
let mutations = 0;
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "./rawSources.testkit";
|
||||
|
||||
// Every push that opens a new view over the current one remembers the list item it was opened from
|
||||
// (`lib/history.rememberNavPosition`), so Back hands focus back to that item. A push that
|
||||
@@ -10,11 +11,10 @@ import { describe, expect, it } from "vitest";
|
||||
// The assertion pins the SET of offending sites and checks the scan found pushes at all: a guard whose
|
||||
// glob stops matching is green over nothing.
|
||||
|
||||
const files = import.meta.glob(["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
const EXEMPT: Record<string, string> = {
|
||||
"lib/history.ts#_ov": "the overlay stack: an overlay entry opens no view over the page",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSources } from "./rawSources.testkit";
|
||||
|
||||
// Every infinite list's read-ahead waits while the list fetches ANYTHING and runs again when the fetch
|
||||
// settles. A read-ahead that only checks `isFetchingNextPage` calls during a background
|
||||
// refetch, joins it through NEXT_PAGE and loads no page: the grid stalls at its loaded end until the
|
||||
// reader scrolls the range. The jsdom virtualizer measures nothing, so the wiring is pinned in source.
|
||||
const files = import.meta.glob(["../components/**/*.tsx", "!../components/**/*.test.tsx"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
const files = readSources(__dirname, ["../components/**/*.tsx", "!../components/**/*.test.tsx"]);
|
||||
const source = (name: string) => {
|
||||
const hit = Object.entries(files).find(([path]) => path.endsWith(`/${name}`));
|
||||
if (!hit) throw new Error(`no ${name}`);
|
||||
|
||||
@@ -89,6 +89,20 @@ describe("plan codes", () => {
|
||||
expect(clean.filter(isPlanCode)).toEqual([]);
|
||||
});
|
||||
|
||||
it("are what the audit metric counts by (its copy of the patterns is held equal here)", () => {
|
||||
const py = readFileSync(new URL("scripts/audit_metrics.py", REPO), "utf8");
|
||||
// A pattern there is `NAME = re.compile(r"…" r"…", re.ASCII)`: its raw literals, joined.
|
||||
const pattern = (name: string) => {
|
||||
const start = py.indexOf(`${name} = re.compile(`);
|
||||
const call = py.slice(start, py.indexOf("re.ASCII", start));
|
||||
return [...call.matchAll(/r(["'])(.*?)\1/g)].map((m) => m[2]).join("");
|
||||
};
|
||||
// JavaScript's source escapes the slash a regex literal needs; Python's has none to escape.
|
||||
const js = (re: RegExp) => re.source.split(String.raw`\/`).join("/");
|
||||
expect(pattern("NOT_CODES")).toBe(js(NOT_CODES));
|
||||
expect(pattern("PLAN_CODE")).toBe(PLAN_CODES.map(js).join("|"));
|
||||
});
|
||||
|
||||
it("are not cited in the code", () => {
|
||||
const files = tracked();
|
||||
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { cardImage } from "./plexImage";
|
||||
import { readSources } from "./rawSources.testkit";
|
||||
|
||||
// A card's srcset names each Plex cut by its width, and the browser picks one by that width. The
|
||||
// widths are the image proxy's (backend `app/plex/imgcache.py` SIZES), with no wire format between
|
||||
@@ -51,14 +52,11 @@ it("names each backdrop cut by the width the proxy cuts it at", () => {
|
||||
// proxy's; the SET is pinned, so a new one fails here. Every <img>'s src counts, wherever it sits
|
||||
// among the attributes and whatever its variable is called: the first cut of this test read only a
|
||||
// leading `src={…thumb…}` and missed two of the three sites it was written for.
|
||||
const plexFiles = import.meta.glob(
|
||||
[
|
||||
const plexFiles = readSources(__dirname, [
|
||||
"../components/{Plex,plex}*.tsx",
|
||||
"../components/{Home,PosterTile,UpNextCard}.tsx",
|
||||
"!../components/**/*.test.tsx",
|
||||
],
|
||||
{ query: "?raw", import: "default", eager: true },
|
||||
);
|
||||
]);
|
||||
const ALLOWED = new Set([
|
||||
"plexCards.tsx pic.src", // cardImage's own src (srcSet/sizes beside it): the card cut
|
||||
"plexCards.tsx c.thumb", // cast photos: the person-image route, not the image proxy
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { matchesGlob, relative, resolve, sep } from "node:path";
|
||||
|
||||
// The source text a contract test scans, read from disk. Not `import.meta.glob(…, { query: "?raw" })`:
|
||||
// knip reads every such glob as an import of each file it matches, and two tests globbed all of src —
|
||||
// so its unused-files lane counted every source file as used and could never name an orphaned one
|
||||
// (the shared-link entry was "used" only by those globs).
|
||||
|
||||
/** Every file matching `patterns` (relative to `dir`, the calling test's directory; a leading "!"
|
||||
* excludes) with its text, keyed the way `import.meta.glob` keyed it: "./x.tsx", "../lib/y.ts". */
|
||||
export function readSources(dir: string, patterns: string | string[]): Record<string, string> {
|
||||
const list = typeof patterns === "string" ? [patterns] : patterns;
|
||||
const include = list.filter((p) => !p.startsWith("!"));
|
||||
const exclude = list.filter((p) => p.startsWith("!")).map((p) => key(p.slice(1)));
|
||||
const out: Record<string, string> = {};
|
||||
for (const pattern of include) {
|
||||
for (const rel of globSync(pattern, { cwd: dir })) {
|
||||
// Canonical, as Vite's: "../ui/Modal.tsx" from inside ui/ is "./Modal.tsx", matched once.
|
||||
const k = key(relative(dir, resolve(dir, rel)));
|
||||
// An exclusion names the file either way: "!../**/*.test.*" as the pattern spelled it
|
||||
// ("../lib/x.test.ts"), "!./**" by its canonical key ("./x.test.ts" from inside lib/).
|
||||
if (exclude.some((x) => matchesGlob(key(rel), x) || matchesGlob(k, x))) continue;
|
||||
out[k] = readFileSync(resolve(dir, rel), "utf8");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A path relative to the test's directory, as `import.meta.glob` spells it. */
|
||||
function key(path: string): string {
|
||||
const posix = path.split(sep).join("/");
|
||||
return posix.startsWith(".") ? posix : `./${posix}`;
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import ts from "typescript";
|
||||
// loads the player page and nothing of the app. That holds only while nothing the page imports
|
||||
// reaches back into the app — and the first build of the entry showed how easily it does: one
|
||||
// file-size formatter imported the app's i18n, and with it every locale of every area (≈ 120 KB gzip,
|
||||
// half the page). This walks the entry's static imports, as the bundler does, and names the path to
|
||||
// any of what the app alone may load.
|
||||
// half the page). This walks the entry's imports as the bundler does — the static ones and the lazy
|
||||
// `import("…")` chunks it would fetch later — and names the path to any of what the app alone may load.
|
||||
const SRC = resolve(__dirname);
|
||||
// Stands for an `import()` whose argument is not a string literal.
|
||||
const DYNAMIC = "<computed import()>";
|
||||
const FORBIDDEN: Record<string, string> = {
|
||||
"src/i18n/index.ts":
|
||||
"the app's i18n (every locale) — the page has its own strings (watchStrings)",
|
||||
@@ -21,9 +23,11 @@ const FORBIDDEN: Record<string, string> = {
|
||||
"src/lib/notifications.ts": "the app's notification center",
|
||||
"src/lib/logHooks.ts": "the app's client-log wiring",
|
||||
"flag-icons": "the app's flag sprites",
|
||||
[DYNAMIC]: "an import() of a computed path — no walk can say what it loads",
|
||||
};
|
||||
|
||||
function resolveSpec(from: string, spec: string): string | null {
|
||||
if (spec === DYNAMIC) return DYNAMIC;
|
||||
if (!spec.startsWith(".")) {
|
||||
const parts = spec.split("/");
|
||||
return parts.slice(0, spec.startsWith("@") ? 2 : 1).join("/");
|
||||
@@ -36,7 +40,8 @@ function resolveSpec(from: string, spec: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Specifiers a file loads at run time: value imports and re-exports, never `import type`. */
|
||||
/** Specifiers a file loads at run time: value imports and re-exports (never `import type`), and
|
||||
* every `import()` anywhere in it — a lazy chunk is loaded by the page as surely as a static one. */
|
||||
function runtimeImports(file: string): string[] {
|
||||
const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
|
||||
const specs: string[] = [];
|
||||
@@ -50,6 +55,14 @@ function runtimeImports(file: string): string[] {
|
||||
specs.push((st.moduleSpecifier as ts.StringLiteral).text);
|
||||
}
|
||||
}
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
const arg = node.arguments[0];
|
||||
specs.push(arg && ts.isStringLiteralLike(arg) ? arg.text : DYNAMIC);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return specs;
|
||||
}
|
||||
|
||||
@@ -91,8 +104,10 @@ describe("the /watch entry", () => {
|
||||
});
|
||||
|
||||
it("catches a leak one import deep (the walk is not vacuous)", () => {
|
||||
// main.tsx is the app: walking it must find the app's i18n.
|
||||
expect(reach(join(SRC, "main.tsx")).has("src/i18n/index.ts")).toBe(true);
|
||||
// main.tsx is the app: walking it must find the app's i18n, and a page it only loads lazily.
|
||||
const app = reach(join(SRC, "main.tsx"));
|
||||
expect(app.has("src/i18n/index.ts")).toBe(true);
|
||||
expect(app.has("src/components/legal/Terms.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
it("serves the same head as the app's page, so the server's OG block lands the same way", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { CrashBoundary } from "./components/CrashBoundary";
|
||||
import { CrashBoundary } from "./components/ui/CrashBoundary";
|
||||
import WatchPage from "./components/WatchPage";
|
||||
import { T } from "./components/watchStrings";
|
||||
import "./index.css";
|
||||
|
||||
@@ -28,6 +28,15 @@ const watchEntry = (): Plugin => ({
|
||||
next();
|
||||
});
|
||||
},
|
||||
// The server answers every /watch/ link with the built watch.html, so a build without it would
|
||||
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
|
||||
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
|
||||
enforce: "post",
|
||||
generateBundle(_options, bundle) {
|
||||
for (const page of ["index.html", "watch.html"]) {
|
||||
if (!(page in bundle)) this.error(`${page} was not built — add it to rollupOptions.input`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user