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."
/>
+