diff --git a/jest.config.js b/jest.config.js index a63ca1c1..090cf8fc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,8 @@ export default { '/node_modules/(?!(color|color-string|color-convert|color-name)/)', ], moduleNameMapper: { + '\\.png$': '/src/test/mocks/assetStub.ts', + '\\?inline$': '/src/test/mocks/inlineStub.ts', '^@/(.*)$': '/src/$1', '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', diff --git a/src/SEQTA.ts b/src/SEQTA.ts index af42696b..142554b2 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -53,6 +53,10 @@ if (document.childNodes[1]) { init(); } +// The 404 page is a bare document with no second childNode, so it must be +// booted unconditionally (outside the gate above). It self-guards on title. +bootErrorPage(); + if (import.meta.env.DEV) { window.addEventListener("unhandledrejection", (event) => { recoverFromStaleDevModuleGraph(event.reason); @@ -138,6 +142,65 @@ async function init() { } } +// The 404 page is a standalone document that never passes the SPA gate above, +// so the normal plugin path never boots there; start just the classic kitten +// 404 plugin so it renders without any SPA work. +async function bootErrorPage() { + if (IsSEQTAPage) return; + + // Runs at document_start, so and body are not available yet; wait + // for the DOM before testing the 404 title. + await new Promise<void>((resolve) => { + if (document.readyState !== "loading") { + resolve(); + return; + } + document.addEventListener("DOMContentLoaded", () => resolve(), { + once: true, + }); + }); + + const is404Page = + /404/.test(document.title) || /not found/i.test(document.title); + if (!is404Page) return; + + const stored = await browser.storage.local.get([ + "onoff", + "plugin.error-page-kitten.settings", + ]); + if ((stored.onoff ?? true) === false) return; + const kittenEnabled = ( + stored["plugin.error-page-kitten.settings"] as + | { enabled?: boolean } + | undefined + )?.enabled; + if (kittenEnabled === false) return; + + hideRebranded404(); + + try { + const { pluginManager } = await import("@/plugins/index"); + await pluginManager.startPlugin("error-page-kitten"); + } catch (error) { + // Restore the rebranded page so the user is not left with a blank document. + document + .querySelectorAll<HTMLElement>(".bsplus-kitten-404-hidden") + .forEach((el) => { + el.classList.remove("bsplus-kitten-404-hidden"); + el.style.display = ""; + }); + console.error("[BetterSEQTA+] Failed to boot 404 page:", error); + } +} + +function hideRebranded404() { + const message = document.querySelector<HTMLElement>(".message"); + if (message) { + message.classList.add("bsplus-kitten-404-hidden"); + message.style.display = "none"; + } +} + function replaceIcons() { document .querySelectorAll<HTMLLinkElement>('link[rel*="icon"]') diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 82970383..095e37fb 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -43,6 +43,7 @@ "web_accessible_resources": [ { "resources": [ + "resources/error-page/*", "resources/icons/*", "resources/update-image.webp", "resources/pdfjs/pdf.worker.min.mjs", diff --git a/src/plugins/built-in/errorPageKitten/index.test.ts b/src/plugins/built-in/errorPageKitten/index.test.ts new file mode 100644 index 00000000..655a0fbc --- /dev/null +++ b/src/plugins/built-in/errorPageKitten/index.test.ts @@ -0,0 +1,115 @@ +/** + * @jest-environment jsdom + */ +/// <reference types="jest" /> +import type { PluginAPI } from "@/plugins/core/types"; +import errorPageKittenPlugin from "./index"; + +const REBRANDED_404_BODY = ` + <div class="message"> + <h1>Page not found</h1> + <p>We can't find the page you're looking for.</p> + <p>It may have been moved, deleted or the link might be out of date.</p> + <p class="error-ref">Ref: 404</p> + </div> +`; + +const api = { + settings: { loaded: Promise.resolve() }, +} as unknown as PluginAPI<{}>; + +async function startPlugin(): Promise<() => void> { + const cleanup = (await errorPageKittenPlugin.run(api)) ?? (() => {}); + return cleanup; +} + +function getCard(): HTMLElement | null { + // The card container shares its class with <html>, so scope to body. + return document.body.querySelector<HTMLElement>(".bsplus-kitten-404"); +} + +function expectKittenRendered(): void { + const card = getCard(); + expect(card).not.toBeNull(); + expect(document.documentElement.classList).toContain("bsplus-kitten-404"); + expect(card?.querySelector("h1")?.textContent).toBe("404 Not Found"); + // The original document's title. + expect(document.title).toBe("404 Not Found"); + // The reference page's exact vendor-prefixed banner CSS is shipped at runtime. + const stripeStyle = Array.from( + document.head.querySelectorAll("style"), + ).find((s) => s.textContent?.includes("-webkit-repeating-linear-gradient")); + expect(stripeStyle?.textContent).toContain( + "repeating-linear-gradient(45deg", + ); + const img = card?.querySelector(".kitten img") as HTMLImageElement | null; + expect(img?.getAttribute("alt")).toBeNull(); + expect(img?.src).toContain("kitten"); + const flickrLink = card?.querySelector( + ".attrib a", + ) as HTMLAnchorElement | null; + expect(flickrLink?.textContent).toBe("by storyvillegirl"); + expect(flickrLink?.href).toBe( + "http://www.flickr.com/photos/bibbit/2756165489/", + ); + const seqtaLink = card?.querySelector("a[href='http://www.seqta.com.au']"); + expect(seqtaLink?.textContent).toBe("SEQTA"); + // Original rebranded message is hidden. + expect( + document.querySelector(".message")?.classList.contains( + "bsplus-kitten-404-hidden", + ), + ).toBe(true); +} + +describe("errorPageKitten", () => { + beforeEach(() => { + document.body.innerHTML = ""; + document.title = ""; + document.documentElement.classList.remove("bsplus-kitten-404"); + }); + + it("renders the classic kitten 404 card over the rebranded page", async () => { + document.title = "Page not found"; + document.body.innerHTML = REBRANDED_404_BODY; + + const cleanup = await startPlugin(); + + expectKittenRendered(); + + cleanup(); + expect(getCard()).toBeNull(); + expect(document.title).toBe("Page not found"); + expect( + Array.from(document.head.querySelectorAll("style")).some((s) => + s.textContent?.includes("-webkit-repeating-linear-gradient"), + ), + ).toBe(false); + expect( + document.documentElement.classList.contains("bsplus-kitten-404"), + ).toBe(false); + expect( + document + .querySelector(".message") + ?.classList.contains("bsplus-kitten-404-hidden"), + ).toBe(false); + }); + + it("does not render inside the SPA (no .message, has #container)", async () => { + document.title = "SEQTA"; + document.body.innerHTML = '<div id="container"></div>'; + + await startPlugin(); + + expect(getCard()).toBeNull(); + }); + + it("does not render on an unrelated page", async () => { + document.title = "Home"; + document.body.innerHTML = '<div id="app"></div>'; + + await startPlugin(); + + expect(getCard()).toBeNull(); + }); +}); diff --git a/src/plugins/built-in/errorPageKitten/index.ts b/src/plugins/built-in/errorPageKitten/index.ts new file mode 100644 index 00000000..c2731eb5 --- /dev/null +++ b/src/plugins/built-in/errorPageKitten/index.ts @@ -0,0 +1,132 @@ +import type { Plugin } from "@/plugins/core/types"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; +import kittenPng from "@/resources/error-page/kitten.png"; +import styles from "./styles.css?inline"; + +const KITTEN_IMG = resolveExtensionAssetUrl(kittenPng); +const KITTEN_CLASS = "bsplus-kitten-404"; + +// The reference page's exact h1 background declarations, in the original +// order. Each engine keeps the last declaration it understands (-webkit in +// Blink, -moz in Gecko, the legacy standard otherwise), so shipping them +// byte-for-byte reproduces the reference banner on every engine. Lives in a +// JS string because the CSS minifier drops vendor-prefixed declarations. +const H1_STRIPE_CSS = + "html.bsplus-kitten-404 .bsplus-kitten-404>h1{background-image:-moz-repeating-linear-gradient(135deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), -moz-linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1));background-image:-webkit-repeating-linear-gradient(135deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), -webkit-linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1));background-image:-o-repeating-linear-gradient(135deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), -o-linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1));background-image:repeating-linear-gradient(45deg, rgba(0,0,0,0), rgba(0,0,0,0) 8px, rgba(0,0,0,0.05) 8px, rgba(0,0,0,0.05) 16px), linear-gradient(top, rgba(0,0,0,0), rgba(0,0,0,0.1))}"; + +// The 404 page (legacy and rebranded) is a standalone document built around a +// single `.message` block; the real app mounts a `#container`. Require the +// former and rule out the latter so the plugin never renders inside the app. +function is404Page(): boolean { + if (document.getElementById("container")) return false; + const heading = document.querySelector(".message > h1"); + const text = heading?.textContent ?? document.title; + return /not found/i.test(text) || /404/.test(document.title); +} + +function buildCard(): HTMLElement { + const card = document.createElement("div"); + card.className = KITTEN_CLASS; + + const h1 = document.createElement("h1"); + h1.textContent = "404 Not Found"; + card.appendChild(h1); + + const p1 = document.createElement("p"); + p1.textContent = + "Sorry — the resource you are looking for could not be found."; + card.appendChild(p1); + + const p2 = document.createElement("p"); + p2.textContent = + "If you believe you are seeing this message in error, please contact your IT department."; + card.appendChild(p2); + + const kitten = document.createElement("div"); + kitten.className = "kitten"; + + const kittenLine = document.createElement("p"); + kittenLine.textContent = "I did find this picture of a kitten for you, though!"; + kitten.appendChild(kittenLine); + + const img = document.createElement("img"); + img.src = KITTEN_IMG; + kitten.appendChild(img); + + const attrib = document.createElement("div"); + attrib.className = "attrib"; + attrib.append("CC-BY-SA "); + const link = document.createElement("a"); + link.href = "http://www.flickr.com/photos/bibbit/2756165489/"; + link.textContent = "by storyvillegirl"; + attrib.appendChild(link); + kitten.appendChild(attrib); + + card.appendChild(kitten); + + const seqtaLink = document.createElement("a"); + seqtaLink.href = "http://www.seqta.com.au"; + seqtaLink.textContent = "SEQTA"; + card.appendChild(seqtaLink); + + return card; +} + +const errorPageKittenPlugin: Plugin = { + id: "error-page-kitten", + name: "Classic 404 Page", + description: "Brings back SEQTA's old kitten 404 page", + version: "1.0.0", + settings: {}, + disableToggle: true, + defaultEnabled: true, + styles, + + run: async (_api) => { + let card: HTMLElement | null = null; + let hasBuilt = false; + let originalTitle = document.title; + let stripeStyle: HTMLStyleElement | null = null; + + const render = () => { + if (!is404Page()) return; + + document.querySelector(".message")?.classList.add("bsplus-kitten-404-hidden"); + + if (hasBuilt) return; + const body = document.body; + if (!body) return; + + hasBuilt = true; + document.documentElement.classList.add(KITTEN_CLASS); + document.title = "404 Not Found"; + // Appended after the manager-injected styles so these same-specificity + // declarations win over the compiled baseline. + stripeStyle = document.createElement("style"); + stripeStyle.textContent = H1_STRIPE_CSS; + document.head.appendChild(stripeStyle); + card = buildCard(); + body.appendChild(card); + }; + + // Body may not be parsed yet at document_start; render now and again on + // DOM ready as a safety net. + render(); + document.addEventListener("DOMContentLoaded", render, { once: true }); + + return () => { + document.removeEventListener("DOMContentLoaded", render); + document.documentElement.classList.remove(KITTEN_CLASS); + document + .querySelectorAll(".bsplus-kitten-404-hidden") + .forEach((el) => el.classList.remove("bsplus-kitten-404-hidden")); + document.title = originalTitle; + stripeStyle?.remove(); + stripeStyle = null; + card?.remove(); + card = null; + }; + }, +}; + +export default errorPageKittenPlugin; diff --git a/src/plugins/built-in/errorPageKitten/lazy.ts b/src/plugins/built-in/errorPageKitten/lazy.ts new file mode 100644 index 00000000..3bbe04a5 --- /dev/null +++ b/src/plugins/built-in/errorPageKitten/lazy.ts @@ -0,0 +1,14 @@ +import { defineLazyPlugin } from "../../core/dynamicLoader"; +import styles from "./styles.css?inline"; + +export default defineLazyPlugin({ + id: "error-page-kitten", + name: "Classic 404 Page", + description: "Brings back SEQTA's old kitten 404 page", + version: "1.0.0", + settings: {}, + disableToggle: true, + defaultEnabled: true, + styles, + loader: () => import("./index"), +}); diff --git a/src/plugins/built-in/errorPageKitten/styles.css b/src/plugins/built-in/errorPageKitten/styles.css new file mode 100644 index 00000000..653c2dcd --- /dev/null +++ b/src/plugins/built-in/errorPageKitten/styles.css @@ -0,0 +1,113 @@ +/* Classic SEQTA 404 page, restored one-to-one over the rebranded document. + Scoped under html.bsplus-kitten-404 so it can never leak into the SPA; + the card element itself carries the .bsplus-kitten-404 class. */ + +/* The rebranded page styles <body> (Arial / flex / 25vh top / line-height + 1.5 / letter-spacing). line-height and letter-spacing are reset because + the rebranded body sets them and the classic page uses browser defaults. */ +html.bsplus-kitten-404 { + background: #333; +} +html.bsplus-kitten-404 body { + background: #333; + font-family: 'Trebuchet MS', sans-serif; + font-size: 12pt; + color: #aaa; + margin: 8px; + padding: 0; + display: block; + line-height: normal; + letter-spacing: normal; +} + +/* Hide the rebranded page content while the classic card is shown. */ +html.bsplus-kitten-404 .bsplus-kitten-404-hidden { + display: none !important; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 { + width: 600px; + margin: 64px auto; + background: #222; + border-radius: 4px; + box-shadow: -1px -1px rgba(0, 0, 0, 0.5), + 1px 1px rgba(255, 255, 255, 0.2); + padding: 0 0 8px; + text-align: center; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > h1 { + background: #612; + /* Valid modern baseline; the reference's exact vendor-prefixed declarations + are injected at runtime (H1_STRIPE_CSS in index.ts), which win over this. + The vendor-prefixed lines can't live in this file because the CSS + minifier drops them. */ + background-image: repeating-linear-gradient( + 135deg, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0) 8px, + rgba(0, 0, 0, 0.05) 8px, + rgba(0, 0, 0, 0.05) 16px + ), + linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.1)); + color: #fff; + padding: 8px; + border-radius: 4px 4px 0 0; + text-shadow: 0 1px rgba(0, 0, 0, 0.5); + font-size: 150%; + font-weight: normal; + margin: 0 0 16px; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > p { + padding: 8px; + margin: 0; + text-shadow: 0 1px rgba(0, 0, 0, 0.5); + font-style: italic; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > p > a { + color: #729fcf; + text-decoration: none; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > .kitten { + font-size: 70%; + padding: 24px 0; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > .kitten > p { + color: #666; + font-style: italic; + margin: 0 0 8px; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > .kitten > .attrib { + color: #333; + font-size: 80%; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > .kitten > .attrib > a { + color: inherit; + text-decoration: none; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > .kitten > img { + display: inline-block; + border-radius: 4px; + box-shadow: 0 0 4px #000; +} + +html.bsplus-kitten-404 .bsplus-kitten-404 > a { + display: block; + padding: 8px; + margin: 0 8px 0 auto; + width: 100px; + text-align: center; + color: #444; + background: #111; + text-decoration: none; + font-size: 80%; + border-radius: 4px; + box-shadow: inset 1px 1px rgba(0, 0, 0, 0.5); +} diff --git a/src/plugins/core/manager.ts b/src/plugins/core/manager.ts index 0719975f..6a1ec151 100644 --- a/src/plugins/core/manager.ts +++ b/src/plugins/core/manager.ts @@ -26,6 +26,9 @@ interface StorageChange<T = any> { /** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */ const PLUGIN_START_PHASES: readonly string[][] = [ + // 404 page boot is a tiny standalone path; start it first so the kitten + // card renders before any SPA work. A no-op on normal SEQTA pages. + ["error-page-kitten"], ["themes", "animated-background"], [ "timetable", diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 039be718..9be02278 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -16,6 +16,7 @@ import messageFoldersPluginLazy from "./built-in/messageFolders/lazy"; import enhancedNavigationPluginLazy from "./built-in/enhancedNavigation/lazy"; import globalSearchPluginLazy from "./built-in/globalSearch/lazy"; import gradeAnalyticsPluginLazy from "./built-in/gradeAnalytics/lazy"; +import errorPageKittenPluginLazy from "./built-in/errorPageKitten/lazy"; // Initialize plugin manager const pluginManager = PluginManager.getInstance(); @@ -36,6 +37,7 @@ pluginManager.registerPlugin(messageFoldersPluginLazy); pluginManager.registerPlugin(enhancedNavigationPluginLazy); pluginManager.registerPlugin(globalSearchPluginLazy); pluginManager.registerPlugin(gradeAnalyticsPluginLazy); +pluginManager.registerPlugin(errorPageKittenPluginLazy); export async function initializePlugins(): Promise<void> { await pluginManager.startAllPlugins(); diff --git a/src/plugins/syncablePluginDefaults.ts b/src/plugins/syncablePluginDefaults.ts index e438e1fa..cb7a58dd 100644 --- a/src/plugins/syncablePluginDefaults.ts +++ b/src/plugins/syncablePluginDefaults.ts @@ -37,4 +37,5 @@ export const SYNCABLE_PLUGIN_SETTING_DEFAULTS: Record< passiveIndexing: true, }, "grade-analytics": { cacheTtlHours: 24 }, + "error-page-kitten": {}, }; diff --git a/src/resources/error-page/kitten.png b/src/resources/error-page/kitten.png new file mode 100644 index 00000000..1a94d5d0 Binary files /dev/null and b/src/resources/error-page/kitten.png differ diff --git a/src/test/mocks/assetStub.ts b/src/test/mocks/assetStub.ts new file mode 100644 index 00000000..50e1fdeb --- /dev/null +++ b/src/test/mocks/assetStub.ts @@ -0,0 +1,2 @@ +// Jest stub for Vite asset imports (png, etc.): Vite returns a URL string. +export default "/resources/error-page/kitten.png"; diff --git a/src/test/mocks/inlineStub.ts b/src/test/mocks/inlineStub.ts new file mode 100644 index 00000000..23332858 --- /dev/null +++ b/src/test/mocks/inlineStub.ts @@ -0,0 +1,2 @@ +// Jest stub for Vite's `?inline` css imports: returns a CSS string. +export default "/* test inline css */"; diff --git a/src/test/mocks/webextension-polyfill.ts b/src/test/mocks/webextension-polyfill.ts index 21d57028..3bffe4a3 100644 --- a/src/test/mocks/webextension-polyfill.ts +++ b/src/test/mocks/webextension-polyfill.ts @@ -32,6 +32,9 @@ export default { storage: { local, onChanged }, runtime: { sendMessage: jest.fn(async () => undefined), + getURL: jest.fn( + (path: string) => `chrome-extension://test/${String(path).replace(/^\/+/, "")}`, + ), }, };