diff --git a/apps/web/package.json b/apps/web/package.json index 07982bc..f1fa689 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "export:presentation": "tsx scripts/export-presentation-pptx.ts" }, "dependencies": { "@app/ui": "workspace:*", @@ -51,6 +52,7 @@ "@vitest/coverage-v8": "^4.1.0", "eslint": "^9", "jsdom": "^28.1.0", + "pptxgenjs": "^4.0.1", "rehype-prism-plus": "^2.0.2", "remark-gfm": "^4.0.1", "tailwindcss": "^4.2.1", diff --git a/apps/web/scripts/export-presentation-pptx.ts b/apps/web/scripts/export-presentation-pptx.ts new file mode 100644 index 0000000..08ac9e1 --- /dev/null +++ b/apps/web/scripts/export-presentation-pptx.ts @@ -0,0 +1,292 @@ +import fs from "node:fs"; +import path from "node:path"; +import { getContentBySlug } from "@app/utils/content"; +import type { ContentFrontmatter } from "@app/utils"; +import PptxGenJS from "pptxgenjs"; +import { + buildPresentationExportManifest, + type PresentationExportManifest, + type PresentationExportScene, +} from "../src/shared/presentation/presentation-export"; + +const CONTENT_DIR = path.resolve(import.meta.dirname, "../content"); +const OUTPUT_DIR = path.resolve( + import.meta.dirname, + "../public/presentation-artifacts", +); + +interface CliOptions { + slug: string | null; + outDir: string; + pptx: boolean; + json: boolean; +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + slug: null, + outDir: OUTPUT_DIR, + pptx: true, + json: true, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--slug") { + options.slug = readOptionValue(argv, index, "--slug"); + index += 1; + } else if (arg === "--out-dir") { + options.outDir = path.resolve(readOptionValue(argv, index, "--out-dir")); + index += 1; + } else if (arg === "--json-only") { + options.pptx = false; + options.json = true; + } else if (arg === "--pptx-only") { + options.pptx = true; + options.json = false; + } + } + + return options; +} + +function readOptionValue( + argv: string[], + index: number, + option: string, +): string { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${option} 옵션에는 값이 필요합니다.`); + } + + return value; +} + +function titleOf(frontmatter: ContentFrontmatter, slug: string): string { + return frontmatter.title || slug.split("/").at(-1) || slug; +} + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeJson(filePath: string, value: unknown) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync( + `${filePath}.tmp`, + `${JSON.stringify(value, null, 2)}\n`, + "utf-8", + ); + fs.renameSync(`${filePath}.tmp`, filePath); +} + +function slugOutputDir(baseOutDir: string, slug: string): string { + const segments = slug.split("/"); + if ( + segments.some( + (segment) => + !segment || + segment === "." || + segment === ".." || + segment.includes(path.sep) || + (path.win32.sep !== path.sep && segment.includes(path.win32.sep)), + ) + ) { + throw new Error(`잘못된 content slug입니다: ${slug}`); + } + + const resolvedBase = path.resolve(baseOutDir); + const resolvedOutputDir = path.resolve( + resolvedBase, + ...segments.map(encodeURIComponent), + ); + + if ( + resolvedOutputDir !== resolvedBase && + !resolvedOutputDir.startsWith(`${resolvedBase}${path.sep}`) + ) { + throw new Error(`출력 경로가 out-dir 범위를 벗어났습니다: ${slug}`); + } + + return resolvedOutputDir; +} + +function createManifest(slug: string): PresentationExportManifest { + const content = getContentBySlug(CONTENT_DIR, slug.split("/")); + if (!content) { + throw new Error(`content slug를 찾을 수 없습니다: ${slug}`); + } + + return buildPresentationExportManifest({ + slug, + title: titleOf(content.frontmatter, slug), + source: content.source, + }); +} + +function sanitizePptxText(value: string): string { + return value + .replace(/[\t\r]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function addSceneSlide( + pptx: PptxGenJS, + scene: PresentationExportScene, + totalSlides: number, +) { + const slide = pptx.addSlide(); + const isTitle = scene.kind === "title" || scene.order === 1; + const bgColor = isTitle ? "111827" : "FFFBF2"; + const fgColor = isTitle ? "FFFFFF" : "111827"; + const accentColor = isTitle ? "F4D35E" : "6B4EFF"; + + slide.background = { color: bgColor }; + slide.addText(scene.title, { + x: 0.55, + y: isTitle ? 1.25 : 0.45, + w: 8.9, + h: isTitle ? 1.25 : 0.72, + fontFace: "Apple SD Gothic Neo", + fontSize: isTitle ? 30 : 22, + bold: true, + color: fgColor, + margin: 0, + fit: "shrink", + }); + + if (scene.summary) { + slide.addText(scene.summary, { + x: 0.58, + y: isTitle ? 2.58 : 1.25, + w: 8.4, + h: isTitle ? 1.05 : 0.82, + fontFace: "Apple SD Gothic Neo", + fontSize: isTitle ? 15 : 12.5, + color: isTitle ? "E5E7EB" : "374151", + margin: 0, + breakLine: false, + fit: "shrink", + }); + } + + const bulletY = isTitle ? 3.78 : 2.25; + const bulletH = isTitle ? 1.45 : 2.55; + const bulletText = scene.bullets.map((bullet) => `• ${bullet}`).join("\n"); + if (bulletText) { + slide.addShape(pptx.ShapeType.roundRect, { + x: 0.55, + y: bulletY - 0.18, + w: 8.3, + h: bulletH, + rectRadius: 0.08, + fill: { color: isTitle ? "1F2937" : "FFFFFF", transparency: 6 }, + line: { color: isTitle ? "374151" : "E5E7EB", transparency: 30 }, + }); + slide.addText(bulletText, { + x: 0.85, + y: bulletY, + w: 7.75, + h: bulletH - 0.25, + fontFace: "Apple SD Gothic Neo", + fontSize: 12.5, + color: fgColor, + breakLine: false, + fit: "shrink", + margin: 0.05, + valign: "middle", + }); + } + + slide.addShape(pptx.ShapeType.rect, { + x: 0.55, + y: 5.02, + w: 0.42, + h: 0.05, + fill: { color: accentColor }, + line: { color: accentColor }, + }); + slide.addText(`${scene.order}/${totalSlides} · ${scene.pptx.layoutHint}`, { + x: 1.1, + y: 4.88, + w: 3.3, + h: 0.3, + fontFace: "Apple SD Gothic Neo", + fontSize: 8.5, + color: isTitle ? "D1D5DB" : "6B7280", + margin: 0, + }); + + if (scene.tts) { + slide.addText(`TTS ${scene.tts.kind}: ${scene.tts.transcriptPath}`, { + x: 5.15, + y: 4.86, + w: 3.75, + h: 0.34, + fontFace: "Menlo", + fontSize: 7.2, + color: isTitle ? "D1D5DB" : "6B7280", + margin: 0, + fit: "shrink", + align: "right", + }); + } + + slide.addNotes(sanitizePptxText(scene.pptx.notes)); +} + +async function writePptx( + manifest: PresentationExportManifest, + filePath: string, +) { + const PptxCtor = ((PptxGenJS as unknown as { default?: typeof PptxGenJS }) + .default ?? PptxGenJS) as typeof PptxGenJS; + const pptx = new PptxCtor(); + pptx.layout = "LAYOUT_WIDE"; + pptx.author = "SEOJing / Hermes"; + pptx.subject = "SEOJing presentation export spike"; + pptx.title = manifest.title; + pptx.company = "SEOJing"; + pptx.theme = { + headFontFace: "Apple SD Gothic Neo", + bodyFontFace: "Apple SD Gothic Neo", + }; + + for (const scene of manifest.scenes) { + addSceneSlide(pptx, scene, manifest.scenes.length); + } + + ensureDir(path.dirname(filePath)); + await pptx.writeFile({ fileName: filePath }); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (!options.slug) { + console.error( + "사용법: pnpm --filter @app/web run export:presentation -- --slug [--out-dir ] [--json-only|--pptx-only]", + ); + process.exit(1); + } + + const manifest = createManifest(options.slug); + const outDir = slugOutputDir(options.outDir, options.slug); + const manifestPath = path.join(outDir, "manifest.json"); + const pptxPath = path.join(outDir, manifest.pptx.fileName); + + if (options.json) writeJson(manifestPath, manifest); + if (options.pptx) await writePptx(manifest, pptxPath); + + console.log( + `presentation export 완료: ${manifest.slug} · scenes ${manifest.scenes.length}`, + ); + if (options.json) console.log(`manifest: ${manifestPath}`); + if (options.pptx) console.log(`pptx: ${pptxPath}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/apps/web/src/shared/lib/code-fence.test.ts b/apps/web/src/shared/lib/code-fence.test.ts new file mode 100644 index 0000000..cf5e1c6 --- /dev/null +++ b/apps/web/src/shared/lib/code-fence.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { nextCodeFenceState } from "./code-fence"; + +describe("nextCodeFenceState", () => { + it("keeps null state for ordinary lines", () => { + expect(nextCodeFenceState(null, "plain text")).toBeNull(); + }); + + it("opens and closes matching backtick fences", () => { + const opened = nextCodeFenceState(null, "```ts"); + expect(opened).toEqual({ char: "`", length: 3 }); + expect(nextCodeFenceState(opened, "```")).toBeNull(); + }); + + it("keeps a fence open for nested shorter or mismatched markers", () => { + const opened = nextCodeFenceState(null, "~~~~md"); + expect(nextCodeFenceState(opened, "```")).toEqual(opened); + expect(nextCodeFenceState(opened, "~~~")).toEqual(opened); + expect(nextCodeFenceState(opened, "~~~~")).toBeNull(); + }); + + it("allows leading whitespace before fence markers", () => { + const opened = nextCodeFenceState(null, " ```js"); + expect(opened).toEqual({ char: "`", length: 3 }); + expect(nextCodeFenceState(opened, " ```")).toBeNull(); + }); + + it("keeps a fence open when a closing marker has trailing content", () => { + const opened = nextCodeFenceState(null, "```"); + const stillOpen = nextCodeFenceState(opened, "``` some comment"); + + expect(stillOpen).toEqual(opened); + expect(nextCodeFenceState(stillOpen, "```")).toBeNull(); + }); + + it("closes a fence with a longer matching marker", () => { + const opened = nextCodeFenceState(null, "```ts"); + expect(nextCodeFenceState(opened, "`````")).toBeNull(); + }); +}); diff --git a/apps/web/src/shared/lib/code-fence.ts b/apps/web/src/shared/lib/code-fence.ts new file mode 100644 index 0000000..e547a11 --- /dev/null +++ b/apps/web/src/shared/lib/code-fence.ts @@ -0,0 +1,27 @@ +export interface CodeFenceState { + char: "`" | "~"; + length: number; +} + +export function nextCodeFenceState( + current: CodeFenceState | null, + line: string, +): CodeFenceState | null { + const fence = /^\s*(`{3,}|~{3,})/.exec(line); + if (!fence) return current; + + const marker = fence[1] ?? ""; + const char = marker[0] as "`" | "~"; + if (!current) return { char, length: marker.length }; + + const closingFence = /^\s*(`{3,}|~{3,})\s*$/.exec(line); + const closingMarker = closingFence?.[1] ?? ""; + if ( + closingMarker[0] === current.char && + closingMarker.length >= current.length + ) { + return null; + } + + return current; +} diff --git a/apps/web/src/shared/presentation/presentation-export.test.ts b/apps/web/src/shared/presentation/presentation-export.test.ts new file mode 100644 index 0000000..30869ac --- /dev/null +++ b/apps/web/src/shared/presentation/presentation-export.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + buildPresentationExportManifest, + extractPresentationScenes, +} from "./presentation-export"; + +describe("extractPresentationScenes", () => { + it("groups markdown content into H1/H2/H3 presentation scenes", () => { + const scenes = extractPresentationScenes( + `# 발표 제목\n\n도입 문장입니다.\n\n## 첫 장면\n\n- 핵심 하나\n- 핵심 둘\n\n### 코드 보기\n\n\`\`\`ts\nconst answer = 42;\n\`\`\``, + "Fallback title", + ); + + expect(scenes).toMatchObject([ + { title: "발표 제목", kind: "title", level: 1 }, + { title: "첫 장면", kind: "section", level: 2 }, + { title: "코드 보기", kind: "section", level: 3 }, + ]); + }); + + it("creates a title scene from preface-only content", () => { + const scenes = extractPresentationScenes( + "프론트매터 제거 후 제목이 없는 짧은 글입니다.", + "문서 제목", + ); + + expect(scenes).toHaveLength(1); + expect(scenes[0]).toMatchObject({ + title: "문서 제목", + kind: "title", + headingId: null, + }); + }); + + it("does not split scenes on headings inside fenced code blocks", () => { + const scenes = extractPresentationScenes( + `# 실제 제목\n\n\`\`\`md\n## 코드 안 제목\n본문\n\`\`\`\n\n## 다음 장면\n\n설명`, + "Fallback title", + ); + + expect(scenes.map((scene) => scene.title)).toEqual([ + "실제 제목", + "다음 장면", + ]); + }); + + it("keeps longer and tilde fenced headings inside the current scene", () => { + const scenes = extractPresentationScenes( + `## 실제 섹션\n\n~~~~md\n## 코드 안 제목\n\`\`\`\n### 여전히 코드\n~~~~\n\n내용`, + "Fallback title", + ); + + expect(scenes.map((scene) => scene.title)).toEqual(["실제 섹션"]); + }); + + it("deduplicates repeated H1 scene ids", () => { + const scenes = extractPresentationScenes( + `# 반복\n\n첫 번째\n\n# 반복\n\n두 번째`, + "Fallback title", + ); + + expect(scenes.map((scene) => scene.headingId)).toEqual(["반복", "반복-2"]); + }); + + it("does not reuse an H1 id for a later H2 section with the same title", () => { + const scenes = extractPresentationScenes( + `# 개요\n\n도입\n\n## 개요\n\n본문`, + "Fallback title", + ); + + expect(scenes[1]?.headingId).toBe("개요-2"); + }); +}); + +describe("buildPresentationExportManifest", () => { + it("builds a PPTX-oriented manifest with TTS bridge paths", () => { + const manifest = buildPresentationExportManifest({ + slug: "study/backend/day1", + title: "백엔드 스터디 Day 1", + generatedAt: "2026-06-08T00:00:00.000Z", + source: `# 백엔드 스터디 Day 1\n\n브라우저에서 서버로 요청이 이동합니다.\n\n## 요청 흐름\n\n- 브라우저가 URL을 요청합니다.\n- 서버가 응답을 만듭니다.\n\n## 코드로 보는 흐름\n\n\`\`\`ts\nfetch('/api/posts')\n\`\`\``, + }); + + expect(manifest).toMatchObject({ + version: 1, + slug: "study/backend/day1", + exportStrategy: "pptxgenjs-spike", + ttsCacheKey: "tts:v1:study/backend/day1", + pptx: { + supported: true, + slideCount: 3, + fileName: "study__backend__day1.pptx", + }, + }); + expect(manifest.scenes[0]).toMatchObject({ + kind: "title", + tts: { + kind: "summary-2m", + transcriptPath: "/tts-artifacts/study/backend/day1/summary-2m.txt", + }, + }); + expect(manifest.scenes[1]).toMatchObject({ + title: "요청 흐름", + bullets: ["브라우저가 URL을 요청합니다.", "서버가 응답을 만듭니다."], + pptx: { layoutHint: "bullets" }, + tts: { + kind: "section", + sectionId: "요청-흐름", + transcriptPath: "/tts-artifacts/study/backend/day1/section-001.txt", + }, + }); + expect(manifest.scenes[2]).toMatchObject({ + title: "코드로 보는 흐름", + pptx: { layoutHint: "code" }, + tts: null, + }); + }); + + it("uses code layout only for bounded HTML code markers", () => { + const manifest = buildPresentationExportManifest({ + slug: "study/backend/day1", + title: "레이아웃 테스트", + generatedAt: "2026-06-08T00:00:00.000Z", + source: `## 코드 블록\n\n
const answer = 42
\n\n## 코드가 아닌 문장\n\n태그 비슷한 텍스트\n\n## 코드가 아닌 속성\n\n
문장
`, + }); + + expect(manifest.scenes.map((scene) => scene.pptx.layoutHint)).toEqual([ + "code", + "section", + "section", + ]); + }); +}); diff --git a/apps/web/src/shared/presentation/presentation-export.ts b/apps/web/src/shared/presentation/presentation-export.ts new file mode 100644 index 0000000..a320b3c --- /dev/null +++ b/apps/web/src/shared/presentation/presentation-export.ts @@ -0,0 +1,337 @@ +import { + nextCodeFenceState, + type CodeFenceState, +} from "@/shared/lib/code-fence"; +import { + buildTtsArticleManifest, + normalizeText, + stripMdx, + type TtsArticleManifest, + type TtsArtifact, +} from "@/shared/tts/tts-artifacts"; + +export type PresentationExportVersion = 1; +export type PresentationSceneKind = "title" | "section" | "content"; +export type PresentationPptxLayoutHint = + | "title" + | "section" + | "code" + | "image" + | "bullets"; + +export interface PresentationTtsBridge { + artifactId: string; + kind: TtsArtifact["kind"]; + sectionId: string | null; + transcriptPath: string; + audioPath: string; + status: TtsArtifact["status"]; +} + +export interface PresentationExportScene { + id: string; + order: number; + kind: PresentationSceneKind; + title: string; + level: number; + sourceHeadingId: string | null; + summary: string; + bullets: string[]; + speakerScript: string; + tts: PresentationTtsBridge | null; + pptx: { + layoutHint: PresentationPptxLayoutHint; + notes: string; + }; +} + +export interface PresentationExportManifest { + version: PresentationExportVersion; + slug: string; + canonicalUrl: string; + title: string; + generatedAt: string; + exportStrategy: "pptxgenjs-spike"; + ttsCacheKey: string; + pptx: { + fileName: string; + slideCount: number; + supported: true; + limitations: string[]; + }; + scenes: PresentationExportScene[]; +} + +export interface BuildPresentationExportManifestInput { + slug: string; + title: string; + source: string; + generatedAt?: string; + ttsManifest?: TtsArticleManifest; +} + +interface RawScene { + kind: PresentationSceneKind; + title: string; + level: number; + headingId: string | null; + body: string; + raw: string; +} + +const MAX_SUMMARY_WORDS = 34; +const MAX_BULLETS = 4; +const MAX_BULLET_WORDS = 18; +const MAX_SCRIPT_WORDS = 95; + +export function buildPresentationExportManifest({ + slug, + title, + source, + generatedAt = new Date().toISOString(), + ttsManifest = buildTtsArticleManifest({ + slug, + title, + source, + generatedAt, + }), +}: BuildPresentationExportManifestInput): PresentationExportManifest { + const rawScenes = extractPresentationScenes(source, title); + const scenes = rawScenes.map((scene, index) => { + const tts = findTtsBridgeForScene(scene, ttsManifest); + const cleanBody = normalizeText(stripMdx(scene.body)); + const speakerScript = buildSpeakerScript(scene.title, cleanBody); + const bullets = extractSceneBullets(scene.raw, cleanBody); + const layoutHint = inferLayoutHint(scene.raw, scene.kind); + + return { + id: `${String(index + 1).padStart(2, "0")}-${slugify(scene.title) || "scene"}`, + order: index + 1, + kind: scene.kind, + title: scene.title, + level: scene.level, + sourceHeadingId: scene.headingId, + summary: firstWords(cleanBody || scene.title, MAX_SUMMARY_WORDS), + bullets, + speakerScript, + tts, + pptx: { + layoutHint, + notes: speakerScript, + }, + } satisfies PresentationExportScene; + }); + + return { + version: 1, + slug, + canonicalUrl: ttsManifest.canonicalUrl, + title, + generatedAt, + exportStrategy: "pptxgenjs-spike", + ttsCacheKey: ttsManifest.cacheKey, + pptx: { + fileName: `${slug.split("/").map(slugify).filter(Boolean).join("__") || "presentation"}.pptx`, + slideCount: scenes.length, + supported: true, + limitations: [ + "브라우저 프레젠테이션의 DOM/애니메이션을 그대로 캡처하지 않고 H1/H2/H3 scene 기반 정적 deck으로 변환한다.", + "코드·이미지는 현재 layoutHint와 텍스트 요약까지만 전달하며, 원본 시각 요소의 픽셀 완전성은 별도 렌더러가 필요하다.", + "MP3는 PPTX에 임베드하지 않고 speaker notes와 TTS transcript/audio path bridge로 연결한다.", + ], + }, + scenes, + }; +} + +export function extractPresentationScenes( + source: string, + title: string, +): RawScene[] { + const lines = source.split(/\r?\n/); + const scenes: RawScene[] = []; + let current: RawScene | null = null; + let preface = ""; + const seenIds = new Map(); + let codeFence: CodeFenceState | null = null; + + const pushCurrent = () => { + if (!current) return; + const body = normalizeText(stripMdx(current.body)); + if (body || current.title) scenes.push(current); + current = null; + }; + + for (const line of lines) { + codeFence = nextCodeFenceState(codeFence, line); + + const heading = codeFence + ? null + : /^(#{1,3})\s+(.+?)\s*$/.exec(line.trim()); + if (heading) { + pushCurrent(); + const headingTitle = stripInlineMdx(heading[2] ?? "섹션"); + const headingLevel = heading[1]?.length ?? 1; + const sectionCount = scenes.filter( + (candidate) => candidate.level > 1, + ).length; + const baseId = slugify(headingTitle) || `section-${sectionCount + 1}`; + const duplicateCount = seenIds.get(baseId) ?? 0; + seenIds.set(baseId, duplicateCount + 1); + current = { + kind: headingLevel === 1 ? "title" : "section", + title: headingTitle, + level: headingLevel, + headingId: + duplicateCount === 0 ? baseId : `${baseId}-${duplicateCount + 1}`, + body: "", + raw: `${line}\n`, + }; + continue; + } + + if (current) { + current.body += `${line}\n`; + current.raw += `${line}\n`; + } else { + preface += `${line}\n`; + } + } + + pushCurrent(); + + const cleanPreface = normalizeText(stripMdx(preface)); + if (cleanPreface) { + scenes.unshift({ + kind: "title", + title, + level: 1, + headingId: null, + body: preface, + raw: preface, + }); + } + + if (scenes.length === 0) { + scenes.push({ + kind: "title", + title, + level: 1, + headingId: null, + body: source, + raw: source, + }); + } + + return scenes; +} + +function findTtsBridgeForScene( + scene: RawScene, + ttsManifest: TtsArticleManifest, +): PresentationTtsBridge | null { + let artifact: TtsArtifact | undefined; + + if (scene.kind === "section" && scene.headingId) { + artifact = ttsManifest.artifacts.find( + (candidate) => + candidate.kind === "section" && candidate.sectionId === scene.headingId, + ); + } + + if (!artifact && scene.kind === "title") { + artifact = ttsManifest.artifacts.find( + (candidate) => candidate.kind === "summary-2m", + ); + } + + if (!artifact) return null; + + return { + artifactId: artifact.id, + kind: artifact.kind, + sectionId: artifact.sectionId, + transcriptPath: artifact.transcriptPath, + audioPath: artifact.audioPath, + status: artifact.status, + }; +} + +function buildSpeakerScript(title: string, cleanBody: string): string { + const body = firstWords(cleanBody, MAX_SCRIPT_WORDS); + return body ? `${title}. ${body}` : title; +} + +function inferLayoutHint( + rawScene: string, + kind: PresentationSceneKind, +): PresentationPptxLayoutHint { + if (kind === "title") return "title"; + if (/(?:`{3,}|~{3,})|= MAX_BULLETS) break; + } + + if (markdownBullets.length > 0) return markdownBullets; + + return splitSentences(cleanBody) + .map((sentence) => firstWords(sentence, MAX_BULLET_WORDS)) + .filter(Boolean) + .slice(0, MAX_BULLETS); +} + +function splitSentences(value: string): string[] { + return normalizeText(value) + .split(/(?<=[.!?。!?])\s+|(?<=다\.)\s+|(?<=요\.)\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +function firstWords(text: string, maxWords: number): string { + const words = normalizeText(text).split(" ").filter(Boolean); + if (words.length <= maxWords) return words.join(" "); + return `${words.slice(0, maxWords).join(" ")} …`; +} + +function stripInlineMdx(value: string): string { + return normalizeText( + value + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[~*_{}]/g, ""), + ); +} + +function slugify(value: string): string { + return normalizeText(value) + .toLowerCase() + .normalize("NFC") + .replace(/[^a-z0-9가-힣]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} diff --git a/apps/web/src/shared/tts/tts-artifacts.test.ts b/apps/web/src/shared/tts/tts-artifacts.test.ts index b41fc25..9e1da7c 100644 --- a/apps/web/src/shared/tts/tts-artifacts.test.ts +++ b/apps/web/src/shared/tts/tts-artifacts.test.ts @@ -60,11 +60,25 @@ describe("TTS artifact manifest", () => { expect(sections.map((section) => section.id)).toEqual(["반복", "반복-2"]); }); + it("does not create section artifacts from headings inside fenced code blocks", () => { + const sections = extractTtsSections( + `## 실제 섹션\n\n\`\`\`md\n## 코드 안 제목\n\`\`\`\n\n설명`, + ); + expect(sections.map((section) => section.id)).toEqual(["실제-섹션"]); + }); + + it("respects markdown fence marker length and tilde fences", () => { + const sections = extractTtsSections( + `## 실제 섹션\n\n~~~~md\n## 코드 안 제목\n\`\`\`\n### 여전히 코드\n~~~~\n\n설명`, + ); + expect(sections.map((section) => section.id)).toEqual(["실제-섹션"]); + }); + it("strips MDX syntax before feeding text to TTS", () => { expect( normalizeText( stripMdx( - "```ts\nconst x = 1\n```\n## 제목\n- [링크](https://example.com)와 `코드`", + "```ts\nconst x = 1\n```\n~~~md\n## 코드 안 제목\n~~~\n## 제목\n- [링크](https://example.com)와 `코드`", ), ), ).toContain("제목 링크와 코드"); diff --git a/apps/web/src/shared/tts/tts-artifacts.ts b/apps/web/src/shared/tts/tts-artifacts.ts index 12cdd2f..926fcbf 100644 --- a/apps/web/src/shared/tts/tts-artifacts.ts +++ b/apps/web/src/shared/tts/tts-artifacts.ts @@ -1,3 +1,7 @@ +import { + nextCodeFenceState, + type CodeFenceState, +} from "@/shared/lib/code-fence"; import { blogUrl } from "@/shared/config/site"; export type TtsArtifactKind = "summary-2m" | "core-5m" | "section"; @@ -116,9 +120,14 @@ export function extractTtsSections(source: string): TtsSection[] { const sections: TtsSection[] = []; let current: TtsSection | null = null; const seenIds = new Map(); + let codeFence: CodeFenceState | null = null; for (const line of lines) { - const heading = /^(#{2,4})\s+(.+?)\s*$/.exec(line.trim()); + codeFence = nextCodeFenceState(codeFence, line); + + const heading = codeFence + ? null + : /^(#{2,4})\s+(.+?)\s*$/.exec(line.trim()); if (heading) { if (current) current.text = normalizeText(stripMdx(current.text)); const title = stripInlineMdx(heading[2] ?? "섹션"); @@ -148,7 +157,7 @@ export function extractTtsSections(source: string): TtsSection[] { export function stripMdx(source: string): string { return source - .replace(/```[\s\S]*?```/g, " ") + .replace(/(`{3,}|~{3,})[\s\S]*?\1/g, " ") .replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, " ") .replace(/^import\s+.+$/gm, " ") .replace(/^export\s+.+$/gm, " ") diff --git a/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx b/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx index 8930205..33c99de 100644 --- a/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx +++ b/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx @@ -248,6 +248,37 @@ describe("PostQaPanel", () => { ).toHaveValue(""); }); + it("aborts in-flight requests without writing stale side effects", async () => { + const listener = vi.fn(); + window.addEventListener("seojing:qa-interaction", listener); + let aborted = false; + vi.mocked(fetch).mockImplementationOnce((_input, init) => { + const signal = init?.signal as AbortSignal | undefined; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("Aborted", "AbortError")); + }); + }); + }); + + const { unmount } = render( + , + ); + fireEvent.change( + screen.getByRole("textbox", { name: "이 글에 대해 질문하기" }), + { target: { value: "느린 요청이면 어떻게 돼?" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "질문 보내기" })); + + unmount(); + + await waitFor(() => expect(aborted).toBe(true)); + expect(listener).not.toHaveBeenCalled(); + expect(window.localStorage.getItem("seojing_post_qa_log_v1")).toBeNull(); + window.removeEventListener("seojing:qa-interaction", listener); + }); + it("prefills section context from floating section prompts", async () => { Element.prototype.scrollIntoView = vi.fn(); const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, "focus"); diff --git a/apps/web/src/widgets/post-qa/PostQaPanel.tsx b/apps/web/src/widgets/post-qa/PostQaPanel.tsx index 8af009f..2e89176 100644 --- a/apps/web/src/widgets/post-qa/PostQaPanel.tsx +++ b/apps/web/src/widgets/post-qa/PostQaPanel.tsx @@ -99,39 +99,53 @@ function safeAnalyticsDetail( return { action, question_length_bucket: questionLengthBucket }; } -export function PostQaPanel({ +export function PostQaPanel(props: PostQaPanelProps) { + return ; +} + +function PostQaPanelInner({ slug, title, endpoint = DEFAULT_ENDPOINT, storageKey = DEFAULT_STORAGE_KEY, }: PostQaPanelProps) { const [question, setQuestion] = useState(""); - const [result, setResult] = useState(null); + const [resultState, setResultState] = useState<{ + slug: string; + result: PostQaResult; + } | null>(null); const [error, setError] = useState(null); const [pending, setPending] = useState(false); - const [log, setLog] = useState([]); + const [log, setLog] = useState(() => + safeReadLog(storageKey).filter((entry) => entry.slug === slug), + ); const [sectionContext, setSectionContext] = useState( null, ); const requestSeq = useRef(0); + const requestAbortController = useRef(null); const panelRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { requestSeq.current += 1; - setQuestion(""); - setResult(null); - setError(null); - setPending(false); - setSectionContext(null); - setLog(safeReadLog(storageKey).filter((entry) => entry.slug === slug)); + requestAbortController.current?.abort(); + requestAbortController.current = null; }, [slug, storageKey]); + useEffect(() => { + return () => { + requestSeq.current += 1; + requestAbortController.current?.abort(); + requestAbortController.current = null; + }; + }, []); + useEffect(() => { const handleContext = (event: WindowEventMap["seojing:qa-context"]) => { const nextContext = event.detail; setSectionContext(nextContext); - setResult(null); + setResultState(null); setError(null); window.setTimeout(() => { panelRef.current?.scrollIntoView({ @@ -152,6 +166,7 @@ export function PostQaPanel({ question.trim().length > 0 && question.trim().length <= 500 && !pending, [pending, question], ); + const visibleResult = resultState?.slug === slug ? resultState.result : null; const submitQuestion = async () => { const trimmedQuestion = question.trim(); @@ -161,6 +176,9 @@ export function PostQaPanel({ : trimmedQuestion; requestSeq.current += 1; + requestAbortController.current?.abort(); + const controller = new AbortController(); + requestAbortController.current = controller; const currentRequestSeq = requestSeq.current; setPending(true); @@ -171,12 +189,13 @@ export function PostQaPanel({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ slug, question: apiQuestion }), + signal: controller.signal, }); if (!response.ok) throw new Error(`qa request failed: ${response.status}`); const body = (await response.json()) as PostQaResult; if (requestSeq.current !== currentRequestSeq) return; - setResult(body); + setResultState({ slug, result: body }); setQuestion(""); const nextLog = [ @@ -203,15 +222,18 @@ export function PostQaPanel({ }), ); } - } catch { + } catch (error) { + if (controller.signal.aborted) return; + if (error instanceof DOMException && error.name === "AbortError") return; if (requestSeq.current !== currentRequestSeq) return; setError( "질문 API가 잠시 불안정해요. 글 읽기는 그대로 가능하니 잠시 후 다시 시도해주세요.", ); } finally { if (requestSeq.current === currentRequestSeq) { - setPending(false); + requestAbortController.current = null; } + setPending(false); } }; @@ -291,18 +313,18 @@ export function PostQaPanel({

)} - {result && ( + {visibleResult && (

- {result.answer} + {visibleResult.answer}

- {result.sources.length > 0 && ( + {visibleResult.sources.length > 0 && (

출처

    - {result.sources.map((source) => { + {visibleResult.sources.map((source) => { const href = sourceHref(source.href, source.chunkId); return (
)} - {result.relatedPosts.length > 0 && ( + {visibleResult.relatedPosts.length > 0 && (

관련 글

    - {result.relatedPosts.map((post) => { + {visibleResult.relatedPosts.map((post) => { const href = safeInternalBlogHref(post.href); return (
  • diff --git a/apps/web/src/widgets/presentation/PresentationView.tsx b/apps/web/src/widgets/presentation/PresentationView.tsx index 988344f..699f06a 100644 --- a/apps/web/src/widgets/presentation/PresentationView.tsx +++ b/apps/web/src/widgets/presentation/PresentationView.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useCallback, - useMemo, useRef, type RefObject, } from "react"; @@ -15,16 +14,9 @@ import { IoRemoveOutline, IoPhonePortraitOutline, IoDesktopOutline, - IoListOutline, - IoExpandOutline, - IoContractOutline, } from "react-icons/io5"; import { FullscreenView } from "@app/ui"; -import { - extractSlideOutlineFromSlides, - extractSlides, - getFillRatio, -} from "./presentation.utils"; +import { extractSlides, getFillRatio } from "./presentation.utils"; const LONG_PRESS_MS = 1500; const LONG_PRESS_THRESHOLD_MS = 300; // 롱프레스 판단 최소 시간 @@ -36,24 +28,38 @@ const MIN_SCALE = 1; const MAX_SCALE = 3; const SCALE_STEP = 0.2; const BOTTOM_BAR_HEIGHT_MOBILE = 36; -const DECK_SIDEBAR_WIDTH = 288; -const DECK_MAIN_PADDING_X = 80; -const DECK_MAIN_PADDING_Y = 64; -const DECK_CARD_PADDING_X = 112; -const DECK_CARD_PADDING_Y = 128; -const DECK_MAX_CARD_WIDTH = 1152; const MIN_FILL_RATIO = 0.3; const MAX_FILL_RATIO = 1.0; const FILL_RATIO_STEP = 0.05; function getPresentationSource(article: HTMLElement): HTMLElement { - const firstChild = article.firstElementChild; + const shell = + article.firstElementChild instanceof HTMLElement + ? article.firstElementChild + : article; + const directChildren = Array.from(shell.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement, + ); + const explicitContent = directChildren.find((child) => + child.hasAttribute("data-presentation-content"), + ); + if (explicitContent) return explicitContent; + + const bodyLikeContent = directChildren.find((child) => { + const tag = child.tagName.toLowerCase(); + if (["header", "nav", "aside", "footer", "section"].includes(tag)) { + return false; + } + if (child.querySelector('[aria-label="블로그 오디오 플레이어"], audio')) { + return false; + } - if (firstChild instanceof HTMLElement) { - return firstChild; - } + return Boolean( + child.querySelector("h2,h3,h4,p,blockquote,ul,ol,pre,[data-code-block]"), + ); + }); - return article; + return bodyLikeContent ?? shell; } interface PresentationViewProps { @@ -121,7 +127,6 @@ export function PresentationView({ : BOTTOM_BAR_HEIGHT_PC; const [slides, setSlides] = useState([]); - const [isFullscreenCanvas, setIsFullscreenCanvas] = useState(false); useEffect(() => { if (!articleRef.current) return; @@ -132,36 +137,13 @@ export function PresentationView({ const viewH = needsRotation ? (vv?.width ?? window.innerWidth) : (vv?.height ?? window.innerHeight); - const scale = isMobile || !isFullscreenCanvas ? 1 : pcScale; - let visibleHeight = viewH - bottomBarHeight; - let slideW = needsRotation + const padding = isMobile ? SLIDE_PADDING_Y_MOBILE : SLIDE_PADDING_Y; + const ratio = fillRatioOverride ?? getFillRatio(viewH); + const available = (viewH - padding - bottomBarHeight) * ratio; + const scale = isMobile ? 1 : pcScale; + const slideW = needsRotation ? (vv?.height ?? window.innerHeight) - 64 : Math.min(window.innerWidth - 128, 1024); - - if (!isMobile && !isFullscreenCanvas) { - const deckMainWidth = Math.max( - 320, - window.innerWidth - DECK_SIDEBAR_WIDTH - DECK_MAIN_PADDING_X, - ); - const deckMainHeight = Math.max( - 240, - viewH - bottomBarHeight - DECK_MAIN_PADDING_Y, - ); - const cardWidth = Math.min( - DECK_MAX_CARD_WIDTH, - deckMainWidth, - deckMainHeight * (16 / 9), - ); - const cardHeight = Math.min(deckMainHeight, cardWidth * (9 / 16)); - visibleHeight = cardHeight - DECK_CARD_PADDING_Y; - slideW = cardWidth - DECK_CARD_PADDING_X; - } else { - const padding = isMobile ? SLIDE_PADDING_Y_MOBILE : SLIDE_PADDING_Y; - visibleHeight = viewH - padding - bottomBarHeight; - } - - const ratio = fillRatioOverride ?? getFillRatio(viewH); - const available = visibleHeight * ratio; const presentationSource = getPresentationSource(articleRef.current); // scale 적용 시 콘텐츠는 원래 크기로 측정되므로, 가용 공간을 scale로 나눠서 전달 setSlides( @@ -174,21 +156,9 @@ export function PresentationView({ bottomBarHeight, pcScale, fillRatioOverride, - isFullscreenCanvas, ]); const totalSlides = slides.length; - const slideOutline = useMemo( - () => extractSlideOutlineFromSlides(slides), - [slides], - ); - const headingOutline = useMemo( - () => - slideOutline.filter( - (item) => item.kind === "heading" && item.level >= 1 && item.level <= 2, - ), - [slideOutline], - ); const closingRef = useRef(false); const safeClose = useCallback(() => { @@ -211,15 +181,6 @@ export function PresentationView({ setCurrentSlide((p) => Math.max(p - 1, 0)); }, []); - const goToSlide = useCallback((slideIndex: number) => { - setCurrentSlide(slideIndex); - }, []); - - const toggleFullscreenCanvas = useCallback(() => { - setIsFullscreenCanvas((prev) => !prev); - setCurrentSlide(0); - }, []); - useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; @@ -270,7 +231,7 @@ export function PresentationView({ const el = slideContentRef.current; const parent = el?.parentElement; if (!el || !parent) return; - const scale = isMobile || !isFullscreenCanvas ? 1 : pcScale; + const scale = isMobile ? 1 : pcScale; const contentH = el.scrollHeight * scale; const parentH = parent.clientHeight; setOverflowing(contentH > parentH + 1); @@ -294,7 +255,7 @@ export function PresentationView({ return () => { handlers.forEach((cleanup) => cleanup()); }; - }, [currentSlide, slides, isMobile, pcScale, isFullscreenCanvas]); + }, [currentSlide, slides, isMobile, pcScale]); const handlePointerDown = useCallback(() => { if (!isMobile) return; @@ -361,88 +322,21 @@ export function PresentationView({ style={{ height: "100dvh" }} >
    - {/* 덱 레이아웃: PC 기본은 좌측 목차 + 우측 카드 슬라이스, 전체화면 모드는 기존 꽉찬 캔버스 */} - {isFullscreenCanvas || isMobile ? ( + {/* 슬라이드 콘텐츠 */} +
    -
    -
    - ) : ( -
    - - -
    -
    -
    - Deck {currentSlide + 1} / {totalSlides} -
    -
    -
    -
    -
    -
    -
    - )} + ref={slideContentRef} + className="mx-auto w-full max-w-5xl overflow-hidden" + style={isMobile ? undefined : { zoom: pcScale }} + /> +
    {/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}
    - )} {pcScale.toFixed(1)} {/* 표시 영역 조절 (미러링 등으로 화면 잘릴 때 사용) */} - + {Math.round( @@ -611,7 +481,7 @@ export function PresentationView({