@haruhimemoe/next-kit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - `@haruhimemoe/next-kit/seo`, for www, packs, pools and bb. No runtime imports (Next's types only).
14
+ - Metadata: `siteMetadata`, `homeMetadata`, `pageMetadata`, `notFoundMetadata`, `pageTitle` ("keyword · host") and `clampDescription` (160 characters, cut at a word). `pageMetadata` always sets the canonical and og:url together and always writes a full openGraph with the site's images, so a page never loses its preview.
15
+ - `robots` with the AI crawler stance written down (`"allow"`, `"block-training"` or `"block-all"`) and the `AI_BOTS` table.
16
+ - `sitemapEntries`: absolute URLs, one per URL, lastmod only when it's a real date, and an error past 50,000 URLs.
17
+ - JSON-LD builders in `ld` (graph, Organization, WebSite with SearchAction, WebApplication, BreadcrumbList, ItemList, FAQPage, HowTo, TechArticle, CreativeWork, Dataset) with stable `@id`s, `HARUHIME_ORG`, and `serializeLd` for script-safe JSON.
18
+ - `llmsTxt`, `llmsFull` and `textResponse` for /llms.txt and /llms-full.txt.
19
+
20
+ ## [0.2.1] - 2026-09-28
21
+
22
+ ### Fixed
23
+
24
+ - The `@haruhimemoe/osu` peer range also allows `^0.4.0`, so apps on osu 0.4.0 install without npm's ERESOLVE.
25
+
9
26
  ## [0.2.0] - 2026-09-28
10
27
 
11
28
  ### Added
@@ -29,6 +46,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
29
46
  - `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
30
47
  - `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
31
48
 
32
- [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.2.0...HEAD
49
+ [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.3.0...HEAD
50
+ [0.3.0]: https://github.com/haruhimemoe/next-kit/compare/v0.2.1...v0.3.0
51
+ [0.2.1]: https://github.com/haruhimemoe/next-kit/compare/v0.2.0...v0.2.1
33
52
  [0.2.0]: https://github.com/haruhimemoe/next-kit/compare/v0.1.0...v0.2.0
34
53
  [0.1.0]: https://github.com/haruhimemoe/next-kit/releases/tag/v0.1.0
package/README.md CHANGED
@@ -9,6 +9,7 @@ The Next.js server plumbing the haruhime.moe tools share. [packs.haruhime.moe](h
9
9
  - **`/mongo`:** one MongoClient per process with Mongoose on the same client, index builds that never take the site down, and frozen collection names.
10
10
  - **`/auth`:** better-auth with osu! as the only way in, the indexes its collections need, and reading the caller.
11
11
  - **`/auth-react`:** the browser half: the signed-in marker cookie, the account store and `useAccount`, `RestoreSignedIn`, and the account components (Sign in with osu!, Sign out, the header's account menu, Delete my account) styled with `@haruhimemoe/ui`.
12
+ - **`/seo`:** Next.js metadata that keeps each page's canonical, og:url and preview image together, robots.txt with the AI crawler stance written down, sitemap entries with honest lastmod, schema.org JSON-LD builders, and llms.txt. No runtime imports; all four haruhime.moe sites use it.
12
13
  - **`/testing`:** Vitest helpers: one in-memory MongoDB per run, an msw server that refuses unhandled requests, and a fake env.
13
14
 
14
15
  Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
@@ -29,6 +30,7 @@ bun add @haruhimemoe/next-kit zod
29
30
  | `mongo` | `mongodb` ^7.6.0, `mongoose` ^9.10.2 |
30
31
  | `auth` | `better-auth` ^1.7.5, `mongodb`, `@haruhimemoe/osu` 0.2 or 0.3 |
31
32
  | `auth-react` | `react` ^19.3.0, `next` ^16.3.6, `@haruhimemoe/ui` ^0.5.0 (with its theme set up) |
33
+ | `seo` | `next` ^16.3.6 types only (nothing loads at runtime) |
32
34
  | `testing` | `vitest` ^5.0.1, `msw` ^2.15.0, `mongodb-memory-server` ^11.3.0 |
33
35
 
34
36
  ## Use
@@ -116,6 +118,35 @@ export const { SignInWithOsu, SignOutButton, AccountMenu, DeleteAccountForm } =
116
118
 
117
119
  Build the auth instance once (memoize `getAuth`), and use the same cookie name on both sides.
118
120
 
121
+ SEO: one `Site` per app, then one call per file.
122
+
123
+ ```ts
124
+ // src/constants/seo.ts
125
+ import { HARUHIME_ORG, type Site } from "@haruhimemoe/next-kit/seo";
126
+ export const SEO_SITE: Site = {
127
+ name: "pools",
128
+ url: "https://pools.haruhime.moe",
129
+ title: "osu! tournament mappool builder",
130
+ description: "Build an osu! tournament mappool: search every ranked map under HR or DT, check the content rules, then download it as a pack.",
131
+ ogImages: [{ url: "/opengraph-image.png", width: 1200, height: 630, alt: "pools" }],
132
+ organization: HARUHIME_ORG,
133
+ parent: { name: "haruhime.moe", url: "https://www.haruhime.moe" },
134
+ };
135
+
136
+ // src/app/layout.tsx: export const metadata = siteMetadata(SEO_SITE);
137
+ // src/app/page.tsx: export const metadata = homeMetadata(SEO_SITE);
138
+ // src/app/search/page.tsx
139
+ export const metadata = pageMetadata(SEO_SITE, { path: "/search", title: "Search osu! tournament mappools" });
140
+ // src/app/robots.ts
141
+ export default () => robots(SEO_SITE, { disallow: ["/api/", "/admin"], aiBots: "allow" });
142
+ // src/app/sitemap.ts
143
+ export default async () => sitemapEntries(SEO_SITE, [["/", "/search"], pools.map((p) => ({ path: `/pools/${p.id}`, lastModified: p.contentUpdatedAt }))]);
144
+ // src/app/page.tsx (render with @haruhimemoe/ui's JsonLd)
145
+ <JsonLd data={ld.graph(ld.webSite(SEO_SITE, { searchUrlTemplate: "/search?q={search_term_string}" }), ld.webApplication(SEO_SITE, { category: "UtilitiesApplication" }))} />
146
+ // src/app/llms.txt/route.ts
147
+ export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", summary: SEO_SITE.description, sections }));
148
+ ```
149
+
119
150
  ## API
120
151
 
121
152
  ### server
@@ -184,6 +215,33 @@ Build the auth instance once (memoize `getAuth`), and use the same cookie name o
184
215
  | `DeleteAccountForm({ username, appName, deletes, onDeleted, endpoint?, homeHref?, homeLabel?, fetcher? })` | Since 0.2.0. ui's `TypeToConfirm` on the username, then `DELETE endpoint` (default `/api/account`) with `{ username }`. 204: `onDeleted`, "Your account is deleted." and home. Another 2xx: `onDeleted` and the answer's `notice` after that line, staying. A refusal shows `error.message` (else "Deleting failed (status)."); no answer, "Couldn't reach <appName>. Your account is still there." `deletes` is inline content in a `<p>`. |
185
216
  | `osuAvatarSrc(url)`, `OSU_AVATAR_HOSTS` | Since 0.2.0. An osu! avatar URL on https when it's on a.ppy.sh or osu.ppy.sh (a bare path is osu.ppy.sh's), else null. |
186
217
 
218
+ ### seo
219
+
220
+ Since 0.3.0. Every helper takes the app's `Site`: `name`, `url` (the canonical origin), `title` (the home page's primary keyword), `titleSuffix?` (defaults to the host), `description`, `locale?` (en_US), `twitter?`, `ogImages`, `organization` and `parent?`.
221
+
222
+ | Export | What it does |
223
+ | --- | --- |
224
+ | `siteMetadata(site)` | The root layout's `Metadata`: `metadataBase`, title default "keyword · host" and template "%s · host", the clamped description, `applicationName`, openGraph (type, site name, locale, images) and the twitter card. No canonical (a layout canonical leaks into every child) and no icons (the app's icon files). |
225
+ | `homeMetadata(site, { title?, description? })` | `pageMetadata` for `/` with the site's keyword title. |
226
+ | `pageMetadata(site, { path, title, description?, index?, ogType?, images?, modifiedTime?, publishedTime? })` | An absolute "keyword · host" title, the description clamped to 160, `alternates.canonical` and `openGraph.url` set together to the same absolute URL, and a full openGraph and twitter card (images default to `site.ogImages`: Next replaces a layout's openGraph, it doesn't merge it). `index: false` adds noindex, follow. `ogType: "article"` writes the ISO times it has. Throws on a relative path, another origin or a blank title. |
227
+ | `notFoundMetadata(site, what?)` | "Pack not found · host" and noindex, for a `generateMetadata` whose record is missing. |
228
+ | `clampDescription(text, max?)`, `DESCRIPTION_MAX` | One line, at most `max` (160) characters: cut at a word, trailing punctuation dropped, "…" added. |
229
+ | `pageTitle(site, title)`, `TITLE_SEPARATOR` | "keyword · host", the suffix added once. |
230
+ | `robots(site, { allow?, disallow?, aiBots? })` | `MetadataRoute.Robots`: the `*` group, one group naming the allowed AI bots with the same rules (a bot with its own group ignores `*`), a `Disallow: /` group for blocked ones, the sitemap and the host. `aiBots`: `"allow"` (default), `"block-training"`, or `"block-all"` (Bingbot stays, or the site leaves Bing). |
231
+ | `AI_BOTS` | The named crawlers, each `{ userAgent, operator, kind: "training" \| "search", searchEngine? }`: GPTBot, OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, ClaudeBot, Claude-SearchBot, Claude-User, anthropic-ai, Google-Extended, Applebot-Extended, Bingbot, CCBot, Bytespider, meta-externalagent. |
232
+ | `sitemapEntries(site, groups)`, `SITEMAP_MAX_URLS` | `MetadataRoute.Sitemap` from groups of paths and `{ path, lastModified?, changeFrequency?, priority? }` records: absolute URLs on the canonical origin, the first entry per URL, `lastModified` only when it's a real date (never made up). Throws past 50,000 URLs or on a priority outside 0 to 1. |
233
+ | `ld.graph(...nodes)` | `{ "@context": "https://schema.org", "@graph": nodes }`. `@context` goes only here. |
234
+ | `ld.organization(org)`, `HARUHIME_ORG` | Organization with `@id` `https://www.haruhime.moe/#organization` and `sameAs` (GitHub, Discord, npm). |
235
+ | `ld.webSite(site, { searchUrlTemplate? })`, `SEARCH_TERM` | WebSite (`#website`) with publisher and parent by `@id`, and a SearchAction when the template holds `{search_term_string}`. |
236
+ | `ld.webApplication(site, { category, name?, description?, features?, path?, browserRequirements? })` | WebApplication (`#app`, or `<path>#app` for a sub-tool), free Offer, operatingSystem "Any", publisher the organization. |
237
+ | `ld.breadcrumbs(site, trail)`, `ld.itemList(site, items, { name? })` | BreadcrumbList and ItemList, positions from 1, absolute URLs. |
238
+ | `ld.faq(items)`, `ld.howTo({ name, description?, steps })` | FAQPage from `{ q, a }` and HowTo with numbered HowToSteps. |
239
+ | `ld.techArticle(site, opts)`, `ld.creativeWork(site, opts)`, `ld.dataset(site, opts)` | TechArticle (author defaults to the organization), CreativeWork and Dataset, with `dateModified` only when known and `isBasedOn` paths resolved. |
240
+ | `serializeLd(data)` | JSON with `<`, `>`, `&`, U+2028 and U+2029 escaped, safe inside a `<script>`. |
241
+ | `llmsTxt({ title, summary, notes?, sections })` | llms.txt (llmstxt.org): H1, blockquote, notes, then `## heading` link lists `- [title](url): note`. Brackets in titles are escaped, spaces and parens in URLs encoded, empty sections left out. |
242
+ | `llmsFull(parts, head?)` | llms-full.txt: each `{ title, url?, markdown }` as its own document, separated by `---`. |
243
+ | `textResponse(body, { maxAge?, sMaxAge?, type? })` | A 200 `text/plain; charset=utf-8` (or `text/markdown`) with public `Cache-Control` (an hour by default) and nosniff. |
244
+
187
245
  ### testing
188
246
 
189
247
  | Export | What it does |
@@ -211,7 +269,7 @@ For pools.haruhime.moe, `createMongo` runs `onConnect` (the privilege check, ind
211
269
 
212
270
  ## Compatibility
213
271
 
214
- ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth.
272
+ ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth. `seo` loads nothing at runtime (Next's types only), so it runs anywhere.
215
273
 
216
274
  ## License
217
275
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file src/seo/describe.ts
3
+ * @desc Meta descriptions: one line, at most 160 characters, cut at a word with "…" and no
4
+ * dangling comma, colon or dash before it (audit O4: pools served a 248-character default).
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ /** The longest description search results show in full. */
10
+ export declare const DESCRIPTION_MAX = 160;
11
+ /**
12
+ * @function clampDescription
13
+ * @param text {string} the description, maybe long or spread over lines
14
+ * @param max {number} the longest result, "…" included (default 160)
15
+ * @returns {string} the text on one line; when it's longer than max, cut at the last word that
16
+ * fits, trailing punctuation dropped, and "…" added. A single word longer than max is
17
+ * cut mid-word.
18
+ * @throws {RangeError} when max is under 2
19
+ */
20
+ export declare const clampDescription: (text: string, max?: number) => string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @file src/seo/describe.ts
3
+ * @desc Meta descriptions: one line, at most 160 characters, cut at a word with "…" and no
4
+ * dangling comma, colon or dash before it (audit O4: pools served a 248-character default).
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Mon Sep 28, 2026
7
+ * @modified Mon Sep 28, 2026
8
+ */
9
+ /** The longest description search results show in full. */
10
+ export const DESCRIPTION_MAX = 160;
11
+ const ELLIPSIS = "…";
12
+ /** Punctuation, dashes and open brackets that shouldn't sit right before the "…". */
13
+ const TRAILING = /[\s,;:.!?\-–—(["'“‘/&+]+$/u;
14
+ /**
15
+ * @function clampDescription
16
+ * @param text {string} the description, maybe long or spread over lines
17
+ * @param max {number} the longest result, "…" included (default 160)
18
+ * @returns {string} the text on one line; when it's longer than max, cut at the last word that
19
+ * fits, trailing punctuation dropped, and "…" added. A single word longer than max is
20
+ * cut mid-word.
21
+ * @throws {RangeError} when max is under 2
22
+ */
23
+ export const clampDescription = (text, max = DESCRIPTION_MAX) => {
24
+ if (!Number.isInteger(max) || max < 2)
25
+ throw new RangeError("seo: max must be 2 or more");
26
+ const line = text.replace(/\s+/g, " ").trim();
27
+ if (line.length <= max)
28
+ return line;
29
+ const room = line.slice(0, max - ELLIPSIS.length + 1);
30
+ const space = room.lastIndexOf(" ");
31
+ const cut = space > 0 ? room.slice(0, space) : room.slice(0, max - ELLIPSIS.length);
32
+ const clean = cut.replace(TRAILING, "");
33
+ return `${clean || cut}${ELLIPSIS}`;
34
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file src/seo/index.ts
3
+ * @desc @haruhimemoe/next-kit/seo: metadata for the root layout and every page (canonical and
4
+ * og:url together, openGraph never dropped), robots.txt with an explicit AI stance, sitemap
5
+ * entries with honest lastmod, schema.org JSON-LD builders and script-safe serialization,
6
+ * and llms.txt. No runtime imports: Next's types only, so it runs anywhere.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ export { clampDescription, DESCRIPTION_MAX } from "./describe.js";
12
+ export { ld, serializeLd } from "./ld.js";
13
+ export type { CreativeWorkOptions, DatasetOptions, HowToOptions, LdAgent, TechArticleOptions, } from "./ld-content.js";
14
+ export { type LdGraph, type LdLink, type LdNode, SEARCH_TERM, type WebApplicationOptions, } from "./ld-site.js";
15
+ export { type LlmsFullPart, type LlmsLink, type LlmsSection, type LlmsTxtOptions, llmsFull, llmsTxt, type TextResponseOptions, textResponse, } from "./llms.js";
16
+ export { homeMetadata, notFoundMetadata, type PageMetadataOptions, pageMetadata, siteMetadata, } from "./metadata.js";
17
+ export { AI_BOTS, type AiBot, type AiBotKind, type AiBotsPolicy, type RobotsOptions, robots, } from "./robots.js";
18
+ export { HARUHIME_ORG, type OgImage, type Organization, pageTitle, type Site, TITLE_SEPARATOR, } from "./site.js";
19
+ export { type ChangeFrequency, SITEMAP_MAX_URLS, type SitemapGroup, type SitemapRecord, sitemapEntries, } from "./sitemap.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @file src/seo/index.ts
3
+ * @desc @haruhimemoe/next-kit/seo: metadata for the root layout and every page (canonical and
4
+ * og:url together, openGraph never dropped), robots.txt with an explicit AI stance, sitemap
5
+ * entries with honest lastmod, schema.org JSON-LD builders and script-safe serialization,
6
+ * and llms.txt. No runtime imports: Next's types only, so it runs anywhere.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ export { clampDescription, DESCRIPTION_MAX } from "./describe.js";
12
+ export { ld, serializeLd } from "./ld.js";
13
+ export { SEARCH_TERM, } from "./ld-site.js";
14
+ export { llmsFull, llmsTxt, textResponse, } from "./llms.js";
15
+ export { homeMetadata, notFoundMetadata, pageMetadata, siteMetadata, } from "./metadata.js";
16
+ export { AI_BOTS, robots, } from "./robots.js";
17
+ export { HARUHIME_ORG, pageTitle, TITLE_SEPARATOR, } from "./site.js";
18
+ export { SITEMAP_MAX_URLS, sitemapEntries, } from "./sitemap.js";
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file src/seo/ld-content.ts
3
+ * @desc schema.org JSON-LD for content pages: FAQPage, HowTo, TechArticle, CreativeWork and
4
+ * Dataset. Dates go out as ISO 8601 and only when known (audit A5); the author and
5
+ * publisher default to the site's organization by @id.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { type LdNode } from "./ld-site.js";
11
+ import { type Site } from "./site.js";
12
+ /** A person or group credited by name, or a node by @id. */
13
+ export type LdAgent = string | {
14
+ name: string;
15
+ url?: string;
16
+ } | {
17
+ "@id": string;
18
+ };
19
+ type When = string | Date | null | undefined;
20
+ /**
21
+ * @function faq
22
+ * @param items {readonly { q: string; a: string }[]} the questions and their answers, as shown
23
+ * @returns {LdNode} FAQPage with one Question and acceptedAnswer per item
24
+ * @throws {Error} when items is empty
25
+ */
26
+ export declare const faq: (items: readonly {
27
+ q: string;
28
+ a: string;
29
+ }[]) => LdNode;
30
+ /** howTo's input. */
31
+ export type HowToOptions = {
32
+ name: string;
33
+ description?: string;
34
+ steps: readonly {
35
+ name: string;
36
+ text: string;
37
+ url?: string;
38
+ }[];
39
+ };
40
+ /**
41
+ * @function howTo
42
+ * @param options {HowToOptions} the task and its steps, in order
43
+ * @returns {LdNode} HowTo with HowToSteps numbered from 1
44
+ * @throws {Error} when steps is empty
45
+ */
46
+ export declare const howTo: (options: HowToOptions) => LdNode;
47
+ /** techArticle's input. */
48
+ export type TechArticleOptions = {
49
+ path: string;
50
+ headline: string;
51
+ description?: string;
52
+ dateModified?: When;
53
+ datePublished?: When;
54
+ /** Defaults to the site's organization. */
55
+ author?: LdAgent;
56
+ /** What the article is about, like "osu! BBCode [imagemap] tag". */
57
+ about?: string;
58
+ };
59
+ /**
60
+ * @function techArticle
61
+ * @param site {Site} the site
62
+ * @param options {TechArticleOptions} the article
63
+ * @returns {LdNode} TechArticle with url, mainEntityOfPage, publisher and author
64
+ */
65
+ export declare const techArticle: (site: Site, options: TechArticleOptions) => LdNode;
66
+ /** creativeWork's input. */
67
+ export type CreativeWorkOptions = {
68
+ path: string;
69
+ name: string;
70
+ description?: string;
71
+ author?: LdAgent;
72
+ dateModified?: When;
73
+ /** What it was made from: a path on this site or an absolute URL, or several. */
74
+ isBasedOn?: string | readonly string[];
75
+ numberOfItems?: number;
76
+ genre?: string;
77
+ };
78
+ /**
79
+ * @function creativeWork
80
+ * @param site {Site} the site
81
+ * @param options {CreativeWorkOptions} the work (a pack, a template, a map page)
82
+ * @returns {LdNode} CreativeWork with url, publisher, and whatever else is known
83
+ */
84
+ export declare const creativeWork: (site: Site, options: CreativeWorkOptions) => LdNode;
85
+ /** dataset's input. */
86
+ export type DatasetOptions = {
87
+ path: string;
88
+ name: string;
89
+ /** Google requires 50 to 5000 characters. */
90
+ description: string;
91
+ creator?: LdAgent;
92
+ /** A year or ISO interval, like "2023". */
93
+ temporalCoverage?: string;
94
+ isBasedOn?: string | readonly string[];
95
+ license?: string;
96
+ dateModified?: When;
97
+ };
98
+ /**
99
+ * @function dataset
100
+ * @param site {Site} the site
101
+ * @param options {DatasetOptions} the dataset (a past tournament pool)
102
+ * @returns {LdNode} Dataset with url, publisher, and creator as an Organization when given
103
+ */
104
+ export declare const dataset: (site: Site, options: DatasetOptions) => LdNode;
105
+ export {};
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @file src/seo/ld-content.ts
3
+ * @desc schema.org JSON-LD for content pages: FAQPage, HowTo, TechArticle, CreativeWork and
4
+ * Dataset. Dates go out as ISO 8601 and only when known (audit A5); the author and
5
+ * publisher default to the site's organization by @id.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { orgRef } from "./ld-site.js";
11
+ import { absoluteUrl, compact, isoDate } from "./site.js";
12
+ const agent = (value, type = "Person") => typeof value === "string"
13
+ ? { "@type": type, name: value }
14
+ : "@id" in value
15
+ ? value
16
+ : compact({ "@type": type, name: value.name, url: value.url });
17
+ const urls = (site, value) => {
18
+ if (value === undefined)
19
+ return undefined;
20
+ const list = (typeof value === "string" ? [value] : [...value]).map((url) => url.startsWith("/") ? absoluteUrl(site, url) : url);
21
+ return list.length === 1 ? list[0] : list;
22
+ };
23
+ /**
24
+ * @function faq
25
+ * @param items {readonly { q: string; a: string }[]} the questions and their answers, as shown
26
+ * @returns {LdNode} FAQPage with one Question and acceptedAnswer per item
27
+ * @throws {Error} when items is empty
28
+ */
29
+ export const faq = (items) => {
30
+ if (!items.length)
31
+ throw new Error("seo: a FAQ needs at least one question");
32
+ return {
33
+ "@type": "FAQPage",
34
+ mainEntity: items.map(({ q, a }) => ({
35
+ "@type": "Question",
36
+ name: q,
37
+ acceptedAnswer: { "@type": "Answer", text: a },
38
+ })),
39
+ };
40
+ };
41
+ /**
42
+ * @function howTo
43
+ * @param options {HowToOptions} the task and its steps, in order
44
+ * @returns {LdNode} HowTo with HowToSteps numbered from 1
45
+ * @throws {Error} when steps is empty
46
+ */
47
+ export const howTo = (options) => {
48
+ if (!options.steps.length)
49
+ throw new Error("seo: a HowTo needs at least one step");
50
+ return compact({
51
+ "@type": "HowTo",
52
+ name: options.name,
53
+ description: options.description,
54
+ step: options.steps.map((step, i) => compact({
55
+ "@type": "HowToStep",
56
+ position: i + 1,
57
+ name: step.name,
58
+ text: step.text,
59
+ url: step.url,
60
+ })),
61
+ });
62
+ };
63
+ /**
64
+ * @function techArticle
65
+ * @param site {Site} the site
66
+ * @param options {TechArticleOptions} the article
67
+ * @returns {LdNode} TechArticle with url, mainEntityOfPage, publisher and author
68
+ */
69
+ export const techArticle = (site, options) => {
70
+ const url = absoluteUrl(site, options.path);
71
+ return compact({
72
+ "@type": "TechArticle",
73
+ headline: options.headline,
74
+ description: options.description,
75
+ url,
76
+ mainEntityOfPage: url,
77
+ about: options.about,
78
+ datePublished: isoDate(options.datePublished),
79
+ dateModified: isoDate(options.dateModified),
80
+ author: options.author ? agent(options.author) : orgRef(site),
81
+ publisher: orgRef(site),
82
+ });
83
+ };
84
+ /**
85
+ * @function creativeWork
86
+ * @param site {Site} the site
87
+ * @param options {CreativeWorkOptions} the work (a pack, a template, a map page)
88
+ * @returns {LdNode} CreativeWork with url, publisher, and whatever else is known
89
+ */
90
+ export const creativeWork = (site, options) => compact({
91
+ "@type": "CreativeWork",
92
+ name: options.name,
93
+ description: options.description,
94
+ url: absoluteUrl(site, options.path),
95
+ author: options.author ? agent(options.author) : undefined,
96
+ dateModified: isoDate(options.dateModified),
97
+ isBasedOn: urls(site, options.isBasedOn),
98
+ numberOfItems: options.numberOfItems,
99
+ genre: options.genre,
100
+ publisher: orgRef(site),
101
+ });
102
+ /**
103
+ * @function dataset
104
+ * @param site {Site} the site
105
+ * @param options {DatasetOptions} the dataset (a past tournament pool)
106
+ * @returns {LdNode} Dataset with url, publisher, and creator as an Organization when given
107
+ */
108
+ export const dataset = (site, options) => compact({
109
+ "@type": "Dataset",
110
+ name: options.name,
111
+ description: options.description,
112
+ url: absoluteUrl(site, options.path),
113
+ creator: options.creator ? agent(options.creator, "Organization") : undefined,
114
+ temporalCoverage: options.temporalCoverage,
115
+ isBasedOn: urls(site, options.isBasedOn),
116
+ license: options.license,
117
+ dateModified: isoDate(options.dateModified),
118
+ isAccessibleForFree: true,
119
+ publisher: orgRef(site),
120
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file src/seo/ld-site.ts
3
+ * @desc schema.org JSON-LD for the site itself: the graph wrapper (the only place `@context`
4
+ * goes), Organization, WebSite with its SearchAction, WebApplication, BreadcrumbList and
5
+ * ItemList. Stable @ids tie every tool to one organization (audit A3, A6).
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ import { type Organization, type Site } from "./site.js";
11
+ /** One schema.org node, without `@context`. */
12
+ export type LdNode = {
13
+ "@type": string;
14
+ "@id"?: string;
15
+ [key: string]: unknown;
16
+ };
17
+ /** A JSON-LD document: `@context` once, the nodes in `@graph`. */
18
+ export type LdGraph = {
19
+ "@context": "https://schema.org";
20
+ "@graph": LdNode[];
21
+ };
22
+ /** A page named by its path on the site. */
23
+ export type LdLink = {
24
+ name: string;
25
+ path: string;
26
+ };
27
+ /** The placeholder a SearchAction's urlTemplate must hold. */
28
+ export declare const SEARCH_TERM = "{search_term_string}";
29
+ /**
30
+ * @function graph
31
+ * @param nodes {LdNode[]} the page's nodes
32
+ * @returns {LdGraph} one JSON-LD document with `@context` on the root only
33
+ */
34
+ export declare const graph: (...nodes: LdNode[]) => LdGraph;
35
+ /**
36
+ * @function organization
37
+ * @param org {Organization} the organization, like HARUHIME_ORG
38
+ * @returns {LdNode} Organization with @id `${origin}/#organization`
39
+ */
40
+ export declare const organization: (org: Organization) => LdNode;
41
+ /** A reference to the site's organization by @id. */
42
+ export declare const orgRef: (site: Site) => {
43
+ "@id": string;
44
+ };
45
+ /**
46
+ * @function webSite
47
+ * @param site {Site} the site
48
+ * @param options {{ searchUrlTemplate?: string }} a search URL holding {search_term_string},
49
+ * like "/search?q={search_term_string}"
50
+ * @returns {LdNode} WebSite with @id `${origin}/#website`, publisher → the organization,
51
+ * isPartOf → the parent site, and a SearchAction when a template is given
52
+ * @throws {Error} when the template lacks {search_term_string} or isn't on the site
53
+ */
54
+ export declare const webSite: (site: Site, options?: {
55
+ searchUrlTemplate?: string;
56
+ }) => LdNode;
57
+ /** webApplication's input. */
58
+ export type WebApplicationOptions = {
59
+ /** Defaults to the site's name. */
60
+ name?: string;
61
+ /** Defaults to the site's description. */
62
+ description?: string;
63
+ /** A schema.org category, like "UtilitiesApplication". */
64
+ category: string;
65
+ features?: readonly string[];
66
+ /** Defaults to "/". A sub-tool (bb's /collab) passes its own path; its @id is then url#app. */
67
+ path?: string;
68
+ /** Default "Requires JavaScript. Works in any modern browser." */
69
+ browserRequirements?: string;
70
+ };
71
+ /**
72
+ * @function webApplication
73
+ * @param site {Site} the site
74
+ * @param options {WebApplicationOptions} category, and optional name, features and path
75
+ * @returns {LdNode} WebApplication, free (Offer price 0), operatingSystem "Any", publisher →
76
+ * the organization
77
+ */
78
+ export declare const webApplication: (site: Site, options: WebApplicationOptions) => LdNode;
79
+ /**
80
+ * @function breadcrumbs
81
+ * @param site {Site} the site
82
+ * @param trail {readonly LdLink[]} from the home page down to this page
83
+ * @returns {LdNode} BreadcrumbList, positions from 1, absolute item URLs
84
+ * @throws {Error} when the trail is empty
85
+ */
86
+ export declare const breadcrumbs: (site: Site, trail: readonly LdLink[]) => LdNode;
87
+ /**
88
+ * @function itemList
89
+ * @param site {Site} the site
90
+ * @param items {readonly LdLink[]} the listed pages, in order
91
+ * @param options {{ name?: string }} the list's name
92
+ * @returns {LdNode} ItemList with numberOfItems and ListItems (position, name, url)
93
+ */
94
+ export declare const itemList: (site: Site, items: readonly LdLink[], options?: {
95
+ name?: string;
96
+ }) => LdNode;