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\nconst 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 }}
+ />
+
{/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}