From 7772d340253e34a094423340ec2921c146814b6d Mon Sep 17 00:00:00 2001 From: Ritesh Yadav Date: Fri, 6 Nov 2026 09:42:45 +0530 Subject: [PATCH] site: publish an OpenSearch description so browsers can search the catalog (#241) --- site/README.md | 11 ++++++++--- site/build.mjs | 5 +++-- site/lib/exports.mjs | 26 +++++++++++++++++++++++-- site/lib/lib.test.mjs | 43 ++++++++++++++++++++++++++++++++++++++++++ site/public/index.html | 1 + 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/site/README.md b/site/README.md index 6591491..4ce8543 100644 --- a/site/README.md +++ b/site/README.md @@ -25,8 +25,10 @@ per entry, columns in this order: id, name, url, category, license, license_spdx commercial, attribution_required, formats, tags, added, verified, status, page, subcategories, publisher, attribution_string; new columns are appended, never inserted. List fields are joined with `; `, and a cell that starts with `=`, `+`, `-`, `@`, a tab or a -carriage return gets a leading `'` so a spreadsheet does not run it as a formula) and two -Atom feeds: +carriage return gets a leading `'` so a spreadsheet does not run it as a formula), +[`opensearch.xml`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/opensearch.xml) +(an OpenSearch 1.1 description document so browsers can add the catalog as a search engine) +and two Atom feeds: [`feed.xml`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/feed.xml), the 40 newest entries by `added` (the day each joined the catalog), and [`reverified.xml`](https://tmhsdigital.github.io/Free-Game-Dev-Assets/reverified.xml), the @@ -141,7 +143,7 @@ not counted. `pages.yml` also runs every Monday at 06:17 UTC so the ages keep mo when nothing is pushed; GitHub pauses scheduled workflows in a public repository after 60 days without activity, so a long quiet spell needs a manual run from the Actions tab. -## Sitemap +## Sitemap and OpenSearch `build.mjs` writes `sitemap.xml` (home, freshness, category pages, stacks and every non-deprecated entry) and links it from the homepage with ``. The `Sitemap:` line in @@ -150,6 +152,9 @@ under `/Free-Game-Dev-Assets/` it is never seen. Submit `https://tmhsdigital.github.io/Free-Game-Dev-Assets/sitemap.xml` once in Google Search Console and Bing Webmaster Tools; that is the step that gets it read. +`opensearch.xml` is published and linked from the homepage with `` +so browsers can discover and add the catalog as a search engine using `{searchTerms}` on `?q=`. + ## Link check `.github/workflows/links.yml` runs `site/check-links.mjs` every Monday at 07:41 UTC (and diff --git a/site/build.mjs b/site/build.mjs index 51db3bc..b9f53c2 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -24,7 +24,7 @@ import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs"; import { listEntryFiles, idMatchesFile } from "./lib/entry-files.mjs"; import { entryPageHtml, scriptJson } from "./lib/entry-page.mjs"; import { LinkError, makeLinkResolver, caseMismatch } from "./lib/links.mjs"; -import { atomFeed, catalogCsv, commitDatesFromLog, DATED_PATHS, homeJsonLd, sitemapXml } from "./lib/exports.mjs"; +import { atomFeed, catalogCsv, commitDatesFromLog, DATED_PATHS, homeJsonLd, openSearchXml, sitemapXml } from "./lib/exports.mjs"; import { schemaErrors } from "./lib/schema-check.mjs"; import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs"; import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs"; @@ -704,6 +704,7 @@ function main() { const modified = lastCommitDates(); writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified, categories: categoriesInSitemap, guides: config.guides })); writeOut("robots.txt", robotsTxt(config.site)); + writeOut("opensearch.xml", openSearchXml({ site: config.site })); writeOut("404.html", notFoundHtml(config.site)); writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.total, hasCard })); @@ -748,7 +749,7 @@ function main() { `Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok, ${stats.commercialVaries} per-file)` ); console.log( - `Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, ${categoryPages.written.length} category pages, ${guidePages.written.length} guide pages, sitemap.xml, robots.txt, 404.html, freshness/index.html` + `Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, ${categoryPages.written.length} category pages, ${guidePages.written.length} guide pages, sitemap.xml, robots.txt, 404.html, freshness/index.html, opensearch.xml` ); console.log(`${writes} of ${produced.size} output files changed${clean ? " (clean build)" : ""}; ${((Date.now() - started) / 1000).toFixed(1)}s`); } diff --git a/site/lib/exports.mjs b/site/lib/exports.mjs index 695bb28..755ae00 100644 --- a/site/lib/exports.mjs +++ b/site/lib/exports.mjs @@ -1,7 +1,7 @@ /** * Flat exports of the catalog: catalog.csv for spreadsheets, feed.xml (Atom) - * for readers and sitemap.xml for crawlers. data.json stays the full - * machine-readable catalog. + * for readers, sitemap.xml for crawlers and opensearch.xml for browsers. + * data.json stays the full machine-readable catalog. */ import { esc } from "./shared.mjs"; @@ -52,6 +52,28 @@ export function sitemapXml({ site, entries, stacks = [], stamp, modified = new M return `\n\n${urls.join("\n")}\n\n`; } +/** + * opensearch.xml. An OpenSearch 1.1 description document so browsers can add + * the catalog as a search engine and search it directly from the address bar. + */ +export function openSearchXml(siteOrOpts) { + const site = siteOrOpts?.site || siteOrOpts; + const base = String(site?.siteUrl || "").replace(/\/+$/, ""); + return [ + '', + '', + ` ${esc(site?.title)}`, + ` ${esc(site?.tagline)}`, + ' UTF-8', + ` ${esc(base)}/favicon.svg`, + ` `, + '', + '', + ].join('\n'); +} + +export const opensearchXml = openSearchXml; + const CSV_COLUMNS = [ "id", "name", diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index 51e94e6..78d0f51 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -1685,6 +1685,49 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q: has("safe-fetch: DNS failure keeps its code", await errOf(safeFetch("https://missing.example/", {}, opts)), "ENOTFOUND"); } +/* opensearch.xml (#241) ---------------------------------------------------- */ +import { openSearchXml } from "./exports.mjs"; +{ + const osSite = { + title: "Free Game Dev Assets", + tagline: "Free assets and tools for commercial games, with every licence read at its source.", + siteUrl: "https://tmhsdigital.github.io/Free-Game-Dev-Assets/", + }; + const xml = openSearchXml({ site: osSite }); + has("opensearch: xml header", xml, ''); + has("opensearch: OpenSearchDescription root with namespace", xml, ''); + has("opensearch: ShortName matches site title", xml, "Free Game Dev Assets"); + has("opensearch: Description matches site tagline", xml, "Free assets and tools for commercial games, with every licence read at its source."); + has("opensearch: InputEncoding is UTF-8", xml, "UTF-8"); + has("opensearch: Image points to favicon.svg", xml, 'https://tmhsdigital.github.io/Free-Game-Dev-Assets/favicon.svg'); + has("opensearch: Url template queries ?q={searchTerms}", xml, ''); + + // Trailing slash handling and XML escaping + const xmlEscaped = openSearchXml({ + site: { + title: "Assets & Tools ", + tagline: 'Quotes "and" & signs', + siteUrl: "https://example.com/catalog", + }, + }); + has("opensearch: title is XML escaped", xmlEscaped, "Assets & Tools <Free>"); + has("opensearch: tagline is XML escaped", xmlEscaped, "Quotes "and" & signs"); + has("opensearch: Url template handles siteUrl without trailing slash", xmlEscaped, ''); + has("opensearch: Image handles siteUrl without trailing slash", xmlEscaped, 'https://example.com/catalog/favicon.svg'); + + // Direct site argument overload + const xmlDirect = openSearchXml(osSite); + eq("opensearch: accepts direct site object", xmlDirect, xml); + + // Homepage links opensearch.xml + const indexHtml = nodeFs.readFileSync(new URL("../public/index.html", import.meta.url), "utf8"); + has( + "homepage links opensearch.xml", + indexHtml, + '' + ); +} + /* report (keep last) ------------------------------------------------------ */ if (failures.length) { console.error(`lib.test failed (${failures.length}):`); diff --git a/site/public/index.html b/site/public/index.html index 80ae5f1..c74051f 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -8,6 +8,7 @@ name="description" content="Curated free game assets and tools, with every licence read at its source." /> +