@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
|
@@ -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;
|
package/dist/seo/site.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "The Next.js
|
|
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",
|