@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 +20 -1
- package/README.md +59 -1
- package/dist/seo/describe.d.ts +20 -0
- package/dist/seo/describe.js +34 -0
- package/dist/seo/index.d.ts +19 -0
- package/dist/seo/index.js +18 -0
- package/dist/seo/ld-content.d.ts +105 -0
- package/dist/seo/ld-content.js +120 -0
- package/dist/seo/ld-site.d.ts +96 -0
- package/dist/seo/ld-site.js +135 -0
- package/dist/seo/ld.d.ts +32 -0
- package/dist/seo/ld.js +39 -0
- package/dist/seo/llms.d.ts +72 -0
- package/dist/seo/llms.js +85 -0
- package/dist/seo/metadata.d.ts +66 -0
- package/dist/seo/metadata.js +106 -0
- package/dist/seo/robots.d.ts +43 -0
- package/dist/seo/robots.js +55 -0
- package/dist/seo/site.d.ts +108 -0
- package/dist/seo/site.js +95 -0
- package/dist/seo/sitemap.d.ts +39 -0
- package/dist/seo/sitemap.js +50 -0
- package/package.json +12 -4
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.
|
|
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;
|