@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.
@@ -0,0 +1,108 @@
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
+ /** The default description, 140 to 160 characters. */
39
+ description: string;
40
+ /** Open Graph locale. Defaults to en_US. */
41
+ locale?: string;
42
+ /** Twitter handles, with the @. */
43
+ twitter?: {
44
+ site?: string;
45
+ creator?: string;
46
+ };
47
+ /** The default preview image(s). Every page keeps them unless it passes its own. */
48
+ ogImages: readonly OgImage[];
49
+ organization: Organization;
50
+ /** The parent site of a tool (www.haruhime.moe): the WebSite's isPartOf. */
51
+ parent?: {
52
+ name: string;
53
+ url: string;
54
+ };
55
+ };
56
+ /** The haruhime.moe organization the four sites share (A6: one entity, one @id). */
57
+ export declare const HARUHIME_ORG: Organization;
58
+ /** The separator between a page's keyword and the site in every title. */
59
+ export declare const TITLE_SEPARATOR = " \u00B7 ";
60
+ /**
61
+ * @function origin
62
+ * @param url {string} a site or organization URL
63
+ * @returns {string} its origin, no trailing slash
64
+ * @throws {TypeError} when url isn't an absolute URL
65
+ */
66
+ export declare const origin: (url: string) => string;
67
+ /**
68
+ * @function absoluteUrl
69
+ * @param site {Pick<Site, "url">} the site
70
+ * @param path {string} a path starting with "/", or an absolute URL on the site's origin
71
+ * @returns {string} the absolute URL on the site's canonical origin
72
+ * @throws {Error} when path is relative or on another origin
73
+ */
74
+ export declare const absoluteUrl: (site: Pick<Site, "url">, path: string) => string;
75
+ /**
76
+ * @function nodeId
77
+ * @param url {string} a site or organization URL
78
+ * @param name {string} the fragment, like "organization", "website" or "app"
79
+ * @returns {string} a stable JSON-LD @id, like https://www.haruhime.moe/#organization
80
+ */
81
+ export declare const nodeId: (url: string, name: string) => string;
82
+ /**
83
+ * @function titleSuffix
84
+ * @param site {Site} the site
85
+ * @returns {string} what follows " · " in its titles: titleSuffix, or the host
86
+ */
87
+ export declare const titleSuffix: (site: Site) => string;
88
+ /**
89
+ * @function pageTitle
90
+ * @param site {Site} the site
91
+ * @param title {string} the page's primary keyword, like "Search osu! tournament mappools"
92
+ * @returns {string} "Primary keyword · host" (the suffix is added once, never twice)
93
+ * @throws {Error} when title is blank
94
+ */
95
+ export declare const pageTitle: (site: Site, title: string) => string;
96
+ /**
97
+ * @function isoDate
98
+ * @param value {string | Date | null | undefined} a date, maybe unknown
99
+ * @returns {string | undefined} ISO 8601, or undefined when missing or not a real date (never
100
+ * a made-up one)
101
+ */
102
+ export declare const isoDate: (value: string | Date | null | undefined) => string | undefined;
103
+ /**
104
+ * @function compact
105
+ * @param object {T} an object that may hold undefined values
106
+ * @returns {T} the same keys minus the undefined ones (so exactOptionalPropertyTypes holds)
107
+ */
108
+ export declare const compact: <T extends Record<string, unknown>>(object: T) => T;
@@ -0,0 +1,95 @@
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
+ /**
59
+ * @function titleSuffix
60
+ * @param site {Site} the site
61
+ * @returns {string} what follows " · " in its titles: titleSuffix, or the host
62
+ */
63
+ export const titleSuffix = (site) => site.titleSuffix ?? new URL(site.url).host;
64
+ /**
65
+ * @function pageTitle
66
+ * @param site {Site} the site
67
+ * @param title {string} the page's primary keyword, like "Search osu! tournament mappools"
68
+ * @returns {string} "Primary keyword · host" (the suffix is added once, never twice)
69
+ * @throws {Error} when title is blank
70
+ */
71
+ export const pageTitle = (site, title) => {
72
+ const keyword = title.replace(/\s+/g, " ").trim();
73
+ if (!keyword)
74
+ throw new Error("seo: a page title can't be blank");
75
+ const suffix = `${TITLE_SEPARATOR}${titleSuffix(site)}`;
76
+ return keyword.endsWith(suffix) ? keyword : `${keyword}${suffix}`;
77
+ };
78
+ /**
79
+ * @function isoDate
80
+ * @param value {string | Date | null | undefined} a date, maybe unknown
81
+ * @returns {string | undefined} ISO 8601, or undefined when missing or not a real date (never
82
+ * a made-up one)
83
+ */
84
+ export const isoDate = (value) => {
85
+ if (value === null || value === undefined || value === "")
86
+ return undefined;
87
+ const time = value instanceof Date ? value.getTime() : Date.parse(value);
88
+ return Number.isNaN(time) ? undefined : new Date(time).toISOString();
89
+ };
90
+ /**
91
+ * @function compact
92
+ * @param object {T} an object that may hold undefined values
93
+ * @returns {T} the same keys minus the undefined ones (so exactOptionalPropertyTypes holds)
94
+ */
95
+ 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.0",
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.3.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": [
@@ -75,7 +83,7 @@
75
83
  "check:consumer": "bun run build && node scripts/check-consumer.mjs"
76
84
  },
77
85
  "peerDependencies": {
78
- "@haruhimemoe/osu": "^0.2.0 || ^0.3.0",
86
+ "@haruhimemoe/osu": "^0.2.0 || ^0.3.0 || ^0.4.0",
79
87
  "@haruhimemoe/ui": "^0.5.0",
80
88
  "better-auth": "^1.7.5",
81
89
  "mongodb": "^7.6.0",