diff --git a/web/scripts/project-links.test.mjs b/web/scripts/project-links.test.mjs new file mode 100644 index 0000000..9db1323 --- /dev/null +++ b/web/scripts/project-links.test.mjs @@ -0,0 +1,57 @@ +/** + * The project's off-site addresses are in one list, and three files show them. + * + * project-links.ts is read by both footers — Footer.astro at build time, + * chrome.ts at run time — so those two cannot drift from each other. What can + * drift is a URL written by hand somewhere else: the FAQ carried + * `tangled.net/lance.blue`, which answers 404, for as long as nothing compared + * it to anything. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const web = join(new URL(".", import.meta.url).pathname, ".."); +const read = (path) => readFile(join(web, path), "utf8"); + +const links = await read("src/project-links.ts"); +const astroFooter = await read("src/components/Footer.astro"); +const chrome = await read("src/chrome.ts"); +const faq = await read("src/content/faq.json"); + +const hrefs = [...links.matchAll(/href:\s*"([^"]+)"/g)].map((m) => m[1]); +assert.ok(hrefs.length >= 2, "project-links.ts no longer declares hrefs"); + +test("both footers render the list rather than their own copy", () => { + for (const [name, source] of [ + ["Footer.astro", astroFooter], + ["chrome.ts", chrome], + ]) { + assert.match(source, /PROJECT_LINKS/, `${name} does not read the list`); + assert.doesNotMatch( + source, + /https:\/\/(bsky\.app|tangled\.)/, + `${name} writes an address the list already holds`, + ); + } +}); + +test("the FAQ points at the same hosts the footer does", () => { + // Same first label, different host: tangled.net against tangled.org, or a + // bsky.social profile URL against bsky.app. Anything sharing no label with + // a project link is some other site and is not this test's business. + const known = new Map( + hrefs + .map((href) => new URL(href).host) + .map((host) => [host.split(".")[0], host]), + ); + for (const [, url] of faq.matchAll(/"(https:\/\/[^"]+)"/g)) { + const host = new URL(url).host; + const expected = known.get(host.split(".")[0]); + if (!expected) continue; + assert.equal(host, expected, `faq.json points at ${host}, not ${expected}`); + } +}); diff --git a/web/src/chrome.ts b/web/src/chrome.ts index b6246fb..76aa774 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -10,6 +10,7 @@ import { alphaStamp } from "./alpha-stamp"; import { apiVersion as sharedVersion } from "./api"; import { type AboutPageId, destinationForRoute } from "./destinations"; import { el } from "./dom"; +import { PROJECT_LINKS } from "./project-links"; export type Route = | "home" @@ -117,6 +118,21 @@ export function footer(): HTMLElement { textContent: "lance.blue is a non-commercial project, in alpha, and undergoing development. Please see our credits page for attribution, copyright, trademark, and licenses information.", }), + projectLinks(), debug, ]); } + +/** + * The account and the repositories, on every page of the app. + * + * components/Footer.astro carries the same row on the pages the app never + * runs on, off the same list - see project-links.ts. + */ +function projectLinks(): HTMLElement { + const row = el("p", { className: "footer-links" }); + for (const link of PROJECT_LINKS) { + row.append(el("a", { href: link.href, textContent: link.label })); + } + return row; +} diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro index 0e3b27b..5f05055 100644 --- a/web/src/components/Footer.astro +++ b/web/src/components/Footer.astro @@ -6,6 +6,8 @@ * arena builds it just talked to. Nothing here talks to the API, so this one * names the site's build and stops. */ +import { PROJECT_LINKS } from "../project-links"; + const version = import.meta.env.DEV ? "dev" : (import.meta.env.VITE_BUILD_REF ?? "unstamped"); @@ -29,5 +31,12 @@ const version = import.meta.env.DEV development. Please see our credits page for attribution, copyright, trademark, and licenses information.
+ { + /* The account and the repositories. chrome.ts renders the same row from + the same list on the app's own footer. */ + } +site {version}
diff --git a/web/src/content/faq.json b/web/src/content/faq.json index f438d5f..a0d1cdb 100644 --- a/web/src/content/faq.json +++ b/web/src/content/faq.json @@ -70,7 +70,7 @@ ", ", {"a": "the lance.blue blog", "href": "https://lance.blue/blog"}, " and ", - {"a": "lance.blue on Tangled", "href": "https://tangled.net/lance.blue"}, + {"a": "lance.blue on Tangled", "href": "https://tangled.org/lance.blue"}, "!" ] } ] diff --git a/web/src/project-links.ts b/web/src/project-links.ts new file mode 100644 index 0000000..3ec65d3 --- /dev/null +++ b/web/src/project-links.ts @@ -0,0 +1,23 @@ +/** + * Where the project lives when it is not this site: the account it posts + * from, and the repositories it is written in. + * + * Two footers need them and they run at different times — Footer.astro is + * rendered by the build, chrome.ts by the app — which is the same split + * destinations.ts exists for, and the same reason this module imports + * nothing and touches no DOM. The alternative was writing both URLs twice and + * finding out they had drifted from a report that one of them 404s. + * + * They were reachable only from two FAQ answers before, which is a page a + * reader opens after deciding the project is worth looking into rather than + * before. + */ +export type ProjectLink = { + readonly href: string; + readonly label: string; +}; + +export const PROJECT_LINKS: readonly ProjectLink[] = [ + { href: "https://bsky.app/profile/lance.blue", label: "@lance.blue" }, + { href: "https://tangled.org/lance.blue", label: "Source on Tangled" }, +]; diff --git a/web/src/styles.css b/web/src/styles.css index 53d3c20..873e658 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2777,6 +2777,16 @@ footer p { margin: 0; } +/* The account and the repositories, on one line above the version. A row + rather than a sentence: they are two addresses, not prose, and at a phone's + width they wrap onto two lines instead of pushing the footer wide. */ +.footer-links { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.9rem; + margin-top: 0.5rem; +} + /* The version line: quiet, but there when a bug report needs it. */ footer .debug { margin-top: 0.5rem;