@sorane/core 0.2.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.
Files changed (38) hide show
  1. package/package.json +42 -0
  2. package/src/ai-disclosure.ts +181 -0
  3. package/src/asset-provenance.ts +141 -0
  4. package/src/associated-media.ts +93 -0
  5. package/src/blog-pages.ts +175 -0
  6. package/src/build.ts +1109 -0
  7. package/src/c2pa-pass.ts +116 -0
  8. package/src/catalog.ts +61 -0
  9. package/src/config.ts +255 -0
  10. package/src/diagrams/compile-d2.ts +70 -0
  11. package/src/diagrams/compile-graphviz.ts +71 -0
  12. package/src/diagrams/compile-mermaid.ts +102 -0
  13. package/src/diagrams/diagram-hash.ts +5 -0
  14. package/src/diagrams/diagram-meta.ts +74 -0
  15. package/src/diagrams/emit-diagram-assets.ts +135 -0
  16. package/src/diagrams/mermaid-head.ts +6 -0
  17. package/src/diagrams/needs-async-compile.ts +12 -0
  18. package/src/diagrams/parse-diagram-fence.ts +109 -0
  19. package/src/diagrams/rehype-diagram-pre.ts +39 -0
  20. package/src/diagrams/remark-inject-built-figures.ts +32 -0
  21. package/src/diagrams/render-async.ts +241 -0
  22. package/src/diagrams/render-body-section.ts +52 -0
  23. package/src/diagrams/validate-diagram-alt.ts +56 -0
  24. package/src/docs.ts +257 -0
  25. package/src/emit-page.ts +87 -0
  26. package/src/index.ts +49 -0
  27. package/src/iptc-xmp-pass.ts +94 -0
  28. package/src/markdown-image-refs.ts +135 -0
  29. package/src/migrate.ts +60 -0
  30. package/src/not-found.ts +64 -0
  31. package/src/og-meta.ts +18 -0
  32. package/src/render.ts +233 -0
  33. package/src/site-labels.ts +97 -0
  34. package/src/site-meta.ts +138 -0
  35. package/src/ssg.ts +676 -0
  36. package/src/static-assets.ts +198 -0
  37. package/src/theme-assets.ts +16 -0
  38. package/src/validate-heading-structure.ts +51 -0
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@sorane/core",
3
+ "version": "0.2.0",
4
+ "description": "OKF-native static site build engine for sorane",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/masanork/sorane.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "homepage": "https://sorane.dev",
13
+ "bugs": "https://github.com/masanork/sorane/issues",
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "engines": {
21
+ "node": ">=23.6"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@mermaid-js/mermaid-cli": "~11.15.0",
28
+ "@sorane/font": "0.2.0",
29
+ "@sorane/okf": "0.2.0",
30
+ "@sorane/search": "0.2.0",
31
+ "mermaid": "~11.15.0",
32
+ "rehype-autolink-headings": "^7.1.0",
33
+ "rehype-raw": "^7.0.0",
34
+ "rehype-sanitize": "^6.0.0",
35
+ "rehype-stringify": "^10.0.1",
36
+ "remark-gfm": "^4.0.1",
37
+ "remark-parse": "^11.0.0",
38
+ "remark-rehype": "^11.1.2",
39
+ "unified": "^11.0.0",
40
+ "unist-util-visit": "^5.1.0"
41
+ }
42
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ inferEuLabel,
3
+ parseAiSystems,
4
+ parseEuAiLabel,
5
+ resolveDigitalSourceType,
6
+ showsEuBadge,
7
+ type AiSystemRef,
8
+ type EuAiLabel,
9
+ } from "@sorane/okf";
10
+ import { escapeHtml } from "./render.ts";
11
+ import { siteLabels } from "./site-labels.ts";
12
+
13
+ export type { AiSystemRef, EuAiLabel };
14
+
15
+ export interface AiDisclosure {
16
+ readonly digitalSourceType: string;
17
+ readonly digitalSourceCode: string;
18
+ readonly euLabel?: EuAiLabel;
19
+ readonly note?: string;
20
+ readonly systems?: readonly AiSystemRef[];
21
+ readonly showBadge: boolean;
22
+ }
23
+
24
+ export interface AiDisclosureConfig {
25
+ readonly enabled?: boolean;
26
+ readonly badges?: boolean;
27
+ readonly json_ld?: boolean;
28
+ readonly machine_readable?: boolean;
29
+ readonly atom?: boolean;
30
+ readonly show_on_lists?: boolean;
31
+ readonly policy_url?: string;
32
+ }
33
+
34
+ export interface ResolvedAiDisclosureFlags {
35
+ readonly badges: boolean;
36
+ readonly jsonLd: boolean;
37
+ readonly machineReadable: boolean;
38
+ readonly atom: boolean;
39
+ readonly showOnLists: boolean;
40
+ readonly policyUrl?: string;
41
+ }
42
+
43
+ export function parseAiDisclosure(
44
+ frontmatter: Record<string, unknown>,
45
+ ): AiDisclosure | null {
46
+ const raw = frontmatter.digitalSourceType;
47
+ if (typeof raw !== "string" || raw.trim().length === 0) return null;
48
+
49
+ const resolved = resolveDigitalSourceType(raw);
50
+ if (!resolved) return null;
51
+
52
+ const euOverride = parseEuAiLabel(frontmatter.euAiLabel);
53
+ const note =
54
+ typeof frontmatter.aiDisclosureNote === "string" &&
55
+ frontmatter.aiDisclosureNote.trim().length > 0
56
+ ? frontmatter.aiDisclosureNote.trim()
57
+ : undefined;
58
+ const systems = parseAiSystems(frontmatter.aiSystems);
59
+
60
+ const showBadge = showsEuBadge(resolved.code, euOverride);
61
+ const euLabel = showBadge
62
+ ? (euOverride ?? inferEuLabel(resolved.code))
63
+ : euOverride;
64
+
65
+ return {
66
+ digitalSourceType: resolved.uri,
67
+ digitalSourceCode: resolved.code,
68
+ euLabel,
69
+ note,
70
+ systems,
71
+ showBadge: showBadge && euLabel !== undefined,
72
+ };
73
+ }
74
+
75
+ export function resolveAiDisclosureFlags(
76
+ config: AiDisclosureConfig | undefined,
77
+ hasDisclosureOnPage: boolean,
78
+ ): ResolvedAiDisclosureFlags {
79
+ const enabled = config?.enabled !== false;
80
+ const badges = config?.badges ?? enabled;
81
+ const jsonLd =
82
+ config?.json_ld ?? (hasDisclosureOnPage ? true : false);
83
+ const machineReadable =
84
+ config?.machine_readable ?? (hasDisclosureOnPage ? true : false);
85
+ const atom = config?.atom ?? machineReadable;
86
+ const showOnLists = config?.show_on_lists ?? false;
87
+
88
+ return {
89
+ badges: badges && hasDisclosureOnPage,
90
+ jsonLd: jsonLd && hasDisclosureOnPage,
91
+ machineReadable: machineReadable && hasDisclosureOnPage,
92
+ atom: atom && hasDisclosureOnPage,
93
+ showOnLists: showOnLists && hasDisclosureOnPage,
94
+ policyUrl: config?.policy_url,
95
+ };
96
+ }
97
+
98
+ function euLabelFile(label: EuAiLabel): string {
99
+ if (label === "fully-generated") return "fully-generated";
100
+ if (label === "partially-modified") return "partially-modified";
101
+ return "basic";
102
+ }
103
+
104
+ function disclosureTitle(label: EuAiLabel, lang: string): string {
105
+ const ja = lang.startsWith("ja");
106
+ if (label === "fully-generated") {
107
+ return ja ? "AI により完全生成されたコンテンツ" : "Fully AI-generated content";
108
+ }
109
+ if (label === "partially-modified") {
110
+ return ja ? "AI により部分的に改変されたコンテンツ" : "Partially AI-modified content";
111
+ }
112
+ return ja ? "AI が関与したコンテンツ" : "AI-involved content";
113
+ }
114
+
115
+ export function buildAiBadgeHtml(
116
+ d: AiDisclosure,
117
+ opts: {
118
+ readonly lang: string;
119
+ readonly rootPrefix: string;
120
+ readonly policyUrl?: string;
121
+ },
122
+ ): string {
123
+ if (!d.showBadge || !d.euLabel) return "";
124
+ const label = d.euLabel;
125
+ const icon = `${opts.rootPrefix}assets/ai-labels/${euLabelFile(label)}.svg`;
126
+ const title = disclosureTitle(label, opts.lang);
127
+ const detail = d.note ? `<p class="ai-disclosure-detail">${escapeHtml(d.note)}</p>` : "";
128
+ const policy = opts.policyUrl
129
+ ? `<p class="ai-disclosure-policy"><a href="${escapeHtml(opts.policyUrl)}">${escapeHtml(siteLabels(opts.lang).aiPolicyLink)}</a></p>`
130
+ : "";
131
+ const meta =
132
+ `<p class="ai-disclosure-meta">` +
133
+ `<a href="${escapeHtml(d.digitalSourceType)}" rel="external noopener">` +
134
+ `IPTC: ${escapeHtml(d.digitalSourceCode)}</a></p>`;
135
+ return (
136
+ `<aside class="ai-disclosure ai-disclosure--${escapeHtml(label)}" role="note" ` +
137
+ `aria-label="${escapeHtml(siteLabels(opts.lang).aiDisclosureAria)}">\n` +
138
+ `<img class="ai-disclosure-icon" src="${escapeHtml(icon)}" alt="" width="32" height="32" decoding="async" />\n` +
139
+ `<div class="ai-disclosure-text">\n` +
140
+ `<p class="ai-disclosure-title">${escapeHtml(title)}</p>\n` +
141
+ `${detail}\n` +
142
+ `${meta}\n` +
143
+ `${policy}\n` +
144
+ `</div>\n` +
145
+ `</aside>\n`
146
+ );
147
+ }
148
+
149
+ export function buildCompactAiBadgeHtml(
150
+ d: AiDisclosure,
151
+ opts: { readonly rootPrefix: string },
152
+ ): string {
153
+ if (!d.showBadge || !d.euLabel) return "";
154
+ const icon = `${opts.rootPrefix}assets/ai-labels/${euLabelFile(d.euLabel)}.svg`;
155
+ return (
156
+ `<img class="ai-disclosure-compact" src="${escapeHtml(icon)}" ` +
157
+ `alt="AI" width="20" height="20" decoding="async" />\n`
158
+ );
159
+ }
160
+
161
+ export function aiDisclosureJsonLdFields(
162
+ d: AiDisclosure,
163
+ ): Record<string, unknown> {
164
+ const fields: Record<string, unknown> = {
165
+ digitalSourceType: d.digitalSourceType,
166
+ };
167
+ if (d.note) {
168
+ fields.disambiguatingDescription = d.note;
169
+ }
170
+ if (d.systems?.length) {
171
+ fields.contributor = d.systems.map((s) => ({
172
+ "@type": "SoftwareApplication",
173
+ name: s.name,
174
+ ...(s.version ? { softwareVersion: s.version } : {}),
175
+ ...(s.provider
176
+ ? { author: { "@type": "Organization", name: s.provider } }
177
+ : {}),
178
+ }));
179
+ }
180
+ return fields;
181
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { parseYaml } from "@sorane/okf";
4
+ import { resolveDigitalSourceType } from "@sorane/okf";
5
+
6
+ export interface AssetAiSystemEntry {
7
+ readonly name: string;
8
+ readonly version?: string;
9
+ readonly provider?: string;
10
+ }
11
+
12
+ export interface AssetProvenanceEntry {
13
+ readonly digitalSourceType?: string;
14
+ readonly aiDisclosureNote?: string;
15
+ readonly createIntent?: string;
16
+ readonly aiSystems?: readonly AssetAiSystemEntry[];
17
+ }
18
+
19
+ export type AssetProvenanceMap = Readonly<Record<string, AssetProvenanceEntry>>;
20
+
21
+ const DEFAULT_MANIFEST_REL = "asset-provenance.yaml";
22
+
23
+ function parseAssetAiSystems(raw: unknown): readonly AssetAiSystemEntry[] | undefined {
24
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
25
+ const out: AssetAiSystemEntry[] = [];
26
+ for (const item of raw) {
27
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
28
+ const row = item as Record<string, unknown>;
29
+ const name = typeof row.name === "string" ? row.name.trim() : "";
30
+ if (name.length === 0) continue;
31
+ out.push({
32
+ name,
33
+ version: typeof row.version === "string" ? row.version : undefined,
34
+ provider: typeof row.provider === "string" ? row.provider : undefined,
35
+ });
36
+ }
37
+ return out.length > 0 ? out : undefined;
38
+ }
39
+
40
+ function normalizeAssetKey(key: string): string {
41
+ return key.replace(/\\/g, "/").replace(/^\.\//, "");
42
+ }
43
+
44
+ /** `content/asset-provenance.yaml` を読み込む(無ければ空)。 */
45
+ export function loadAssetProvenance(
46
+ contentDir: string,
47
+ manifestRel = DEFAULT_MANIFEST_REL,
48
+ ): AssetProvenanceMap {
49
+ const path = resolve(contentDir, manifestRel);
50
+ if (!existsSync(path)) return {};
51
+ const raw = parseYaml(readFileSync(path, "utf8"));
52
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
53
+ const assets = (raw as Record<string, unknown>).assets;
54
+ if (assets === null || typeof assets !== "object" || Array.isArray(assets)) return {};
55
+
56
+ const out: Record<string, AssetProvenanceEntry> = {};
57
+ for (const [key, value] of Object.entries(assets as Record<string, unknown>)) {
58
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
59
+ const entry = value as Record<string, unknown>;
60
+ const dst =
61
+ typeof entry.digitalSourceType === "string" ? entry.digitalSourceType : undefined;
62
+ const note =
63
+ typeof entry.aiDisclosureNote === "string" ? entry.aiDisclosureNote : undefined;
64
+ const createIntent =
65
+ typeof entry.createIntent === "string" ? entry.createIntent : undefined;
66
+ const aiSystems = parseAssetAiSystems(entry.aiSystems);
67
+ out[normalizeAssetKey(key)] = {
68
+ digitalSourceType: dst,
69
+ aiDisclosureNote: note,
70
+ createIntent,
71
+ aiSystems,
72
+ };
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export interface ProvenanceLookupHints {
78
+ readonly staticRel?: string;
79
+ readonly markdownPath?: string;
80
+ readonly publicPath?: string;
81
+ readonly contentRel?: string;
82
+ readonly sourceMdRel?: string;
83
+ }
84
+
85
+ function provenanceCandidates(hints: ProvenanceLookupHints): string[] {
86
+ const out: string[] = [];
87
+ const push = (value: string | undefined) => {
88
+ if (!value) return;
89
+ const key = normalizeAssetKey(value);
90
+ if (key.length > 0 && !out.includes(key)) out.push(key);
91
+ };
92
+
93
+ push(hints.staticRel);
94
+ push(hints.markdownPath);
95
+ push(hints.publicPath);
96
+ push(hints.contentRel);
97
+ if (hints.staticRel) push(join("static", hints.staticRel).replace(/\\/g, "/"));
98
+ if (hints.publicPath?.startsWith("static/")) {
99
+ push(hints.publicPath.slice("static/".length));
100
+ }
101
+ if (hints.sourceMdRel && hints.markdownPath) {
102
+ const sourceDir = hints.sourceMdRel.includes("/")
103
+ ? hints.sourceMdRel.replace(/\/[^/]+$/, "")
104
+ : "";
105
+ push(sourceDir.length > 0 ? `${sourceDir}/${hints.markdownPath}` : hints.markdownPath);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export function lookupAssetProvenance(
111
+ map: AssetProvenanceMap,
112
+ hints: string | ProvenanceLookupHints,
113
+ ): AssetProvenanceEntry | undefined {
114
+ const keys =
115
+ typeof hints === "string"
116
+ ? provenanceCandidates({ staticRel: hints })
117
+ : provenanceCandidates(hints);
118
+ for (const key of keys) {
119
+ const entry = map[key];
120
+ if (entry) return entry;
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /** C2PA `--create` に渡す IPTC 短コードを解決する。 */
126
+ export function resolveC2paCreateIntent(
127
+ entry: AssetProvenanceEntry | undefined,
128
+ fallback = "digitalCapture",
129
+ ): string {
130
+ if (entry?.createIntent) return entry.createIntent;
131
+ const dst = entry?.digitalSourceType;
132
+ if (!dst) return fallback;
133
+ const resolved = resolveDigitalSourceType(dst);
134
+ if (!resolved) return fallback;
135
+ const code = resolved.code;
136
+ if (code === "trainedAlgorithmicMedia" || code === "compositeWithTrainedAlgorithmicMedia") {
137
+ return code;
138
+ }
139
+ if (code === "digitalCreation" || code === "humanEdits") return "digitalCapture";
140
+ return fallback;
141
+ }
@@ -0,0 +1,93 @@
1
+ import { resolveDigitalSourceType } from "@sorane/okf";
2
+ import {
3
+ lookupAssetProvenance,
4
+ type AssetProvenanceEntry,
5
+ type AssetProvenanceMap,
6
+ } from "./asset-provenance.ts";
7
+ import { hasImageMetadataFields } from "./iptc-xmp-pass.ts";
8
+ import type { MarkdownImageRef } from "./markdown-image-refs.ts";
9
+
10
+ export interface AssociatedMediaItem {
11
+ readonly contentUrl: string;
12
+ readonly digitalSourceType: string;
13
+ readonly name?: string;
14
+ readonly encodingFormat?: string;
15
+ }
16
+
17
+ function encodingFormatFromPath(filePath: string): string | undefined {
18
+ const ext = filePath.split(".").pop()?.toLowerCase();
19
+ if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
20
+ if (ext === "png") return "image/png";
21
+ if (ext === "webp") return "image/webp";
22
+ return undefined;
23
+ }
24
+
25
+ function provenanceForRef(
26
+ map: AssetProvenanceMap,
27
+ ref: MarkdownImageRef,
28
+ ): AssetProvenanceEntry | undefined {
29
+ return lookupAssetProvenance(map, {
30
+ staticRel: ref.kind === "static" ? ref.publicPath.replace(/^static\//, "") : undefined,
31
+ markdownPath: ref.markdownPath,
32
+ publicPath: ref.publicPath,
33
+ contentRel: ref.publicPath,
34
+ sourceMdRel: ref.sourceMdRel,
35
+ });
36
+ }
37
+
38
+ function toAssociatedMediaItem(
39
+ ref: MarkdownImageRef,
40
+ entry: AssetProvenanceEntry,
41
+ baseUrl: string,
42
+ ): AssociatedMediaItem | null {
43
+ const dst = entry.digitalSourceType;
44
+ if (!dst) return null;
45
+ const resolved = resolveDigitalSourceType(dst);
46
+ if (!resolved) return null;
47
+
48
+ const path = ref.publicPath.startsWith("/") ? ref.publicPath : `/${ref.publicPath}`;
49
+ const contentUrl =
50
+ baseUrl.length > 0 ? `${baseUrl.replace(/\/$/, "")}${path}` : path.replace(/^\//, "");
51
+
52
+ return {
53
+ contentUrl,
54
+ digitalSourceType: resolved.uri,
55
+ ...(ref.alt.length > 0 ? { name: ref.alt } : {}),
56
+ encodingFormat: encodingFormatFromPath(ref.publicPath),
57
+ };
58
+ }
59
+
60
+ /** 記事本文のインライン画像から `associatedMedia` 用 ImageObject 配列を組み立てる。 */
61
+ export function buildAssociatedMediaForArticle(opts: {
62
+ readonly refs: readonly MarkdownImageRef[];
63
+ readonly provenance: AssetProvenanceMap;
64
+ readonly baseUrl: string;
65
+ }): AssociatedMediaItem[] {
66
+ const out: AssociatedMediaItem[] = [];
67
+ const seen = new Set<string>();
68
+
69
+ for (const ref of opts.refs) {
70
+ const entry = provenanceForRef(opts.provenance, ref);
71
+ if (!hasImageMetadataFields(entry)) continue;
72
+ const item = toAssociatedMediaItem(ref, entry!, opts.baseUrl);
73
+ if (!item || seen.has(item.contentUrl)) continue;
74
+ seen.add(item.contentUrl);
75
+ out.push(item);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ export function associatedMediaJsonLdFields(
81
+ items: readonly AssociatedMediaItem[],
82
+ ): Record<string, unknown> | null {
83
+ if (items.length === 0) return null;
84
+ return {
85
+ associatedMedia: items.map((item) => ({
86
+ "@type": "ImageObject",
87
+ contentUrl: item.contentUrl,
88
+ digitalSourceType: item.digitalSourceType,
89
+ ...(item.name ? { name: item.name } : {}),
90
+ ...(item.encodingFormat ? { encodingFormat: item.encodingFormat } : {}),
91
+ })),
92
+ };
93
+ }
@@ -0,0 +1,175 @@
1
+ import { buildCompactAiBadgeHtml } from "./ai-disclosure.ts";
2
+ import type { ArticleListEntry } from "./ssg.ts";
3
+ import { escapeHtml } from "./render.ts";
4
+ import { relLinkFrom, renderBlogIndexBody, slugifyTag } from "./ssg.ts";
5
+
6
+ export { slugifyTag };
7
+
8
+ export function groupByYearMonth(
9
+ articles: readonly ArticleListEntry[],
10
+ ): Map<string, ArticleListEntry[]> {
11
+ const byMonth = new Map<string, ArticleListEntry[]>();
12
+ for (const a of articles) {
13
+ const ym = a.timestamp?.slice(0, 7);
14
+ if (!ym) continue;
15
+ const list = byMonth.get(ym) ?? [];
16
+ list.push(a);
17
+ byMonth.set(ym, list);
18
+ }
19
+ return byMonth;
20
+ }
21
+
22
+ export function groupByYear(
23
+ articles: readonly ArticleListEntry[],
24
+ ): Map<string, ArticleListEntry[]> {
25
+ const byYear = new Map<string, ArticleListEntry[]>();
26
+ for (const a of articles) {
27
+ const y = a.timestamp?.slice(0, 4);
28
+ if (!y) continue;
29
+ const list = byYear.get(y) ?? [];
30
+ list.push(a);
31
+ byYear.set(y, list);
32
+ }
33
+ return byYear;
34
+ }
35
+
36
+ export function groupByTag(
37
+ articles: readonly ArticleListEntry[],
38
+ ): Map<string, ArticleListEntry[]> {
39
+ const byTag = new Map<string, ArticleListEntry[]>();
40
+ for (const a of articles) {
41
+ for (const tag of a.tags ?? []) {
42
+ const slug = slugifyTag(tag);
43
+ if (!slug) continue;
44
+ const list = byTag.get(slug) ?? [];
45
+ list.push(a);
46
+ byTag.set(slug, list);
47
+ }
48
+ }
49
+ return byTag;
50
+ }
51
+
52
+ export function paginate<T>(items: readonly T[], pageSize: number): T[][] {
53
+ const pages: T[][] = [];
54
+ for (let i = 0; i < items.length; i += pageSize) {
55
+ pages.push(items.slice(i, i + pageSize));
56
+ }
57
+ return pages;
58
+ }
59
+
60
+ export function blogPaginationRel(page: number): string {
61
+ return page <= 1 ? "index.html" : `page/${page}.html`;
62
+ }
63
+
64
+ export function renderArchiveListBody(
65
+ title: string,
66
+ description: string | undefined,
67
+ articles: readonly ArticleListEntry[],
68
+ opts?: {
69
+ fromRel: string;
70
+ page?: number;
71
+ totalPages?: number;
72
+ showOnLists?: boolean;
73
+ listRootPrefix?: string;
74
+ },
75
+ ): string {
76
+ const fromRel = opts?.fromRel ?? "index.html";
77
+ const listPrefix = opts?.listRootPrefix ?? relLinkFrom(fromRel, "index.html").replace(/index\.html$/, "");
78
+ const items = articles
79
+ .map((a) => {
80
+ const date = a.timestamp?.slice(0, 10) ?? "";
81
+ const dateHtml = date
82
+ ? `<time datetime="${escapeHtml(date)}">${escapeHtml(date)}</time> · `
83
+ : "";
84
+ const href = relLinkFrom(fromRel, a.href);
85
+ const badge =
86
+ opts?.showOnLists && a.aiDisclosure?.showBadge
87
+ ? buildCompactAiBadgeHtml(a.aiDisclosure, { rootPrefix: listPrefix })
88
+ : "";
89
+ return (
90
+ `<li class="blog-list-item">` +
91
+ `${dateHtml}<a href="${escapeHtml(href)}" class="blog-list-title">${escapeHtml(a.title)}</a>` +
92
+ `${badge}` +
93
+ `</li>`
94
+ );
95
+ })
96
+ .join("\n");
97
+
98
+ let pager = "";
99
+ const page = opts?.page;
100
+ const totalPages = opts?.totalPages;
101
+ if (page !== undefined && totalPages !== undefined && totalPages > 1) {
102
+ const parts: string[] = [];
103
+ if (page > 1) {
104
+ parts.push(
105
+ `<a href="${escapeHtml(relLinkFrom(fromRel, blogPaginationRel(page - 1)))}" rel="prev">← 前へ</a>`,
106
+ );
107
+ }
108
+ parts.push(`<span>${page} / ${totalPages}</span>`);
109
+ if (page < totalPages) {
110
+ parts.push(
111
+ `<a href="${escapeHtml(relLinkFrom(fromRel, blogPaginationRel(page + 1)))}" rel="next">次へ →</a>`,
112
+ );
113
+ }
114
+ pager = `<nav class="blog-pagination" aria-label="ページ">${parts.join(" · ")}</nav>`;
115
+ }
116
+
117
+ return (
118
+ `<div class="blog-index">\n` +
119
+ `<header class="blog-header"><h1>${escapeHtml(title)}</h1>` +
120
+ (description ? `<p class="blog-lead">${escapeHtml(description)}</p>` : "") +
121
+ `</header>\n` +
122
+ `<ul class="blog-list">\n${items}\n</ul>\n` +
123
+ `${pager}\n` +
124
+ `</div>\n`
125
+ );
126
+ }
127
+
128
+ export function renderYearArchiveIndexBody(
129
+ siteTitle: string,
130
+ byYear: Map<string, ArticleListEntry[]>,
131
+ fromRel = "archive/index.html",
132
+ ): string {
133
+ const years = [...byYear.keys()].sort((a, b) => b.localeCompare(a));
134
+ const items = years
135
+ .map((y) => {
136
+ const count = byYear.get(y)!.length;
137
+ const href = relLinkFrom(fromRel, `archive/${y}.html`);
138
+ return `<li><a href="${escapeHtml(href)}">${escapeHtml(y)}</a> (${count})</li>`;
139
+ })
140
+ .join("\n");
141
+ return (
142
+ `<div class="blog-index">\n` +
143
+ `<header class="blog-header"><h1>${escapeHtml(siteTitle)} — 年別アーカイブ</h1></header>\n` +
144
+ `<ul class="blog-list">\n${items}\n</ul>\n` +
145
+ `</div>\n`
146
+ );
147
+ }
148
+
149
+ export function renderMonthListForYear(
150
+ year: string,
151
+ byMonth: Map<string, ArticleListEntry[]>,
152
+ fromRel = `archive/${year}.html`,
153
+ ): string {
154
+ const months = [...byMonth.keys()]
155
+ .filter((ym) => ym.startsWith(year))
156
+ .sort((a, b) => b.localeCompare(a));
157
+ const items = months
158
+ .map((ym) => {
159
+ const count = byMonth.get(ym)!.length;
160
+ const label = ym.slice(5);
161
+ const href = relLinkFrom(fromRel, `archive/${ym}.html`);
162
+ return `<li><a href="${escapeHtml(href)}">${escapeHtml(year)}年${escapeHtml(label)}月</a> (${count})</li>`;
163
+ })
164
+ .join("\n");
165
+ const backHref = relLinkFrom(fromRel, "archive/index.html");
166
+ return (
167
+ `<div class="blog-index">\n` +
168
+ `<header class="blog-header"><h1>${escapeHtml(year)}年</h1></header>\n` +
169
+ `<ul class="blog-list">\n${items}\n</ul>\n` +
170
+ `<p><a href="${escapeHtml(backHref)}">← 年別アーカイブ</a></p>\n` +
171
+ `</div>\n`
172
+ );
173
+ }
174
+
175
+ export { renderBlogIndexBody };