@haruhimemoe/next-kit 0.2.1 → 0.4.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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @file src/seo/site.ts
3
+ * @desc The site description every seo helper reads (`Site`), the haruhime.moe organization the
4
+ * four sites share, the "Primary keyword · host" title rule, and the small URL, date and
5
+ * @id helpers the metadata, sitemap and JSON-LD builders share.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ /** One Open Graph image. A relative `url` resolves against the site's origin. */
11
+ export type OgImage = {
12
+ url: string;
13
+ width?: number;
14
+ height?: number;
15
+ alt?: string;
16
+ type?: string;
17
+ };
18
+ /** The organization behind a site: schema.org Organization, and every page's publisher. */
19
+ export type Organization = {
20
+ name: string;
21
+ /** Its home page, like https://www.haruhime.moe. Its @id is `${url}/#organization`. */
22
+ url: string;
23
+ logo?: string;
24
+ email?: string;
25
+ /** Profiles that are the same entity: GitHub org, Discord invite, npm org. */
26
+ sameAs?: readonly string[];
27
+ };
28
+ /** One site: what siteMetadata, pageMetadata, robots, sitemapEntries and the ld builders read. */
29
+ export type Site = {
30
+ /** The product name, like "pools". og:site_name, applicationName, the WebSite name. */
31
+ name: string;
32
+ /** The canonical origin, like https://pools.haruhime.moe (www for haruhime.moe). */
33
+ url: string;
34
+ /** The home page's primary keyword, like "osu! tournament mappool builder". */
35
+ title: string;
36
+ /** What follows " · " in every title. Defaults to the host, like pools.haruhime.moe. */
37
+ titleSuffix?: string;
38
+ /** A shorter suffix, like "pools", for titles that would pass TITLE_MAX with the full one. */
39
+ shortTitleSuffix?: string;
40
+ /** The default description, 140 to 160 characters. */
41
+ description: string;
42
+ /** Open Graph locale. Defaults to en_US. */
43
+ locale?: string;
44
+ /** Twitter handles, with the @. */
45
+ twitter?: {
46
+ site?: string;
47
+ creator?: string;
48
+ };
49
+ /** The default preview image(s). Every page keeps them unless it passes its own. */
50
+ ogImages: readonly OgImage[];
51
+ organization: Organization;
52
+ /** The parent site of a tool (www.haruhime.moe): the WebSite's isPartOf. */
53
+ parent?: {
54
+ name: string;
55
+ url: string;
56
+ };
57
+ };
58
+ /** The haruhime.moe organization the four sites share (A6: one entity, one @id). */
59
+ export declare const HARUHIME_ORG: Organization;
60
+ /** The separator between a page's keyword and the site in every title. */
61
+ export declare const TITLE_SEPARATOR = " \u00B7 ";
62
+ /**
63
+ * @function origin
64
+ * @param url {string} a site or organization URL
65
+ * @returns {string} its origin, no trailing slash
66
+ * @throws {TypeError} when url isn't an absolute URL
67
+ */
68
+ export declare const origin: (url: string) => string;
69
+ /**
70
+ * @function absoluteUrl
71
+ * @param site {Pick<Site, "url">} the site
72
+ * @param path {string} a path starting with "/", or an absolute URL on the site's origin
73
+ * @returns {string} the absolute URL on the site's canonical origin
74
+ * @throws {Error} when path is relative or on another origin
75
+ */
76
+ export declare const absoluteUrl: (site: Pick<Site, "url">, path: string) => string;
77
+ /**
78
+ * @function nodeId
79
+ * @param url {string} a site or organization URL
80
+ * @param name {string} the fragment, like "organization", "website" or "app"
81
+ * @returns {string} a stable JSON-LD @id, like https://www.haruhime.moe/#organization
82
+ */
83
+ export declare const nodeId: (url: string, name: string) => string;
84
+ /** The longest title search results show in full; "auto" switches to the short suffix past it. */
85
+ export declare const TITLE_MAX = 60;
86
+ /**
87
+ * Which suffix a title gets: "full" (titleSuffix or the host), "short" (shortTitleSuffix, else
88
+ * full), "none" (the keyword alone), or "auto" (full, unless that passes TITLE_MAX and the site
89
+ * has a shortTitleSuffix).
90
+ */
91
+ export type TitleSuffixMode = "auto" | "full" | "short" | "none";
92
+ /**
93
+ * @function titleSuffix
94
+ * @param site {Site} the site
95
+ * @returns {string} what follows " · " in its titles: titleSuffix, or the host
96
+ */
97
+ export declare const titleSuffix: (site: Site) => string;
98
+ /**
99
+ * @function pageTitle
100
+ * @param site {Site} the site
101
+ * @param title {string} the page's primary keyword, like "Search osu! tournament mappools"
102
+ * @param mode {TitleSuffixMode} which suffix to add (default "full")
103
+ * @returns {string} "Primary keyword · host" (a suffix already there is never added twice)
104
+ * @throws {Error} when title is blank
105
+ */
106
+ export declare const pageTitle: (site: Site, title: string, mode?: TitleSuffixMode) => string;
107
+ /**
108
+ * @function isoDate
109
+ * @param value {string | Date | null | undefined} a date, maybe unknown
110
+ * @returns {string | undefined} ISO 8601, or undefined when missing or not a real date (never
111
+ * a made-up one)
112
+ */
113
+ export declare const isoDate: (value: string | Date | null | undefined) => string | undefined;
114
+ /**
115
+ * @function compact
116
+ * @param object {T} an object that may hold undefined values
117
+ * @returns {T} the same keys minus the undefined ones (so exactOptionalPropertyTypes holds)
118
+ */
119
+ export declare const compact: <T extends Record<string, unknown>>(object: T) => T;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file src/seo/site.ts
3
+ * @desc The site description every seo helper reads (`Site`), the haruhime.moe organization the
4
+ * four sites share, the "Primary keyword · host" title rule, and the small URL, date and
5
+ * @id helpers the metadata, sitemap and JSON-LD builders share.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Mon Sep 28, 2026
8
+ * @modified Mon Sep 28, 2026
9
+ */
10
+ /** The haruhime.moe organization the four sites share (A6: one entity, one @id). */
11
+ export const HARUHIME_ORG = {
12
+ name: "haruhime.moe",
13
+ url: "https://www.haruhime.moe",
14
+ logo: "https://www.haruhime.moe/apple-icon.png",
15
+ email: "contact@haruhime.moe",
16
+ sameAs: [
17
+ "https://github.com/haruhimemoe",
18
+ "https://discord.gg/bKy9kjMV4y",
19
+ "https://www.npmjs.com/org/haruhimemoe",
20
+ ],
21
+ };
22
+ /** The separator between a page's keyword and the site in every title. */
23
+ export const TITLE_SEPARATOR = " · ";
24
+ /**
25
+ * @function origin
26
+ * @param url {string} a site or organization URL
27
+ * @returns {string} its origin, no trailing slash
28
+ * @throws {TypeError} when url isn't an absolute URL
29
+ */
30
+ export const origin = (url) => new URL(url).origin;
31
+ /**
32
+ * @function absoluteUrl
33
+ * @param site {Pick<Site, "url">} the site
34
+ * @param path {string} a path starting with "/", or an absolute URL on the site's origin
35
+ * @returns {string} the absolute URL on the site's canonical origin
36
+ * @throws {Error} when path is relative or on another origin
37
+ */
38
+ export const absoluteUrl = (site, path) => {
39
+ const base = origin(site.url);
40
+ if (/^[a-z][a-z\d+.-]*:/i.test(path)) {
41
+ const url = new URL(path);
42
+ if (url.origin !== base)
43
+ throw new Error(`seo: ${path} is not on ${base}`);
44
+ return url.href;
45
+ }
46
+ if (!path.startsWith("/") || path.startsWith("//")) {
47
+ throw new Error(`seo: path "${path}" must start with one "/"`);
48
+ }
49
+ return `${base}${path}`;
50
+ };
51
+ /**
52
+ * @function nodeId
53
+ * @param url {string} a site or organization URL
54
+ * @param name {string} the fragment, like "organization", "website" or "app"
55
+ * @returns {string} a stable JSON-LD @id, like https://www.haruhime.moe/#organization
56
+ */
57
+ export const nodeId = (url, name) => `${origin(url)}/#${name}`;
58
+ /** The longest title search results show in full; "auto" switches to the short suffix past it. */
59
+ export const TITLE_MAX = 60;
60
+ /**
61
+ * @function titleSuffix
62
+ * @param site {Site} the site
63
+ * @returns {string} what follows " · " in its titles: titleSuffix, or the host
64
+ */
65
+ export const titleSuffix = (site) => site.titleSuffix ?? new URL(site.url).host;
66
+ /**
67
+ * @function pageTitle
68
+ * @param site {Site} the site
69
+ * @param title {string} the page's primary keyword, like "Search osu! tournament mappools"
70
+ * @param mode {TitleSuffixMode} which suffix to add (default "full")
71
+ * @returns {string} "Primary keyword · host" (a suffix already there is never added twice)
72
+ * @throws {Error} when title is blank
73
+ */
74
+ export const pageTitle = (site, title, mode = "full") => {
75
+ const keyword = title.replace(/\s+/g, " ").trim();
76
+ if (!keyword)
77
+ throw new Error("seo: a page title can't be blank");
78
+ const full = titleSuffix(site);
79
+ const suffixes = [full, site.shortTitleSuffix]
80
+ .filter(Boolean)
81
+ .map((s) => `${TITLE_SEPARATOR}${s}`);
82
+ if (mode === "none" || suffixes.some((suffix) => keyword.endsWith(suffix)))
83
+ return keyword;
84
+ const long = `${keyword}${TITLE_SEPARATOR}${full}`;
85
+ const short = site.shortTitleSuffix
86
+ ? `${keyword}${TITLE_SEPARATOR}${site.shortTitleSuffix}`
87
+ : long;
88
+ if (mode === "short")
89
+ return short;
90
+ if (mode === "auto" && long.length > TITLE_MAX)
91
+ return short;
92
+ return long;
93
+ };
94
+ /**
95
+ * @function isoDate
96
+ * @param value {string | Date | null | undefined} a date, maybe unknown
97
+ * @returns {string | undefined} ISO 8601, or undefined when missing or not a real date (never
98
+ * a made-up one)
99
+ */
100
+ export const isoDate = (value) => {
101
+ if (value === null || value === undefined || value === "")
102
+ return undefined;
103
+ const time = value instanceof Date ? value.getTime() : Date.parse(value);
104
+ return Number.isNaN(time) ? undefined : new Date(time).toISOString();
105
+ };
106
+ /**
107
+ * @function compact
108
+ * @param object {T} an object that may hold undefined values
109
+ * @returns {T} the same keys minus the undefined ones (so exactOptionalPropertyTypes holds)
110
+ */
111
+ export const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @file src/seo/sitemap.ts
3
+ * @desc sitemap.xml entries from static paths and records: absolute URLs on the canonical origin,
4
+ * one entry per URL, and lastModified only when it's known and real (audit T4: an import
5
+ * time or a made-up date teaches crawlers to ignore lastmod). Throws past 50,000 URLs, the
6
+ * sitemaps.org cap for one file.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import type { MetadataRoute } from "next";
12
+ import { type Site } from "./site.js";
13
+ /** The most URLs one sitemap file may list (sitemaps.org). */
14
+ export declare const SITEMAP_MAX_URLS = 50000;
15
+ /** How often a page changes, as sitemaps.org spells it. */
16
+ export type ChangeFrequency = NonNullable<MetadataRoute.Sitemap[number]["changeFrequency"]>;
17
+ /** One page: its path and what's known about it. */
18
+ export type SitemapRecord = {
19
+ /** A path like "/pools/otdb-98", or an absolute URL on the site's origin. */
20
+ path: string;
21
+ /** When its content last changed. Missing or invalid: no lastmod (never a guess). */
22
+ lastModified?: string | Date | null;
23
+ changeFrequency?: ChangeFrequency;
24
+ /** 0 to 1. */
25
+ priority?: number;
26
+ };
27
+ /** One group: plain paths, records, or both. */
28
+ export type SitemapGroup = readonly (string | SitemapRecord)[];
29
+ /**
30
+ * @function sitemapEntries
31
+ * @param site {Site} the site
32
+ * @param groups {readonly SitemapGroup[]} static paths and dynamic records, in the order they
33
+ * should appear
34
+ * @returns {MetadataRoute.Sitemap} one entry per URL (the first one wins), absolute URLs, and no
35
+ * lastModified where it's unknown
36
+ * @throws {Error} when a path isn't on the site, a priority is outside 0 to 1, or there are
37
+ * more than 50,000 URLs (split into sitemap files with generateSitemaps)
38
+ */
39
+ export declare const sitemapEntries: (site: Site, groups: readonly SitemapGroup[]) => MetadataRoute.Sitemap;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @file src/seo/sitemap.ts
3
+ * @desc sitemap.xml entries from static paths and records: absolute URLs on the canonical origin,
4
+ * one entry per URL, and lastModified only when it's known and real (audit T4: an import
5
+ * time or a made-up date teaches crawlers to ignore lastmod). Throws past 50,000 URLs, the
6
+ * sitemaps.org cap for one file.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import { absoluteUrl, compact, isoDate } from "./site.js";
12
+ /** The most URLs one sitemap file may list (sitemaps.org). */
13
+ export const SITEMAP_MAX_URLS = 50_000;
14
+ const toEntry = (site, item) => {
15
+ const record = typeof item === "string" ? { path: item } : item;
16
+ const { priority } = record;
17
+ if (priority !== undefined && !(priority >= 0 && priority <= 1)) {
18
+ throw new RangeError(`seo: sitemap priority for ${record.path} must be 0 to 1`);
19
+ }
20
+ return compact({
21
+ url: absoluteUrl(site, record.path),
22
+ lastModified: isoDate(record.lastModified),
23
+ changeFrequency: record.changeFrequency,
24
+ priority,
25
+ });
26
+ };
27
+ /**
28
+ * @function sitemapEntries
29
+ * @param site {Site} the site
30
+ * @param groups {readonly SitemapGroup[]} static paths and dynamic records, in the order they
31
+ * should appear
32
+ * @returns {MetadataRoute.Sitemap} one entry per URL (the first one wins), absolute URLs, and no
33
+ * lastModified where it's unknown
34
+ * @throws {Error} when a path isn't on the site, a priority is outside 0 to 1, or there are
35
+ * more than 50,000 URLs (split into sitemap files with generateSitemaps)
36
+ */
37
+ export const sitemapEntries = (site, groups) => {
38
+ const byUrl = new Map();
39
+ for (const group of groups) {
40
+ for (const item of group) {
41
+ const entry = toEntry(site, item);
42
+ if (!byUrl.has(entry.url))
43
+ byUrl.set(entry.url, entry);
44
+ }
45
+ }
46
+ if (byUrl.size > SITEMAP_MAX_URLS) {
47
+ throw new Error(`seo: ${byUrl.size} sitemap URLs, over the ${SITEMAP_MAX_URLS} a sitemap file may list. Split it with generateSitemaps.`);
48
+ }
49
+ return [...byUrl.values()];
50
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@haruhimemoe/next-kit",
3
- "version": "0.2.1",
4
- "description": "The Next.js server plumbing packs.haruhime.moe and pools.haruhime.moe share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, and the signed-in marker and account store for the browser.",
3
+ "version": "0.4.0",
4
+ "description": "The Next.js plumbing the haruhime.moe tools share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, the signed-in marker and account store for the browser, and SEO: metadata, robots.txt, sitemaps, JSON-LD and llms.txt.",
5
5
  "keywords": [
6
6
  "nextjs",
7
7
  "next.js",
@@ -10,7 +10,11 @@
10
10
  "osu!",
11
11
  "mongodb",
12
12
  "rate limit",
13
- "zod"
13
+ "zod",
14
+ "seo",
15
+ "json-ld",
16
+ "sitemap",
17
+ "llms.txt"
14
18
  ],
15
19
  "homepage": "https://github.com/haruhimemoe/next-kit#readme",
16
20
  "bugs": "https://github.com/haruhimemoe/next-kit/issues",
@@ -46,6 +50,10 @@
46
50
  "types": "./dist/testing/index.d.ts",
47
51
  "default": "./dist/testing/index.js"
48
52
  },
53
+ "./seo": {
54
+ "types": "./dist/seo/index.d.ts",
55
+ "default": "./dist/seo/index.js"
56
+ },
49
57
  "./package.json": "./package.json"
50
58
  },
51
59
  "files": [