// 記事の全文ページ(/record/{slug}.md。2026-11-01) // AI が記事を丸ごと読めるように、1本の記事を飾りのない Markdown 1枚にする。 // - 長いコード・ログを外に分けた記事(tools/split_files.py)は、分ける前の全文(public/files/LOG{N}/LOG{N}_full.md・.html)を使う // - Studio から移した記事(本文が HTML)は Markdown に直す(turndown) // - 画像・図解・動画・このサイトの住所は、ページの外でも開けるよう https://pixelstomotors.com から始まる住所にする // 人が見るのは今の記事ページのまま。全文ページは worker/index.js が canonical(正しいページは記事ページ)を付けて返す import { existsSync, readFileSync } from 'node:fs'; import TurndownService from 'turndown'; import { SITE, SITE_NAME } from './seo'; import { ymd } from './format'; export const fullTextPath = (slug: string) => `/record/${slug}.md`; type Meta = { title: string; slug: string; day: string; series: string; seriesName: string; published: string; description: string; lang: 'ja' | 'en' }; const absUrl = (u: string) => (/^https?:|^mailto:|^#/.test(u) ? u : u.startsWith('/') ? SITE + u : u); // 記事の Markdown の印(【画像】など。src/lib/remark-markers.mjs)を、普通の Markdown に直す function markersToMarkdown(md: string, title: string): string { const media = (f: string) => `${SITE}/media/${encodeURIComponent(f)}`; return md.split('\n').map((line) => { const t = line.trim(); let m = t.match(/^【画像】\s*(\S+)(?:\s+(.+))?$/); if (m) return `![${(m[2] || title).trim()}](${media(m[1])})`; m = t.match(/^【動画】\s*(\S+\.(?:mp4|webm|mov))(?:\s+(.+))?$/); if (m) return `[動画: ${(m[2] || m[1]).trim()}](${media(m[1])})`; m = t.match(/^【図解】\s*(\S+\.html)$/); if (m) return `[図解: ${m[1]}](${SITE}/embeds/${encodeURIComponent(m[1])})`; m = t.match(/^【バナー】\s*(\S+)$/); if (m) return `[${m[1] === 'gpu-buy-or-rent' ? 'ロボットAIの学習用GPU、買うか借りるか' : m[1] === 'serving-robot-one-less-staff' ? '配膳ロボットの導入前シミュレーション' : m[1]}](${SITE}/${m[1]})`; // 分けたファイルの印は、全文には本来残らない。残っていたらファイルへのリンクにする m = t.match(/^【ファイル】\s*(LOG\d+\/\S+)\s+(\S+)\s+(\S+)$/); if (m) return `[ファイル: ${m[1]}(${m[2]}・${m[3]})](${SITE}/files/${m[1]})`; m = t.match(/^【ファイル一式】\s*(LOG\d+)$/); if (m) return `[ファイル一式: ${SITE}/files/${m[1]}/index.json](${SITE}/files/${m[1]}/index.json)`; return line; }).join('\n') // このサイトの中の住所(](/record/… )を、外でも開ける住所に .replace(/\]\((\/[^)\s]*)\)/g, (_, p) => `](${SITE}${p})`); } const stripFrontmatter = (md: string) => md.replace(/^---\n[\s\S]*?\n---\n+/, ''); let td: TurndownService | null = null; function htmlToMarkdown(html: string): string { if (!td) { td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-', emDelimiter: '*' }); td.remove(['script', 'style', 'noscript']); // 図解・YouTube の枠は、中身のページへのリンクにする td.addRule('iframe', { filter: 'iframe', replacement: (_c, node: any) => { const src = node.getAttribute('src') || ''; if (!src) return ''; const yt = /youtube(?:-nocookie)?\.com\/embed\/([\w-]+)/.exec(src); return yt ? `\n\n[動画(YouTube)](https://www.youtube.com/watch?v=${yt[1]})\n\n` : `\n\n[図解](${absUrl(src)})\n\n`; }, }); td.addRule('video', { filter: 'video', replacement: (_c, node: any) => { const src = (node.getAttribute('src') || node.querySelector?.('source')?.getAttribute('src') || '').replace(/#.*$/, ''); return src ? `\n\n[動画](${absUrl(src)})\n\n` : ''; }, }); // コードの言語(class="language-python")を残す td.addRule('pre', { filter: (node: any) => node.nodeName === 'PRE', replacement: (_c, node: any) => { const code = node.querySelector?.('code'); const lang = /language-([\w+-]+)/.exec((code || node).getAttribute?.('class') || '')?.[1] || ''; const text = (code || node).textContent || ''; const fence = text.includes('```') ? '````' : '```'; return `\n\n${fence}${lang}\n${text.replace(/\n$/, '')}\n${fence}\n\n`; }, }); } return td.turndown(html.replace(//g, '')) .replace(/\]\((\/[^)\s]*)\)/g, (_, p) => `](${SITE}${p})`) .replace(/\n{3,}/g, '\n\n'); } function header(m: Meta): string { const url = `${SITE}/record/${m.slug}`; const en = m.lang === 'en'; return [ `# ${m.title}`, '', en ? `- Record: ${m.day} (${m.seriesName})\n- Published: ${ymd(m.published)}\n- Page: ${url}\n- Site: ${SITE_NAME} (ADAPT DESIGN / Eiichi Shimazu) ${SITE}/` : `- 記録: ${m.day}(${m.seriesName})\n- 公開: ${ymd(m.published)}\n- 記事のページ: ${url}\n- サイト: ${SITE_NAME}(ADAPT DESIGN / 島津 栄一) ${SITE}/`, m.description ? (en ? `- Summary: ${m.description}` : `- 要約: ${m.description}`) : '', '', en ? '> This is the full text of the article for AI and text readers, including code and logs. Please cite the article page above.' : '> AI が読むための記事の全文です(コード・実行ログを含む)。引用するときは上の「記事のページ」の住所を使ってください。', '', '---', '', ].filter((l, i, a) => !(l === '' && a[i - 1] === '')).join('\n') + '\n'; } // 分けた記事の全文ファイル(public/files/LOG{N}/index.json の article_full) function splitFull(day: string): { body: string; type: 'md' | 'html' } | null { const idx = `public/files/${day}/index.json`; if (!existsSync(idx)) return null; try { const j = JSON.parse(readFileSync(idx, 'utf-8')); const type = j.article_full_type === 'text/html' ? 'html' : 'md'; const p = type === 'html' ? `public/files/${day}/${day}_full.html` : `public/files/${day}/${day}_full.md`; return existsSync(p) ? { body: readFileSync(p, 'utf-8'), type } : null; } catch { return null; } } export function markdownRecord(m: Meta, src: { kind: 'md'; body: string } | { kind: 'studio'; html: string }): string { const split = splitFull(m.day); let body: string; if (split?.type === 'md') body = markersToMarkdown(stripFrontmatter(split.body), m.title); else if (split?.type === 'html') body = htmlToMarkdown(split.body); else if (src.kind === 'md') body = markersToMarkdown(src.body, m.title); else body = htmlToMarkdown(src.html); return header(m) + body.trim() + '\n'; }