diff --git a/ui/apps/docs/astro.config.mjs b/ui/apps/docs/astro.config.mjs index 72b1ddf9fab..496835ae654 100644 --- a/ui/apps/docs/astro.config.mjs +++ b/ui/apps/docs/astro.config.mjs @@ -2,9 +2,10 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import tailwind from "@astrojs/tailwind"; import react from "@astrojs/react"; +import { shots } from "./src/lib/shots/integration.ts"; export default defineConfig({ server: { port: 8083, host: true, allowedHosts: true }, devToolbar: { enabled: false }, - integrations: [mdx(), tailwind(), react()], + integrations: [mdx(), tailwind(), react(), shots()], }); diff --git a/ui/apps/docs/package.json b/ui/apps/docs/package.json index 4dffac094c6..7e04877521f 100644 --- a/ui/apps/docs/package.json +++ b/ui/apps/docs/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "astro dev", "build": "astro build", + "shots": "./scripts/capture-shots.sh", "preview": "astro preview", "test": "vitest run --passWithNoTests", "test:watch": "vitest", diff --git a/ui/apps/docs/public/img/account/mfa-enable.gif b/ui/apps/docs/public/img/account/mfa-enable.gif deleted file mode 100644 index e5d40f46a34..00000000000 Binary files a/ui/apps/docs/public/img/account/mfa-enable.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/account/mfa-login.png b/ui/apps/docs/public/img/account/mfa-login.png deleted file mode 100644 index 9df8684e221..00000000000 Binary files a/ui/apps/docs/public/img/account/mfa-login.png and /dev/null differ diff --git a/ui/apps/docs/public/img/account/mfa-recovery-codes.png b/ui/apps/docs/public/img/account/mfa-recovery-codes.png deleted file mode 100644 index 240d2df5e44..00000000000 Binary files a/ui/apps/docs/public/img/account/mfa-recovery-codes.png and /dev/null differ diff --git a/ui/apps/docs/public/img/account/mfa-recovery-mail.png b/ui/apps/docs/public/img/account/mfa-recovery-mail.png deleted file mode 100644 index ece45eb565c..00000000000 Binary files a/ui/apps/docs/public/img/account/mfa-recovery-mail.png and /dev/null differ diff --git a/ui/apps/docs/public/img/account/mfa-setup.png b/ui/apps/docs/public/img/account/mfa-setup.png deleted file mode 100644 index e1e1697b482..00000000000 Binary files a/ui/apps/docs/public/img/account/mfa-setup.png and /dev/null differ diff --git a/ui/apps/docs/public/img/auth/admin-authentication.png b/ui/apps/docs/public/img/auth/admin-authentication.png deleted file mode 100644 index 7a32e85e12f..00000000000 Binary files a/ui/apps/docs/public/img/auth/admin-authentication.png and /dev/null differ diff --git a/ui/apps/docs/public/img/containers/add-docker-host.png b/ui/apps/docs/public/img/containers/add-docker-host.png deleted file mode 100644 index e9e390d3134..00000000000 Binary files a/ui/apps/docs/public/img/containers/add-docker-host.png and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-add.gif b/ui/apps/docs/public/img/devices/device-add.gif deleted file mode 100644 index 6980a3fd26e..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-add.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-connect.gif b/ui/apps/docs/public/img/devices/device-connect.gif deleted file mode 100644 index 235f47940a8..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-connect.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-details.png b/ui/apps/docs/public/img/devices/device-details.png deleted file mode 100644 index a2238cfccf2..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-details.png and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-list.png b/ui/apps/docs/public/img/devices/device-list.png deleted file mode 100644 index 86bb1576d79..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-list.png and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-pending.gif b/ui/apps/docs/public/img/devices/device-pending.gif deleted file mode 100644 index 1104959baa9..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-pending.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/firewall/add-rule.png b/ui/apps/docs/public/img/firewall/add-rule.png deleted file mode 100644 index 1f2ab0bf923..00000000000 Binary files a/ui/apps/docs/public/img/firewall/add-rule.png and /dev/null differ diff --git a/ui/apps/docs/public/img/firewall/firewall-rules.png b/ui/apps/docs/public/img/firewall/firewall-rules.png deleted file mode 100644 index 31e767d5c5b..00000000000 Binary files a/ui/apps/docs/public/img/firewall/firewall-rules.png and /dev/null differ diff --git a/ui/apps/docs/public/img/getting-started/dashboard.png b/ui/apps/docs/public/img/getting-started/dashboard.png deleted file mode 100644 index 66222fb8cdd..00000000000 Binary files a/ui/apps/docs/public/img/getting-started/dashboard.png and /dev/null differ diff --git a/ui/apps/docs/public/img/public-keys/public-keys.png b/ui/apps/docs/public/img/public-keys/public-keys.png deleted file mode 100644 index 8ade492be8e..00000000000 Binary files a/ui/apps/docs/public/img/public-keys/public-keys.png and /dev/null differ diff --git a/ui/apps/docs/public/img/sessions/session-detail.png b/ui/apps/docs/public/img/sessions/session-detail.png deleted file mode 100644 index f1295c7bc25..00000000000 Binary files a/ui/apps/docs/public/img/sessions/session-detail.png and /dev/null differ diff --git a/ui/apps/docs/public/img/sessions/session-list.png b/ui/apps/docs/public/img/sessions/session-list.png deleted file mode 100644 index b5f6b2c50ab..00000000000 Binary files a/ui/apps/docs/public/img/sessions/session-list.png and /dev/null differ diff --git a/ui/apps/docs/public/img/sessions/session-play.gif b/ui/apps/docs/public/img/sessions/session-play.gif deleted file mode 100644 index 2686c216012..00000000000 Binary files a/ui/apps/docs/public/img/sessions/session-play.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/sessions/session-recording-enable.gif b/ui/apps/docs/public/img/sessions/session-recording-enable.gif deleted file mode 100644 index b780ea80660..00000000000 Binary files a/ui/apps/docs/public/img/sessions/session-recording-enable.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/settings/namespace-copy-tenant-id.gif b/ui/apps/docs/public/img/settings/namespace-copy-tenant-id.gif deleted file mode 100644 index eaf096ae2ba..00000000000 Binary files a/ui/apps/docs/public/img/settings/namespace-copy-tenant-id.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/settings/namespace-create.gif b/ui/apps/docs/public/img/settings/namespace-create.gif deleted file mode 100644 index dc95e1c8024..00000000000 Binary files a/ui/apps/docs/public/img/settings/namespace-create.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/settings/namespace-delete.gif b/ui/apps/docs/public/img/settings/namespace-delete.gif deleted file mode 100644 index e1987204c0e..00000000000 Binary files a/ui/apps/docs/public/img/settings/namespace-delete.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/settings/namespace-settings.png b/ui/apps/docs/public/img/settings/namespace-settings.png deleted file mode 100644 index 4b5f701328a..00000000000 Binary files a/ui/apps/docs/public/img/settings/namespace-settings.png and /dev/null differ diff --git a/ui/apps/docs/public/img/settings/namespace-switch.gif b/ui/apps/docs/public/img/settings/namespace-switch.gif deleted file mode 100644 index 2395f403f07..00000000000 Binary files a/ui/apps/docs/public/img/settings/namespace-switch.gif and /dev/null differ diff --git a/ui/apps/docs/public/img/team/members.png b/ui/apps/docs/public/img/team/members.png deleted file mode 100644 index 4c1d9d398a8..00000000000 Binary files a/ui/apps/docs/public/img/team/members.png and /dev/null differ diff --git a/ui/apps/docs/public/img/web-endpoints/web-endpoints.png b/ui/apps/docs/public/img/web-endpoints/web-endpoints.png deleted file mode 100644 index addde0feecf..00000000000 Binary files a/ui/apps/docs/public/img/web-endpoints/web-endpoints.png and /dev/null differ diff --git a/ui/apps/docs/scripts/capture-shots.sh b/ui/apps/docs/scripts/capture-shots.sh new file mode 100755 index 00000000000..82e31234ef9 --- /dev/null +++ b/ui/apps/docs/scripts/capture-shots.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Refresh the screenshots the docs use, by handing shellhub-demo the manifest the last build +# wrote and the directory the pages read from. +# +# The demo deliberately knows neither path: it photographs whatever list it is given and +# writes wherever it is told, which is what keeps a demo-environment builder free of any +# knowledge of the documentation that consumes it. That boundary constrains the demo, not +# the caller - and this side knows both paths perfectly well, so it fills them in. + +set -euo pipefail + +docs_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +manifest="${docs_dir}/.astro/shots.json" +out_dir="${docs_dir}/public/img/shots" + +# Assumed to sit beside the shellhub checkout, which is how the two are normally cloned. +# Overridable because "normally" is not "always", and a wrong guess should be a sentence +# rather than a stack trace. +demo_dir="${SHELLHUB_DEMO_DIR:-${docs_dir}/../../../../shellhub-demo}" + +if [ ! -x "${demo_dir}/stage" ]; then + echo "no shellhub-demo checkout at ${demo_dir}" >&2 + echo "-> clone it beside shellhub, or set SHELLHUB_DEMO_DIR" >&2 + exit 1 +fi + +# Written at astro:build:done, so a stale manifest means a stale shot list - and a shot +# removed from a page would otherwise keep being photographed forever. +if [ ! -f "$manifest" ]; then + echo "no shot list at ${manifest}" >&2 + echo "-> npm run build -w @shellhub/docs first" >&2 + exit 1 +fi + +exec "${demo_dir}/stage" capture --manifest "$manifest" --out "$out_dir" "$@" diff --git a/ui/apps/docs/src/components/Shot.astro b/ui/apps/docs/src/components/Shot.astro index b7c67e8c5b3..665271d774f 100644 --- a/ui/apps/docs/src/components/Shot.astro +++ b/ui/apps/docs/src/components/Shot.astro @@ -1,43 +1,42 @@ --- -import fs from "node:fs"; -import path from "node:path"; +import { warnMissingShotImageInDev } from "../lib/shots/check"; +import type { + ShotEdition, + ShotInteraction, + ShotSelector, + ShotViewport, +} from "../lib/shots/manifest"; +import { shotImageHref } from "../lib/shots/paths"; +import { recordShot } from "../lib/shots/registry"; -/** - * A screenshot the docs declare for themselves. - * - * The page states what it wants captured and where the image belongs; the - * capture pass reads these markers and produces exactly them. There is no - * separate manifest to drift out of sync, and the id is the filename, so a - * shot cannot end up written next to the image the prose actually references. - * - * `of`, `expect`, `click` and `padding` are read by the capture pass rather - * than by this component: they describe how to reach and frame the shot. - */ interface Props { id: string; - alt: string; route: string; - of?: { role: string; name?: string }; - expect?: string[]; - click?: string; - padding?: number; - caption?: string; + of?: ShotSelector; + viewport?: ShotViewport; + alt?: string; + edition?: ShotEdition; + before?: ShotInteraction[]; } -const { id, alt, caption } = Astro.props; +const { id, route, of, viewport, alt, edition, before } = Astro.props; -const src = `/img/generated/${id}.png`; -const file = path.join(process.cwd(), "public", src); +recordShot({ id, route, of, viewport, edition, before, page: Astro.url.pathname }); -if (!fs.existsSync(file)) { - throw new Error( - ` has no image at public${src}. ` + - "Run the capture pass, or remove the marker.", - ); -} +warnMissingShotImageInDev(id); --- - -
- {alt} - {caption &&
{caption}
} +
+ {alt
+ + diff --git a/ui/apps/docs/src/lib/shots/__tests__/build.test.ts b/ui/apps/docs/src/lib/shots/__tests__/build.test.ts new file mode 100644 index 00000000000..181fe2e6e91 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/__tests__/build.test.ts @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { JSDOM } from "jsdom"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { ShotManifest } from "@/lib/shots/manifest"; + +const fixture = fileURLToPath( + new URL("../../../../test/fixtures/shots-site", import.meta.url), +); + +let manifest: ShotManifest; + +/** + * The component runs in Vite's SSR module graph and the integration in Node's. + * Those are separate module caches, so a registry that works under vitest can + * still hand the integration an empty array during a real build. Only an actual + * `astro build` exercises both graphs, which is why this one test is worth its + * cost. + */ +describe("shots integration, over a real astro build", () => { + beforeAll(() => { + rmSync(`${fixture}/dist`, { recursive: true, force: true }); + rmSync(`${fixture}/.astro`, { recursive: true, force: true }); + + execFileSync("npx", ["astro", "build", "--root", "."], { + cwd: fixture, + encoding: "utf-8", + env: { ...process.env, CI: "" }, + }); + + manifest = JSON.parse( + readFileSync(`${fixture}/.astro/shots.json`, "utf-8"), + ) as ShotManifest; + }, 120_000); + + it("emits every declared shot, merging the one used on both pages", () => { + expect(manifest.shots).toEqual([ + { + id: "dashboard", + route: "/", + viewport: { width: 800, height: 600 }, + edition: "ce", + usedBy: ["/one/"], + }, + { + id: "device-list", + route: "/devices", + viewport: { width: 1440, height: 900 }, + edition: "ce", + of: { role: "table" }, + usedBy: ["/one/", "/two/"], + }, + { + id: "session-list", + route: "/sessions", + viewport: { width: 1440, height: 900 }, + edition: "enterprise", + usedBy: ["/two/"], + }, + ]); + }); + + it("renders the image that displays each shot", () => { + const html = readFileSync(`${fixture}/dist/one/index.html`, "utf-8"); + const { document } = new JSDOM(html).window; + + const images = [...document.querySelectorAll("img")].map((image) => ({ + src: image.getAttribute("src"), + alt: image.getAttribute("alt"), + })); + + expect(images).toContainEqual({ + src: "/img/shots/device-list.png", + alt: "Devices", + }); + }); +}); diff --git a/ui/apps/docs/src/lib/shots/__tests__/check.test.ts b/ui/apps/docs/src/lib/shots/__tests__/check.test.ts new file mode 100644 index 00000000000..3c8d17ceda7 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/__tests__/check.test.ts @@ -0,0 +1,95 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { removeImages, unreferencedImages } from "@/lib/shots/check"; + +let root = ""; +const publicDir = () => join(root, "public"); +const outDir = () => join(root, "dist"); + +function image(dir: string, name: string): void { + mkdirSync(join(publicDir(), dir), { recursive: true }); + writeFileSync(join(publicDir(), dir, name), ""); +} + +function page(html: string): void { + mkdirSync(join(outDir(), "guides"), { recursive: true }); + writeFileSync(join(outDir(), "guides", "index.html"), html); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "shots-check-")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("unreferencedImages", () => { + it("keeps a captured shot the built page displays", () => { + image("img/shots", "device-list.png"); + page(''); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + }); + + it("reports a shot no page displays any more", () => { + image("img/shots", "device-list.png"); + image("img/shots", "firewall-rules.png"); + page(''); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([ + "img/shots/firewall-rules.png", + ]); + }); + + // Manual images are referenced by plain markdown, which no manifest knows + // about. Reading the built html is what lets one rule cover both directories. + it("reports a manual image no page displays any more", () => { + image("img/manual", "mfa-setup.png"); + image("img/manual", "public-keys.png"); + page('setup'); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([ + "img/manual/public-keys.png", + ]); + }); + + it("counts a reference from anywhere in the output", () => { + image("img/manual", "session-play.gif"); + mkdirSync(outDir(), { recursive: true }); + writeFileSync(join(outDir(), "styles.css"), "a{background:url(/img/manual/session-play.gif)}"); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + }); + + // Every image lives in one of the owned directories, so a build that wrote no + // html would otherwise propose deleting all of them at once. + it("proposes nothing when there is no build to read", () => { + image("img/shots", "device-list.png"); + + expect(unreferencedImages(publicDir(), join(root, "nowhere"))).toEqual([]); + }); + + it("ignores directories the docs do not own", () => { + image("img/logos", "shellhub.svg"); + page("

no images here

"); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + }); +}); + +describe("removeImages", () => { + it("deletes exactly what it was given", () => { + image("img/shots", "device-list.png"); + image("img/manual", "public-keys.png"); + page(''); + + removeImages(publicDir(), unreferencedImages(publicDir(), outDir())); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + expect(existsSync(join(publicDir(), "img/shots/device-list.png"))).toBe(true); + expect(existsSync(join(publicDir(), "img/manual/public-keys.png"))).toBe(false); + }); +}); diff --git a/ui/apps/docs/src/lib/shots/__tests__/manifest.test.ts b/ui/apps/docs/src/lib/shots/__tests__/manifest.test.ts new file mode 100644 index 00000000000..07f8636eb30 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/__tests__/manifest.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { ShotDeclaration } from "@/lib/shots/manifest"; +import { buildManifest } from "@/lib/shots/manifest"; + +function declare(overrides: Partial = {}): ShotDeclaration { + return { + id: "device-list", + route: "/devices", + page: "/getting-started/quick-start/", + ...overrides, + }; +} + +describe("buildManifest", () => { + describe("deduplication", () => { + it("collapses the same shot declared on two pages into one entry", () => { + const manifest = buildManifest([ + declare({ page: "/getting-started/quick-start/" }), + declare({ page: "/guides/devices/" }), + ]); + + expect(manifest.shots).toHaveLength(1); + expect(manifest.shots[0].usedBy).toEqual([ + "/getting-started/quick-start/", + "/guides/devices/", + ]); + }); + + it("records a page once when the same shot appears twice on it", () => { + const manifest = buildManifest([ + declare({ page: "/guides/devices/" }), + declare({ page: "/guides/devices/" }), + ]); + + expect(manifest.shots[0].usedBy).toEqual(["/guides/devices/"]); + }); + + it("keeps distinct ids as separate shots", () => { + const manifest = buildManifest([ + declare({ id: "device-list" }), + declare({ id: "session-list", route: "/sessions" }), + ]); + + expect(manifest.shots.map((shot) => shot.id)).toEqual([ + "device-list", + "session-list", + ]); + }); + + it("rejects one id declaring two different captures", () => { + const entries = [ + declare({ id: "device-list", route: "/devices" }), + declare({ id: "device-list", route: "/sessions" }), + ]; + + expect(() => buildManifest(entries)).toThrow(/device-list/); + }); + }); + + describe("defaults", () => { + it("falls back to a 1440x900 viewport and the community edition", () => { + const manifest = buildManifest([declare()]); + + expect(manifest.shots[0]).toMatchObject({ + viewport: { width: 1440, height: 900 }, + edition: "ce", + }); + }); + + it("keeps explicit values over the defaults", () => { + const manifest = buildManifest([ + declare({ viewport: { width: 800, height: 600 }, edition: "enterprise" }), + ]); + + expect(manifest.shots[0]).toMatchObject({ + viewport: { width: 800, height: 600 }, + edition: "enterprise", + }); + }); + + it("preserves the capture coordinates the author supplied", () => { + const of = { role: "table" }; + const before = [{ click: { role: "button", name: "Sort by Hostname" } }]; + + const manifest = buildManifest([declare({ of, before })]); + + expect(manifest.shots[0]).toMatchObject({ of, before }); + }); + + it("omits optional coordinates that were never declared", () => { + const manifest = buildManifest([declare()]); + + expect(manifest.shots[0]).not.toHaveProperty("of"); + expect(manifest.shots[0]).not.toHaveProperty("before"); + }); + }); + + describe("stability", () => { + it("sorts shots by id regardless of the order pages rendered in", () => { + const ids = ["session-list", "dashboard", "device-list"]; + + const manifest = buildManifest( + ids.map((id) => declare({ id, route: `/${id}` })), + ); + + expect(manifest.shots.map((shot) => shot.id)).toEqual([ + "dashboard", + "device-list", + "session-list", + ]); + }); + + it("sorts usedBy so a reordered build produces the same manifest", () => { + const forward = buildManifest([ + declare({ page: "/a/" }), + declare({ page: "/b/" }), + ]); + const reversed = buildManifest([ + declare({ page: "/b/" }), + declare({ page: "/a/" }), + ]); + + expect(reversed).toEqual(forward); + expect(forward.shots[0].usedBy).toEqual(["/a/", "/b/"]); + }); + + it("returns an empty shot list when no page declared one", () => { + expect(buildManifest([]).shots).toEqual([]); + }); + }); +}); diff --git a/ui/apps/docs/src/lib/shots/check.ts b/ui/apps/docs/src/lib/shots/check.ts new file mode 100644 index 00000000000..e22670186e7 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/check.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { extname, join } from "node:path"; +import { SHOTS_DIR, shotImageFile } from "./paths"; + +/** Which of `ids` have no PNG on disk yet. */ +export function missingShotImages(publicDir: string, ids: string[]): string[] { + return ids.filter((id) => !existsSync(join(publicDir, shotImageFile(id)))); +} + +/** + * The image directories the docs own outright. Everything in them exists to be + * shown by a page, so anything no page asks for is a leftover. + */ +const OWNED_DIRS = [SHOTS_DIR, "img/manual"]; + +const TEXT = new Set([".html", ".css", ".js", ".json", ".xml", ".txt"]); + +function* walk(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + + if (entry.isDirectory()) yield* walk(path); + else yield path; + } +} + +/** + * Every image path the built site asks for. + * + * Read from the output rather than from the sources, because that is the only + * answer covering every way a page can reference one: a tag, a markdown + * image, a raw . Astro copies public/ verbatim and rewrites none of these + * URLs, so what is in the HTML is what a reader will request. + */ +function referencedImages(outDir: string): Set { + const found = new Set(); + if (!existsSync(outDir)) return found; + + for (const file of walk(outDir)) { + if (!TEXT.has(extname(file))) continue; + + for (const [path] of readFileSync(file, "utf-8").matchAll(/\/img\/[\w./-]+/g)) { + found.add(path); + } + } + + return found; +} + +/** Files in the owned directories that the built site never asks for. */ +export function unreferencedImages(publicDir: string, outDir: string): string[] { + const referenced = referencedImages(outDir); + + // A build that referenced nothing at all is a build that produced no pages, + // not a site that stopped using every image it has. Proposing the whole + // directory for deletion on that evidence is the one mistake here that would + // be expensive. + if (referenced.size === 0) return []; + + const orphans: string[] = []; + + for (const dir of OWNED_DIRS) { + const path = join(publicDir, dir); + if (!existsSync(path)) continue; + + for (const file of readdirSync(path)) { + if (!referenced.has(`/${dir}/${file}`)) orphans.push(`${dir}/${file}`); + } + } + + return orphans.sort(); +} + +/** Delete files under `publicDir`, tolerating ones already gone. */ +export function removeImages(publicDir: string, files: string[]): void { + for (const file of files) rmSync(join(publicDir, file), { force: true }); +} + +/** + * Same split as the registry: the integration and the component load in + * different module graphs, and Vite inlines `process.env` reads during the SSR + * transform, so the dev server's state has to travel on `globalThis`. The + * integration supplies `publicDir` because only it knows which project is being + * served: resolving it relative to the component would resolve against this app + * even when another one is building. + */ +const DEV: unique symbol = Symbol.for("@shellhub/docs:shots:dev"); + +type Host = typeof globalThis & { [DEV]?: { publicDir: string } }; + +/** Hand the dev server the public directory the render-time check reads. */ +export function serveShotsInDev(publicDir: string): void { + (globalThis as Host)[DEV] = { publicDir }; +} + +const warned = new Set(); + +/** + * The dev server never fires `astro:build:done`, so an author writing a page + * would get no signal until they ran a build. Warning from the render is the + * only hook `astro dev` runs. + */ +export function warnMissingShotImageInDev(id: string): void { + const dev = (globalThis as Host)[DEV]; + if (!dev || warned.has(id)) return; + if (missingShotImages(dev.publicDir, [id]).length === 0) return; + + warned.add(id); + console.warn(`[shots] no image captured yet for: ${id}`); +} diff --git a/ui/apps/docs/src/lib/shots/integration.ts b/ui/apps/docs/src/lib/shots/integration.ts new file mode 100644 index 00000000000..b84d14c8dfb --- /dev/null +++ b/ui/apps/docs/src/lib/shots/integration.ts @@ -0,0 +1,82 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AstroIntegration } from "astro"; +import { + missingShotImages, + removeImages, + serveShotsInDev, + unreferencedImages, +} from "./check"; +import { buildManifest } from "./manifest"; +import { collectShots, resetShots } from "./registry"; + +const MANIFEST_PATH = ".astro/shots.json"; + +/** + * The Astro integration that writes the manifest and guards the image + * directory: it collects what the pages declared, and reports an id with no + * image and an image no page asks for. + */ +export function shots(): AstroIntegration { + let root = ""; + let publicDir = ""; + let command = ""; + + return { + name: "@shellhub/docs:shots", + hooks: { + "astro:config:setup": (options) => { + command = options.command; + }, + + "astro:config:done": ({ config }) => { + root = fileURLToPath(config.root); + publicDir = fileURLToPath(config.publicDir); + + if (command === "dev") serveShotsInDev(publicDir); + }, + + "astro:build:start": () => { + // A watched rebuild reuses the process, so last build's entries survive. + resetShots(); + }, + + "astro:build:done": ({ dir, logger }) => { + const manifest = buildManifest(collectShots()); + const out = join(root, MANIFEST_PATH); + + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`); + logger.info(`wrote ${manifest.shots.length} shots to ${MANIFEST_PATH}`); + + const orphans = unreferencedImages(publicDir, fileURLToPath(dir)); + + if (orphans.length > 0) { + // CI reports what was left behind rather than deleting it: a build + // that quietly rewrites the checkout hides the fact that the removal + // was never committed. + const left = `no page references: ${orphans.join(", ")}`; + if (process.env.CI) throw new Error(left); + + removeImages(publicDir, orphans); + logger.info(`removed ${orphans.length} unused: ${orphans.join(", ")}`); + } + + const missing = missingShotImages( + publicDir, + manifest.shots.map((shot) => shot.id), + ); + + if (missing.length === 0) return; + + const message = `no image captured yet for: ${missing.join(", ")}`; + + // A missing image is normal while writing a page and unacceptable on the + // published site, so it is a warning locally and a failure in CI. + if (process.env.CI) throw new Error(message); + logger.warn(message); + }, + }, + }; +} diff --git a/ui/apps/docs/src/lib/shots/manifest.ts b/ui/apps/docs/src/lib/shots/manifest.ts new file mode 100644 index 00000000000..370ca2dcb9f --- /dev/null +++ b/ui/apps/docs/src/lib/shots/manifest.ts @@ -0,0 +1,110 @@ +import { isDeepStrictEqual } from "node:util"; + +/** Which edition a shot needs the instance to be running. */ +export type ShotEdition = "ce" | "enterprise"; + +/** How the capture finds an element: by role and name, text, or test id. */ +export interface ShotSelector { + role?: string; + name?: string; + text?: string; + testId?: string; +} + +/** A step the capture performs before the picture is taken. */ +export interface ShotInteraction { + click?: ShotSelector; +} + +/** The browser size a shot is taken at. */ +export interface ShotViewport { + width: number; + height: number; +} + +/** A single `` tag, as recorded while the page that contains it renders. */ +export interface ShotDeclaration { + id: string; + route: string; + page: string; + of?: ShotSelector; + viewport?: ShotViewport; + edition?: ShotEdition; + before?: ShotInteraction[]; +} + +/** One shot in the manifest, with the pages that declared it. */ +export interface ManifestShot { + id: string; + route: string; + viewport: ShotViewport; + edition: ShotEdition; + of?: ShotSelector; + before?: ShotInteraction[]; + usedBy: string[]; +} + +/** The whole shot list, as the capture tool reads it. */ +export interface ShotManifest { + shots: ManifestShot[]; +} + +/** The viewport a shot is taken at unless it asks for another. */ +export const DEFAULT_VIEWPORT: ShotViewport = { width: 1440, height: 900 }; +/** The edition assumed when a shot does not name one. */ +export const DEFAULT_EDITION: ShotEdition = "ce"; + +type Capture = Omit; + +function toCapture(declaration: ShotDeclaration): Capture { + const capture: Capture = { + id: declaration.id, + route: declaration.route, + viewport: declaration.viewport ?? DEFAULT_VIEWPORT, + edition: declaration.edition ?? DEFAULT_EDITION, + }; + + if (declaration.of) capture.of = declaration.of; + if (declaration.before) capture.before = declaration.before; + + return capture; +} + +/** + * Collapse the declarations gathered during a build into the manifest the + * capture tool consumes. + * + * Ordering is fixed rather than inherited from the build: Astro renders pages + * concurrently, so input order varies between runs that changed no content. + */ +export function buildManifest(declarations: ShotDeclaration[]): ShotManifest { + const collected = new Map }>(); + + for (const declaration of declarations) { + const capture = toCapture(declaration); + const existing = collected.get(capture.id); + + if (!existing) { + collected.set(capture.id, { + capture, + pages: new Set([declaration.page]), + }); + continue; + } + + if (!isDeepStrictEqual(existing.capture, capture)) { + throw new Error( + `shot "${capture.id}" is declared twice with different capture coordinates; ` + + "give one of them a new id, or make the two tags identical", + ); + } + + existing.pages.add(declaration.page); + } + + const shots = [...collected.values()] + .map(({ capture, pages }) => ({ ...capture, usedBy: [...pages].sort() })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + return { shots }; +} diff --git a/ui/apps/docs/src/lib/shots/paths.ts b/ui/apps/docs/src/lib/shots/paths.ts new file mode 100644 index 00000000000..fdddfd7e656 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/paths.ts @@ -0,0 +1,16 @@ +/** + * Shots are addressed by id alone, so the id is the whole filename. A flat + * directory is what lets the tag and the capture tool agree on a path without + * either of them carrying a second list. + */ +export const SHOTS_DIR = "img/shots"; + +/** The URL a page requests for a shot. */ +export function shotImageHref(id: string): string { + return `/${SHOTS_DIR}/${id}.png`; +} + +/** The shot's path inside `public/`. */ +export function shotImageFile(id: string): string { + return `${SHOTS_DIR}/${id}.png`; +} diff --git a/ui/apps/docs/src/lib/shots/registry.ts b/ui/apps/docs/src/lib/shots/registry.ts new file mode 100644 index 00000000000..3e3d9b3f7c5 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/registry.ts @@ -0,0 +1,34 @@ +import type { ShotDeclaration } from "./manifest"; + +/** + * The component is loaded through Vite's SSR module graph and the integration + * through Node's. Each graph gets its own module instance, so a module-level + * array would be created twice and the integration would read the copy the + * component never wrote to: an empty manifest and a green build. Hanging the + * store off `globalThis` under a shared key is what makes both graphs agree. + */ +const REGISTRY: unique symbol = Symbol.for("@shellhub/docs:shots"); + +type Host = typeof globalThis & { [REGISTRY]?: ShotDeclaration[] }; + +function store(): ShotDeclaration[] { + const host = globalThis as Host; + host[REGISTRY] ??= []; + + return host[REGISTRY]; +} + +/** Add a declaration, called by `` as the page renders. */ +export function recordShot(declaration: ShotDeclaration): void { + store().push(declaration); +} + +/** Everything declared since the last reset. */ +export function collectShots(): ShotDeclaration[] { + return [...store()]; +} + +/** Drop what a previous build recorded, so a rebuild starts empty. */ +export function resetShots(): void { + store().length = 0; +} diff --git a/ui/apps/docs/test/fixtures/shots-site/astro.config.mjs b/ui/apps/docs/test/fixtures/shots-site/astro.config.mjs new file mode 100644 index 00000000000..8dd8f773c85 --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/astro.config.mjs @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; +import mdx from "@astrojs/mdx"; +import { defineConfig } from "astro/config"; +import { shots } from "../../../src/lib/shots/integration.ts"; + +export default defineConfig({ + integrations: [mdx(), shots()], + vite: { + resolve: { + alias: { + "@": fileURLToPath(new URL("../../../src", import.meta.url)), + }, + }, + }, +}); diff --git a/ui/apps/docs/test/fixtures/shots-site/package.json b/ui/apps/docs/test/fixtures/shots-site/package.json new file mode 100644 index 00000000000..75c9b36356d --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/package.json @@ -0,0 +1,6 @@ +{ + "name": "@shellhub/docs-shots-fixture", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/ui/apps/docs/test/fixtures/shots-site/src/pages/one.mdx b/ui/apps/docs/test/fixtures/shots-site/src/pages/one.mdx new file mode 100644 index 00000000000..25daf458a75 --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/src/pages/one.mdx @@ -0,0 +1,7 @@ +import Shot from "@/components/Shot.astro"; + +# Page one + + + + diff --git a/ui/apps/docs/test/fixtures/shots-site/src/pages/two.mdx b/ui/apps/docs/test/fixtures/shots-site/src/pages/two.mdx new file mode 100644 index 00000000000..c3051ac85b9 --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/src/pages/two.mdx @@ -0,0 +1,7 @@ +import Shot from "@/components/Shot.astro"; + +# Page two + + + +