@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.
- package/package.json +42 -0
- package/src/ai-disclosure.ts +181 -0
- package/src/asset-provenance.ts +141 -0
- package/src/associated-media.ts +93 -0
- package/src/blog-pages.ts +175 -0
- package/src/build.ts +1109 -0
- package/src/c2pa-pass.ts +116 -0
- package/src/catalog.ts +61 -0
- package/src/config.ts +255 -0
- package/src/diagrams/compile-d2.ts +70 -0
- package/src/diagrams/compile-graphviz.ts +71 -0
- package/src/diagrams/compile-mermaid.ts +102 -0
- package/src/diagrams/diagram-hash.ts +5 -0
- package/src/diagrams/diagram-meta.ts +74 -0
- package/src/diagrams/emit-diagram-assets.ts +135 -0
- package/src/diagrams/mermaid-head.ts +6 -0
- package/src/diagrams/needs-async-compile.ts +12 -0
- package/src/diagrams/parse-diagram-fence.ts +109 -0
- package/src/diagrams/rehype-diagram-pre.ts +39 -0
- package/src/diagrams/remark-inject-built-figures.ts +32 -0
- package/src/diagrams/render-async.ts +241 -0
- package/src/diagrams/render-body-section.ts +52 -0
- package/src/diagrams/validate-diagram-alt.ts +56 -0
- package/src/docs.ts +257 -0
- package/src/emit-page.ts +87 -0
- package/src/index.ts +49 -0
- package/src/iptc-xmp-pass.ts +94 -0
- package/src/markdown-image-refs.ts +135 -0
- package/src/migrate.ts +60 -0
- package/src/not-found.ts +64 -0
- package/src/og-meta.ts +18 -0
- package/src/render.ts +233 -0
- package/src/site-labels.ts +97 -0
- package/src/site-meta.ts +138 -0
- package/src/ssg.ts +676 -0
- package/src/static-assets.ts +198 -0
- package/src/theme-assets.ts +16 -0
- package/src/validate-heading-structure.ts +51 -0
package/src/ssg.ts
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { dirname, relative } from "node:path";
|
|
2
|
+
import type { OkfConcept } from "@sorane/okf";
|
|
3
|
+
import type { AiDisclosure } from "./ai-disclosure.ts";
|
|
4
|
+
import { aiDisclosureJsonLdFields, buildCompactAiBadgeHtml } from "./ai-disclosure.ts";
|
|
5
|
+
import {
|
|
6
|
+
associatedMediaJsonLdFields,
|
|
7
|
+
type AssociatedMediaItem,
|
|
8
|
+
} from "./associated-media.ts";
|
|
9
|
+
import type { DiagramsConfig } from "./config.ts";
|
|
10
|
+
import {
|
|
11
|
+
renderBodySection,
|
|
12
|
+
renderBodySectionForConfig,
|
|
13
|
+
type BodySectionOptions,
|
|
14
|
+
} from "./diagrams/render-body-section.ts";
|
|
15
|
+
import type { DiagramRenderMeta } from "./diagrams/diagram-meta.ts";
|
|
16
|
+
import { escapeHtml, stripDuplicateTitleHeading } from "./render.ts";
|
|
17
|
+
import { ogLocaleFromLang } from "./og-meta.ts";
|
|
18
|
+
import { siteLabels, type SiteLabels } from "./site-labels.ts";
|
|
19
|
+
|
|
20
|
+
export function extractDescription(body: string, maxLen = 200): string | null {
|
|
21
|
+
const lines = body.split(/\r?\n/);
|
|
22
|
+
const para: string[] = [];
|
|
23
|
+
let inFence = false;
|
|
24
|
+
for (const raw of lines) {
|
|
25
|
+
const line = raw.trim();
|
|
26
|
+
if (/^(```|~~~)/.test(line)) {
|
|
27
|
+
inFence = !inFence;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (inFence) continue;
|
|
31
|
+
if (line.length === 0) {
|
|
32
|
+
if (para.length > 0) break;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|\||={3,}|-{3,}|<)/.test(line)) {
|
|
36
|
+
if (para.length > 0) break;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
para.push(line);
|
|
40
|
+
}
|
|
41
|
+
if (para.length === 0) return null;
|
|
42
|
+
let text = para
|
|
43
|
+
.join(" ")
|
|
44
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
|
45
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
46
|
+
.replace(/[*_`]+/g, "")
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
if (text.length === 0) return null;
|
|
50
|
+
if (text.length <= maxLen) return text;
|
|
51
|
+
return text.slice(0, maxLen).replace(/\s+\S*$/, "") + "…";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** リスト用 description の HTML タグ・エスケープ残骸を除去する。 */
|
|
55
|
+
export function sanitizeListDescription(text: string, maxLen = 200): string {
|
|
56
|
+
let t = text
|
|
57
|
+
.replace(/\\</g, "<")
|
|
58
|
+
.replace(/<[^>]+>/g, "")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/&/g, "&")
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/\s+/g, " ")
|
|
64
|
+
.trim();
|
|
65
|
+
if (t.length <= maxLen) return t;
|
|
66
|
+
return t.slice(0, maxLen).replace(/\s+\S*$/, "") + "…";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderFeaturedExcerpt(
|
|
70
|
+
concept: OkfConcept,
|
|
71
|
+
excerptLength: number,
|
|
72
|
+
): string {
|
|
73
|
+
const text =
|
|
74
|
+
concept.description ??
|
|
75
|
+
extractDescription(concept.body, excerptLength) ??
|
|
76
|
+
extractDescription(concept.body);
|
|
77
|
+
if (!text) return "";
|
|
78
|
+
return `<p>${escapeHtml(text)}</p>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildWebSiteJsonLd(opts: {
|
|
82
|
+
readonly title: string;
|
|
83
|
+
readonly description?: string;
|
|
84
|
+
readonly url?: string;
|
|
85
|
+
readonly lang: string;
|
|
86
|
+
}): string {
|
|
87
|
+
const data: Record<string, unknown> = {
|
|
88
|
+
"@context": "https://schema.org",
|
|
89
|
+
"@type": "WebSite",
|
|
90
|
+
name: opts.title,
|
|
91
|
+
inLanguage: opts.lang,
|
|
92
|
+
};
|
|
93
|
+
if (opts.description) data.description = opts.description;
|
|
94
|
+
if (opts.url) data.url = opts.url;
|
|
95
|
+
return `<script type="application/ld+json">${JSON.stringify(data)}</script>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PageShellOptions {
|
|
99
|
+
readonly title: string;
|
|
100
|
+
readonly siteTitle: string;
|
|
101
|
+
readonly bodyHtml: string;
|
|
102
|
+
readonly rootPrefix: string;
|
|
103
|
+
readonly description?: string;
|
|
104
|
+
readonly canonicalUrl?: string;
|
|
105
|
+
readonly machineSources?: ReadonlyArray<{ href: string; type: string }>;
|
|
106
|
+
readonly lang?: string;
|
|
107
|
+
readonly extraHead?: ReadonlyArray<string>;
|
|
108
|
+
readonly feedPath?: string;
|
|
109
|
+
readonly showArchiveNav?: boolean;
|
|
110
|
+
readonly searchPath?: string;
|
|
111
|
+
readonly pageKind?: "website" | "article";
|
|
112
|
+
readonly docsLayout?: boolean;
|
|
113
|
+
readonly docsSidebarHtml?: string;
|
|
114
|
+
readonly headerSearchHtml?: string;
|
|
115
|
+
readonly ogImageUrl?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ArticleListEntry {
|
|
119
|
+
readonly title: string;
|
|
120
|
+
readonly href: string;
|
|
121
|
+
readonly timestamp?: string;
|
|
122
|
+
readonly updated?: string;
|
|
123
|
+
readonly author?: string;
|
|
124
|
+
readonly description?: string;
|
|
125
|
+
readonly tags?: readonly string[];
|
|
126
|
+
readonly aiDisclosure?: AiDisclosure;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ArticleNav {
|
|
130
|
+
readonly prev?: { href: string; title: string };
|
|
131
|
+
readonly next?: { href: string; title: string };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface BlogIndexOptions {
|
|
135
|
+
readonly siteTitle: string;
|
|
136
|
+
readonly description?: string;
|
|
137
|
+
/** false のとき blog-header の h1 を出さない(site-header と重複させない) */
|
|
138
|
+
readonly showHeaderTitle?: boolean;
|
|
139
|
+
readonly profileUrl?: string;
|
|
140
|
+
readonly githubUrl?: string;
|
|
141
|
+
readonly introHtml?: string;
|
|
142
|
+
readonly latestArticle?: {
|
|
143
|
+
readonly title: string;
|
|
144
|
+
readonly href: string;
|
|
145
|
+
readonly timestamp?: string;
|
|
146
|
+
readonly updated?: string;
|
|
147
|
+
readonly author?: string;
|
|
148
|
+
readonly bodyHtml: string;
|
|
149
|
+
readonly aiDisclosure?: AiDisclosure;
|
|
150
|
+
};
|
|
151
|
+
readonly articles: readonly ArticleListEntry[];
|
|
152
|
+
readonly archiveLimit?: number;
|
|
153
|
+
readonly showListDescriptions?: boolean;
|
|
154
|
+
readonly lang?: string;
|
|
155
|
+
readonly labels?: SiteLabels;
|
|
156
|
+
readonly showOnLists?: boolean;
|
|
157
|
+
readonly listRootPrefix?: string;
|
|
158
|
+
/** トップのアーカイブ欄から続きの一覧へ(例: page/2.html) */
|
|
159
|
+
readonly moreArticlesHref?: string;
|
|
160
|
+
/** 年別アーカイブ index への相対 URL */
|
|
161
|
+
readonly yearArchiveHref?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildPage(opts: PageShellOptions): string {
|
|
165
|
+
const titleEsc = escapeHtml(opts.title);
|
|
166
|
+
const head: string[] = [
|
|
167
|
+
'<meta charset="utf-8">',
|
|
168
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
169
|
+
`<title>${titleEsc}</title>`,
|
|
170
|
+
];
|
|
171
|
+
if (opts.description) {
|
|
172
|
+
const d = escapeHtml(opts.description);
|
|
173
|
+
head.push(`<meta name="description" content="${d}">`);
|
|
174
|
+
head.push(`<meta property="og:description" content="${d}">`);
|
|
175
|
+
}
|
|
176
|
+
const ogType = opts.pageKind === "website" ? "website" : "article";
|
|
177
|
+
head.push(
|
|
178
|
+
`<meta property="og:title" content="${titleEsc}">`,
|
|
179
|
+
`<meta property="og:type" content="${ogType}">`,
|
|
180
|
+
`<meta property="og:site_name" content="${escapeHtml(opts.siteTitle)}">`,
|
|
181
|
+
`<link rel="stylesheet" href="${opts.rootPrefix}assets/main.css">`,
|
|
182
|
+
`<link rel="alternate" type="application/ld+json" href="${opts.rootPrefix}catalog.jsonld">`,
|
|
183
|
+
`<link rel="help" type="text/plain" href="${opts.rootPrefix}llms.txt">`,
|
|
184
|
+
);
|
|
185
|
+
if (opts.canonicalUrl) {
|
|
186
|
+
const u = escapeHtml(opts.canonicalUrl);
|
|
187
|
+
head.push(`<link rel="canonical" href="${u}">`);
|
|
188
|
+
head.push(`<meta property="og:url" content="${u}">`);
|
|
189
|
+
}
|
|
190
|
+
const lang = opts.lang ?? "ja";
|
|
191
|
+
head.push(`<meta property="og:locale" content="${escapeHtml(ogLocaleFromLang(lang))}">`);
|
|
192
|
+
if (opts.ogImageUrl) {
|
|
193
|
+
const img = escapeHtml(opts.ogImageUrl);
|
|
194
|
+
head.push(`<meta property="og:image" content="${img}">`);
|
|
195
|
+
head.push(`<meta name="twitter:card" content="summary_large_image">`);
|
|
196
|
+
head.push(`<meta name="twitter:image" content="${img}">`);
|
|
197
|
+
} else {
|
|
198
|
+
head.push(`<meta name="twitter:card" content="summary">`);
|
|
199
|
+
}
|
|
200
|
+
head.push(`<meta name="twitter:title" content="${titleEsc}">`);
|
|
201
|
+
if (opts.description) {
|
|
202
|
+
head.push(`<meta name="twitter:description" content="${escapeHtml(opts.description)}">`);
|
|
203
|
+
}
|
|
204
|
+
if (opts.machineSources) {
|
|
205
|
+
for (const s of opts.machineSources) {
|
|
206
|
+
head.push(
|
|
207
|
+
`<link rel="alternate" type="${escapeHtml(s.type)}" href="${escapeHtml(s.href)}">`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (opts.feedPath) {
|
|
212
|
+
head.push(
|
|
213
|
+
`<link rel="alternate" type="application/atom+xml" title="${escapeHtml(opts.siteTitle)}" href="${escapeHtml(opts.rootPrefix + opts.feedPath)}">`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (opts.extraHead) head.push(...opts.extraHead);
|
|
217
|
+
const labels = siteLabels(lang);
|
|
218
|
+
const home = `${opts.rootPrefix}index.html`;
|
|
219
|
+
const navParts: string[] = [];
|
|
220
|
+
if (opts.showArchiveNav) {
|
|
221
|
+
navParts.push(
|
|
222
|
+
`<a href="${escapeHtml(`${opts.rootPrefix}archive/index.html`)}">${escapeHtml(labels.yearArchive)}</a>`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (opts.searchPath) {
|
|
226
|
+
navParts.push(
|
|
227
|
+
`<a href="${escapeHtml(opts.rootPrefix + opts.searchPath)}">${escapeHtml(labels.search)}</a>`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const nav =
|
|
231
|
+
navParts.length > 0
|
|
232
|
+
? `<nav class="site-nav" aria-label="サイト">${navParts.join("")}</nav>`
|
|
233
|
+
: "";
|
|
234
|
+
const headerEnd =
|
|
235
|
+
opts.headerSearchHtml || nav
|
|
236
|
+
? `<div class="site-header-end">\n${opts.headerSearchHtml ?? ""}${nav}\n</div>\n`
|
|
237
|
+
: "";
|
|
238
|
+
const skipLink =
|
|
239
|
+
`<a href="#main" class="skip-link">${escapeHtml(labels.skipToContent)}</a>\n`;
|
|
240
|
+
const bodyClass = opts.docsLayout ? ' class="docs-site"' : "";
|
|
241
|
+
let mainBlock = `<main id="main">\n${opts.bodyHtml}\n</main>\n`;
|
|
242
|
+
if (opts.docsLayout && opts.docsSidebarHtml) {
|
|
243
|
+
mainBlock =
|
|
244
|
+
`<div class="docs-layout">\n` +
|
|
245
|
+
`<aside class="docs-sidebar">\n` +
|
|
246
|
+
`<details class="docs-nav-toggle">\n` +
|
|
247
|
+
`<summary>${escapeHtml(labels.docsMenu)}</summary>\n` +
|
|
248
|
+
`${opts.docsSidebarHtml}` +
|
|
249
|
+
`</details>\n` +
|
|
250
|
+
`<div class="docs-sidebar-desktop">\n${opts.docsSidebarHtml}</div>\n` +
|
|
251
|
+
`</aside>\n` +
|
|
252
|
+
`<main id="main" class="docs-main">\n${opts.bodyHtml}\n</main>\n` +
|
|
253
|
+
`</div>\n`;
|
|
254
|
+
}
|
|
255
|
+
return (
|
|
256
|
+
"<!doctype html>\n" +
|
|
257
|
+
`<html lang="${escapeHtml(lang)}">\n` +
|
|
258
|
+
`<head>\n${head.join("\n")}\n</head>\n` +
|
|
259
|
+
`<body${bodyClass}>\n` +
|
|
260
|
+
`${skipLink}` +
|
|
261
|
+
`<header class="site-header">\n` +
|
|
262
|
+
`<a class="site-title" href="${escapeHtml(home)}">${escapeHtml(opts.siteTitle)}</a>\n` +
|
|
263
|
+
`${headerEnd}` +
|
|
264
|
+
`</header>\n` +
|
|
265
|
+
`${mainBlock}` +
|
|
266
|
+
`<footer class="site-footer"><p><a href="${escapeHtml(home)}">${escapeHtml(opts.siteTitle)}</a></p></footer>\n` +
|
|
267
|
+
"</body>\n</html>\n"
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function formatDate(iso?: string): string | undefined {
|
|
272
|
+
return iso?.slice(0, 10);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function articleMetaHtml(opts: {
|
|
276
|
+
timestamp?: string;
|
|
277
|
+
updated?: string;
|
|
278
|
+
author?: string;
|
|
279
|
+
}): string {
|
|
280
|
+
const parts: string[] = [];
|
|
281
|
+
const date = formatDate(opts.timestamp);
|
|
282
|
+
if (date) {
|
|
283
|
+
parts.push(`<time datetime="${escapeHtml(date)}">${escapeHtml(date)}</time>`);
|
|
284
|
+
}
|
|
285
|
+
const updated = formatDate(opts.updated);
|
|
286
|
+
if (updated && updated !== date) {
|
|
287
|
+
parts.push(
|
|
288
|
+
`<span class="article-updated">更新 <time datetime="${escapeHtml(updated)}">${escapeHtml(updated)}</time></span>`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (opts.author) {
|
|
292
|
+
parts.push(`<span class="article-author">${escapeHtml(opts.author)}</span>`);
|
|
293
|
+
}
|
|
294
|
+
if (parts.length === 0) return "";
|
|
295
|
+
return `<p class="article-meta">${parts.join(" · ")}</p>`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function slugifyTag(tag: string): string {
|
|
299
|
+
return tag
|
|
300
|
+
.trim()
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.replace(/\s+/g, "-")
|
|
303
|
+
.replace(/[^a-z0-9\u3040-\u30ff\u3400-\u9fff-]/gi, "");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function tagsHtml(tags: readonly string[] | undefined): string {
|
|
307
|
+
if (!tags || tags.length === 0) return "";
|
|
308
|
+
const items = tags
|
|
309
|
+
.map((t) => {
|
|
310
|
+
const slug = slugifyTag(t);
|
|
311
|
+
return slug
|
|
312
|
+
? `<a class="article-tag" href="tag/${escapeHtml(slug)}.html">${escapeHtml(t)}</a>`
|
|
313
|
+
: "";
|
|
314
|
+
})
|
|
315
|
+
.filter(Boolean)
|
|
316
|
+
.join(" ");
|
|
317
|
+
if (!items) return "";
|
|
318
|
+
return `<p class="article-tags">${items}</p>`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function articleNavHtml(nav?: ArticleNav): string {
|
|
322
|
+
if (!nav?.prev && !nav?.next) return "";
|
|
323
|
+
const parts: string[] = [];
|
|
324
|
+
if (nav.prev) {
|
|
325
|
+
parts.push(
|
|
326
|
+
`<span class="article-nav-prev"><a href="${escapeHtml(nav.prev.href)}">← ${escapeHtml(nav.prev.title)}</a></span>`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (nav.next) {
|
|
330
|
+
parts.push(
|
|
331
|
+
`<span class="article-nav-next"><a href="${escapeHtml(nav.next.href)}">${escapeHtml(nav.next.title)} →</a></span>`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return `<nav class="article-nav" aria-label="記事">${parts.join(" · ")}</nav>`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function buildBlogPostingJsonLd(opts: {
|
|
338
|
+
title: string;
|
|
339
|
+
description?: string;
|
|
340
|
+
url: string;
|
|
341
|
+
datePublished?: string;
|
|
342
|
+
dateModified?: string;
|
|
343
|
+
author?: string;
|
|
344
|
+
siteTitle: string;
|
|
345
|
+
lang: string;
|
|
346
|
+
aiDisclosure?: AiDisclosure;
|
|
347
|
+
associatedMedia?: readonly AssociatedMediaItem[];
|
|
348
|
+
}): string {
|
|
349
|
+
const data: Record<string, unknown> = {
|
|
350
|
+
"@context": "https://schema.org",
|
|
351
|
+
"@type": "BlogPosting",
|
|
352
|
+
headline: opts.title,
|
|
353
|
+
url: opts.url,
|
|
354
|
+
inLanguage: opts.lang,
|
|
355
|
+
isPartOf: { "@type": "Blog", name: opts.siteTitle },
|
|
356
|
+
};
|
|
357
|
+
if (opts.description) data.description = opts.description;
|
|
358
|
+
if (opts.datePublished) data.datePublished = opts.datePublished;
|
|
359
|
+
if (opts.dateModified) data.dateModified = opts.dateModified;
|
|
360
|
+
if (opts.author) {
|
|
361
|
+
data.author = { "@type": "Person", name: opts.author };
|
|
362
|
+
}
|
|
363
|
+
if (opts.aiDisclosure) {
|
|
364
|
+
Object.assign(data, aiDisclosureJsonLdFields(opts.aiDisclosure));
|
|
365
|
+
}
|
|
366
|
+
const mediaFields = associatedMediaJsonLdFields(opts.associatedMedia ?? []);
|
|
367
|
+
if (mediaFields) Object.assign(data, mediaFields);
|
|
368
|
+
return `<script type="application/ld+json">${JSON.stringify(data)}</script>`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const SERIF_FONT_STYLES = new Set(["GJM", "serif", "mincho"]);
|
|
372
|
+
|
|
373
|
+
/** frontmatter の font スタイルが明朝指定なら class を返す。 */
|
|
374
|
+
export function articleFontClass(concept: OkfConcept): string {
|
|
375
|
+
const font = concept.frontmatter.font;
|
|
376
|
+
if (typeof font !== "string") return "";
|
|
377
|
+
return SERIF_FONT_STYLES.has(font) ? " font-serif" : "";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface ArticleBodyResult {
|
|
381
|
+
readonly bodyHtml: string;
|
|
382
|
+
readonly diagrams: DiagramRenderMeta;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function renderArticleBodyWithMeta(
|
|
386
|
+
concept: OkfConcept,
|
|
387
|
+
nav?: ArticleNav,
|
|
388
|
+
opts?: {
|
|
389
|
+
readonly badgeHtml?: string;
|
|
390
|
+
readonly diagrams?: DiagramsConfig;
|
|
391
|
+
} & BodySectionOptions,
|
|
392
|
+
): ArticleBodyResult {
|
|
393
|
+
const updated =
|
|
394
|
+
typeof concept.frontmatter.updated === "string"
|
|
395
|
+
? concept.frontmatter.updated
|
|
396
|
+
: typeof concept.frontmatter.date === "string"
|
|
397
|
+
? concept.frontmatter.date
|
|
398
|
+
: undefined;
|
|
399
|
+
const author =
|
|
400
|
+
typeof concept.frontmatter.author === "string" ? concept.frontmatter.author : undefined;
|
|
401
|
+
const badge = opts?.badgeHtml ?? "";
|
|
402
|
+
const section = renderBodySection(stripDuplicateTitleHeading(concept.body, concept.title), opts);
|
|
403
|
+
const header = [
|
|
404
|
+
"<header>",
|
|
405
|
+
`<h1>${escapeHtml(concept.title)}</h1>`,
|
|
406
|
+
articleMetaHtml({ timestamp: concept.timestamp, updated, author }),
|
|
407
|
+
tagsHtml(concept.tags),
|
|
408
|
+
badge,
|
|
409
|
+
"</header>",
|
|
410
|
+
].join("\n");
|
|
411
|
+
const bodyHtml =
|
|
412
|
+
`<article class="article-page${articleFontClass(concept)}">\n` +
|
|
413
|
+
`${header}\n` +
|
|
414
|
+
`<div class="article-body">\n${section.html}\n</div>\n` +
|
|
415
|
+
`${articleNavHtml(nav)}\n` +
|
|
416
|
+
`</article>`;
|
|
417
|
+
return { bodyHtml, diagrams: section.diagrams };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function renderArticleBodyWithMetaForConfig(
|
|
421
|
+
concept: OkfConcept,
|
|
422
|
+
nav?: ArticleNav,
|
|
423
|
+
opts?: {
|
|
424
|
+
readonly badgeHtml?: string;
|
|
425
|
+
readonly diagrams?: DiagramsConfig;
|
|
426
|
+
} & BodySectionOptions,
|
|
427
|
+
): Promise<ArticleBodyResult> {
|
|
428
|
+
const updated =
|
|
429
|
+
typeof concept.frontmatter.updated === "string"
|
|
430
|
+
? concept.frontmatter.updated
|
|
431
|
+
: typeof concept.frontmatter.date === "string"
|
|
432
|
+
? concept.frontmatter.date
|
|
433
|
+
: undefined;
|
|
434
|
+
const author =
|
|
435
|
+
typeof concept.frontmatter.author === "string" ? concept.frontmatter.author : undefined;
|
|
436
|
+
const badge = opts?.badgeHtml ?? "";
|
|
437
|
+
const section = await renderBodySectionForConfig(
|
|
438
|
+
stripDuplicateTitleHeading(concept.body, concept.title),
|
|
439
|
+
opts,
|
|
440
|
+
);
|
|
441
|
+
const header = [
|
|
442
|
+
"<header>",
|
|
443
|
+
`<h1>${escapeHtml(concept.title)}</h1>`,
|
|
444
|
+
articleMetaHtml({ timestamp: concept.timestamp, updated, author }),
|
|
445
|
+
tagsHtml(concept.tags),
|
|
446
|
+
badge,
|
|
447
|
+
"</header>",
|
|
448
|
+
].join("\n");
|
|
449
|
+
const bodyHtml =
|
|
450
|
+
`<article class="article-page${articleFontClass(concept)}">\n` +
|
|
451
|
+
`${header}\n` +
|
|
452
|
+
`<div class="article-body">\n${section.html}\n</div>\n` +
|
|
453
|
+
`${articleNavHtml(nav)}\n` +
|
|
454
|
+
`</article>`;
|
|
455
|
+
return { bodyHtml, diagrams: section.diagrams };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function renderArticleBody(
|
|
459
|
+
concept: OkfConcept,
|
|
460
|
+
nav?: ArticleNav,
|
|
461
|
+
opts?: { readonly badgeHtml?: string; readonly diagrams?: DiagramsConfig },
|
|
462
|
+
): string {
|
|
463
|
+
return renderArticleBodyWithMeta(concept, nav, opts).bodyHtml;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function renderBlogIndexBody(opts: BlogIndexOptions): string {
|
|
467
|
+
const labels = opts.labels ?? siteLabels(opts.lang ?? "ja");
|
|
468
|
+
const links = [
|
|
469
|
+
opts.profileUrl
|
|
470
|
+
? `<a href="${escapeHtml(opts.profileUrl)}" class="blog-profile-link">${escapeHtml(labels.profile)}</a>`
|
|
471
|
+
: "",
|
|
472
|
+
opts.githubUrl
|
|
473
|
+
? `<a href="${escapeHtml(opts.githubUrl)}" class="blog-profile-link">${escapeHtml(labels.github)}</a>`
|
|
474
|
+
: "",
|
|
475
|
+
]
|
|
476
|
+
.filter(Boolean)
|
|
477
|
+
.join(" ");
|
|
478
|
+
const profile = links ? links : "";
|
|
479
|
+
const intro = opts.introHtml
|
|
480
|
+
? `<div class="blog-intro">${opts.introHtml}</div>`
|
|
481
|
+
: "";
|
|
482
|
+
|
|
483
|
+
const listPrefix = opts.listRootPrefix ?? "./";
|
|
484
|
+
const compactBadge = (d?: AiDisclosure) =>
|
|
485
|
+
opts.showOnLists && d?.showBadge
|
|
486
|
+
? buildCompactAiBadgeHtml(d, { rootPrefix: listPrefix })
|
|
487
|
+
: "";
|
|
488
|
+
|
|
489
|
+
let featured = "";
|
|
490
|
+
if (opts.latestArticle) {
|
|
491
|
+
const la = opts.latestArticle;
|
|
492
|
+
const badge = compactBadge(la.aiDisclosure);
|
|
493
|
+
featured =
|
|
494
|
+
`<article class="blog-featured">\n` +
|
|
495
|
+
`<header>\n` +
|
|
496
|
+
`<h2><a href="${escapeHtml(la.href)}">${escapeHtml(la.title)}</a></h2>\n` +
|
|
497
|
+
articleMetaHtml({
|
|
498
|
+
timestamp: la.timestamp,
|
|
499
|
+
updated: la.updated,
|
|
500
|
+
author: la.author,
|
|
501
|
+
}) +
|
|
502
|
+
badge +
|
|
503
|
+
`</header>\n` +
|
|
504
|
+
`<div class="article-body">\n${la.bodyHtml}\n</div>\n` +
|
|
505
|
+
`<p class="blog-permalink"><a href="${escapeHtml(la.href)}">${escapeHtml(labels.readMore)}</a></p>\n` +
|
|
506
|
+
`</article>\n`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const archive = opts.articles.slice(0, opts.archiveLimit ?? opts.articles.length);
|
|
510
|
+
const items = archive
|
|
511
|
+
.map((a) => {
|
|
512
|
+
const date = formatDate(a.timestamp);
|
|
513
|
+
const meta: string[] = [];
|
|
514
|
+
if (date) meta.push(`<time datetime="${escapeHtml(date)}">${escapeHtml(date)}</time>`);
|
|
515
|
+
const updated = formatDate(a.updated);
|
|
516
|
+
if (updated && updated !== date) {
|
|
517
|
+
meta.push(
|
|
518
|
+
`<span class="article-updated">${escapeHtml(labels.updated)} ${escapeHtml(updated)}</span>`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (a.author) meta.push(`<span>${escapeHtml(a.author)}</span>`);
|
|
522
|
+
if (opts.showListDescriptions && a.description) {
|
|
523
|
+
meta.push(`<span class="blog-list-desc">${escapeHtml(a.description)}</span>`);
|
|
524
|
+
}
|
|
525
|
+
const badge = compactBadge(a.aiDisclosure);
|
|
526
|
+
const metaHtml = meta.length > 0 ? `<div class="blog-list-meta">${meta.join(" · ")}</div>` : "";
|
|
527
|
+
return (
|
|
528
|
+
`<li class="blog-list-item">\n` +
|
|
529
|
+
`<a href="${escapeHtml(a.href)}" class="blog-list-title">${escapeHtml(a.title)}</a>\n` +
|
|
530
|
+
`${badge}` +
|
|
531
|
+
`${metaHtml}\n` +
|
|
532
|
+
`</li>`
|
|
533
|
+
);
|
|
534
|
+
})
|
|
535
|
+
.join("\n");
|
|
536
|
+
|
|
537
|
+
const archiveNavLinks: string[] = [];
|
|
538
|
+
if (opts.moreArticlesHref) {
|
|
539
|
+
archiveNavLinks.push(
|
|
540
|
+
`<a href="${escapeHtml(opts.moreArticlesHref)}" class="blog-archive-more">${escapeHtml(labels.moreArticles)}</a>`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (opts.yearArchiveHref) {
|
|
544
|
+
archiveNavLinks.push(
|
|
545
|
+
`<a href="${escapeHtml(opts.yearArchiveHref)}" class="blog-archive-by-year">${escapeHtml(labels.yearArchive)}</a>`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
const archiveNav =
|
|
549
|
+
archiveNavLinks.length > 0
|
|
550
|
+
? `<nav class="blog-archive-nav" aria-label="${escapeHtml(labels.pastArticles)}">${archiveNavLinks.join("")}</nav>\n`
|
|
551
|
+
: "";
|
|
552
|
+
|
|
553
|
+
const more =
|
|
554
|
+
items.length > 0
|
|
555
|
+
? `<section class="blog-archive">\n<h2>${escapeHtml(labels.pastArticles)}</h2>\n<ul class="blog-list">\n${items}\n</ul>\n${archiveNav}</section>\n`
|
|
556
|
+
: "";
|
|
557
|
+
|
|
558
|
+
const showTitle = opts.showHeaderTitle !== false;
|
|
559
|
+
return (
|
|
560
|
+
`<div class="blog-index">\n` +
|
|
561
|
+
`<header class="blog-header">\n` +
|
|
562
|
+
(showTitle ? `<h1>${escapeHtml(opts.siteTitle)}</h1>\n` : "") +
|
|
563
|
+
(opts.description ? `<p class="blog-lead">${escapeHtml(opts.description)}</p>\n` : "") +
|
|
564
|
+
(profile ? `<div class="blog-profile">${profile}</div>\n` : "") +
|
|
565
|
+
`${intro}` +
|
|
566
|
+
`</header>\n` +
|
|
567
|
+
`${featured}` +
|
|
568
|
+
`${more}` +
|
|
569
|
+
`</div>\n`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function isSearchView(frontmatter: Record<string, unknown>): boolean {
|
|
574
|
+
return frontmatter.view === "search";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export type SearchMountMode = "fts" | "hybrid";
|
|
578
|
+
export type SearchMountVariant = "page" | "header";
|
|
579
|
+
|
|
580
|
+
export function buildSearchMount(
|
|
581
|
+
rootPrefix: string,
|
|
582
|
+
opts: {
|
|
583
|
+
readonly assetBaseUrl?: string;
|
|
584
|
+
readonly mode?: SearchMountMode;
|
|
585
|
+
readonly variant?: SearchMountVariant;
|
|
586
|
+
} = {},
|
|
587
|
+
): string {
|
|
588
|
+
const mode = opts.mode ?? "fts";
|
|
589
|
+
const variant = opts.variant ?? "page";
|
|
590
|
+
const facetOpts = [
|
|
591
|
+
["", "すべて"],
|
|
592
|
+
["article", "記事"],
|
|
593
|
+
["index", "トップ"],
|
|
594
|
+
]
|
|
595
|
+
.map(([v, l]) => `<option value="${escapeHtml(v!)}">${escapeHtml(l!)}</option>`)
|
|
596
|
+
.join("");
|
|
597
|
+
const indexUrl = `${rootPrefix}assets/search-index.json`;
|
|
598
|
+
const hybridAttrs =
|
|
599
|
+
mode === "hybrid"
|
|
600
|
+
? (() => {
|
|
601
|
+
const assetBaseUrl = opts.assetBaseUrl ?? "";
|
|
602
|
+
const modelBase =
|
|
603
|
+
assetBaseUrl.length > 0 ? `${assetBaseUrl}models/` : `${rootPrefix}models/`;
|
|
604
|
+
return (
|
|
605
|
+
` data-mode="hybrid"` +
|
|
606
|
+
` data-model-base="${escapeHtml(modelBase)}"` +
|
|
607
|
+
` data-lib-base="${escapeHtml(rootPrefix)}assets/search/lib/"`
|
|
608
|
+
);
|
|
609
|
+
})()
|
|
610
|
+
: ` data-mode="fts"`;
|
|
611
|
+
const searchClass = variant === "header" ? "search search--header" : "search";
|
|
612
|
+
const form =
|
|
613
|
+
variant === "header"
|
|
614
|
+
? `<form class="search-form" role="search">` +
|
|
615
|
+
`<input type="search" name="q" class="search-input" placeholder="検索" autocomplete="off" aria-label="検索">` +
|
|
616
|
+
`<button type="submit" class="search-submit" aria-label="検索">検索</button>` +
|
|
617
|
+
`</form>`
|
|
618
|
+
: `<form class="search-form" role="search">` +
|
|
619
|
+
`<input type="search" name="q" class="search-input" placeholder="キーワードで検索" autocomplete="off" aria-label="検索キーワード">` +
|
|
620
|
+
`<select name="type" class="search-facet" aria-label="種別で絞り込み">${facetOpts}</select>` +
|
|
621
|
+
`<button type="submit" class="search-submit">検索</button>` +
|
|
622
|
+
`</form>`;
|
|
623
|
+
const status =
|
|
624
|
+
variant === "header"
|
|
625
|
+
? `<p class="search-status search-status--sr" data-search-status aria-live="polite" aria-atomic="true"></p>`
|
|
626
|
+
: `<p class="search-status" data-search-status aria-live="polite" aria-atomic="true"></p>`;
|
|
627
|
+
return (
|
|
628
|
+
`<div class="${searchClass}" data-search data-index="${escapeHtml(indexUrl)}"${hybridAttrs}>` +
|
|
629
|
+
`${form}` +
|
|
630
|
+
`${status}` +
|
|
631
|
+
`<ol class="search-results" data-search-results role="list" aria-live="polite" aria-relevant="additions"></ol>` +
|
|
632
|
+
`</div>\n`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function buildSearchHead(rootPrefix: string, mode: SearchMountMode = "fts"): string[] {
|
|
637
|
+
if (mode === "fts") {
|
|
638
|
+
return [`<script type="module" src="${rootPrefix}assets/search.mjs"></script>`];
|
|
639
|
+
}
|
|
640
|
+
const libBase = `${rootPrefix || "./"}assets/search/lib/`;
|
|
641
|
+
return [
|
|
642
|
+
`<script type="importmap">${JSON.stringify({
|
|
643
|
+
imports: {
|
|
644
|
+
"onnxruntime-web/webgpu": `${libBase}ort.webgpu.bundle.min.mjs`,
|
|
645
|
+
"onnxruntime-common": `${libBase}ort.webgpu.bundle.min.mjs`,
|
|
646
|
+
},
|
|
647
|
+
})}</script>`,
|
|
648
|
+
`<script type="module" src="${rootPrefix}assets/search.mjs"></script>`,
|
|
649
|
+
];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** シンプルな index(examples 向け)。blog サイトは renderBlogIndexBody を使う。 */
|
|
653
|
+
export function renderIndexBody(
|
|
654
|
+
siteTitle: string,
|
|
655
|
+
articles: ReadonlyArray<{ title: string; href: string; timestamp?: string }>,
|
|
656
|
+
): string {
|
|
657
|
+
return renderBlogIndexBody({
|
|
658
|
+
siteTitle,
|
|
659
|
+
articles,
|
|
660
|
+
archiveLimit: articles.length,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** content ルートからの相対パスに対する dist ルートからの rootPrefix を計算する。 */
|
|
665
|
+
export function rootPrefixFromRel(relPath: string): string {
|
|
666
|
+
const depth = relPath.replace(/\\/g, "/").split("/").length - 1;
|
|
667
|
+
return depth > 0 ? "../".repeat(depth) : "./";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** dist ルート基準のパス同士から、from ページ向けの相対リンクを作る。 */
|
|
671
|
+
export function relLinkFrom(fromRel: string, toRel: string): string {
|
|
672
|
+
const from = fromRel.replace(/\\/g, "/");
|
|
673
|
+
const to = toRel.replace(/\\/g, "/");
|
|
674
|
+
const rel = relative(dirname(from), to).split("\\").join("/");
|
|
675
|
+
return rel.length > 0 ? rel : "./";
|
|
676
|
+
}
|