From 0bfe78f5d8385e8b05cac4ce2ae7043de9da267a Mon Sep 17 00:00:00 2001
From: seoJing
Date: Mon, 8 Jun 2026 21:47:17 +0900
Subject: [PATCH 1/7] feature: add presentation pptx export bridge
---
apps/web/package.json | 4 +-
apps/web/scripts/export-presentation-pptx.ts | 292 ++++++++++++++
.../presentation/presentation-export.test.ts | 109 ++++++
.../presentation/presentation-export.ts | 363 ++++++++++++++++++
apps/web/src/shared/tts/tts-artifacts.test.ts | 16 +-
apps/web/src/shared/tts/tts-artifacts.ts | 37 +-
apps/web/src/widgets/post-qa/PostQaPanel.tsx | 17 +-
docs/seojing-pptx-tts-bridge-spike.md | 39 ++
pnpm-lock.yaml | 274 +++++++++++--
9 files changed, 1099 insertions(+), 52 deletions(-)
create mode 100644 apps/web/scripts/export-presentation-pptx.ts
create mode 100644 apps/web/src/shared/presentation/presentation-export.test.ts
create mode 100644 apps/web/src/shared/presentation/presentation-export.ts
create mode 100644 docs/seojing-pptx-tts-bridge-spike.md
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/presentation/presentation-export.test.ts b/apps/web/src/shared/presentation/presentation-export.test.ts
new file mode 100644
index 0000000..0a9fc3f
--- /dev/null
+++ b/apps/web/src/shared/presentation/presentation-export.test.ts
@@ -0,0 +1,109 @@
+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("keeps H2 section ids aligned with TTS even when H1 has the same title", () => {
+ const scenes = extractPresentationScenes(
+ `# 개요\n\n도입\n\n## 개요\n\n본문`,
+ "Fallback title",
+ );
+
+ expect(scenes[1]?.headingId).toBe("개요");
+ });
+});
+
+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,
+ });
+ });
+});
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..56064ce
--- /dev/null
+++ b/apps/web/src/shared/presentation/presentation-export.ts
@@ -0,0 +1,363 @@
+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;
+}
+
+interface CodeFenceState {
+ char: "`" | "~";
+ length: number;
+}
+
+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 =
+ headingLevel === 1 ? 0 : (seenIds.get(baseId) ?? 0);
+ if (headingLevel > 1) {
+ 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.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);
+}
+
+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/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..e0799f0 100644
--- a/apps/web/src/shared/tts/tts-artifacts.ts
+++ b/apps/web/src/shared/tts/tts-artifacts.ts
@@ -46,6 +46,11 @@ export interface BuildTtsManifestInput {
generatedAt?: string;
}
+interface CodeFenceState {
+ char: "`" | "~";
+ length: number;
+}
+
const TWO_MINUTE_MAX_WORDS = 360;
const FIVE_MINUTE_MAX_WORDS = 900;
const SECTION_MAX_WORDS = 520;
@@ -116,9 +121,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 +158,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, " ")
@@ -225,3 +235,26 @@ function slugify(value: string): string {
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
+
+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/widgets/post-qa/PostQaPanel.tsx b/apps/web/src/widgets/post-qa/PostQaPanel.tsx
index 8af009f..412dce2 100644
--- a/apps/web/src/widgets/post-qa/PostQaPanel.tsx
+++ b/apps/web/src/widgets/post-qa/PostQaPanel.tsx
@@ -99,7 +99,12 @@ function safeAnalyticsDetail(
return { action, question_length_bucket: questionLengthBucket };
}
-export function PostQaPanel({
+export function PostQaPanel(props: PostQaPanelProps) {
+ const storageKey = props.storageKey ?? DEFAULT_STORAGE_KEY;
+ return ;
+}
+
+function PostQaPanelInner({
slug,
title,
endpoint = DEFAULT_ENDPOINT,
@@ -109,7 +114,9 @@ export function PostQaPanel({
const [result, setResult] = useState(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,
);
@@ -119,12 +126,6 @@ export function PostQaPanel({
useEffect(() => {
requestSeq.current += 1;
- setQuestion("");
- setResult(null);
- setError(null);
- setPending(false);
- setSectionContext(null);
- setLog(safeReadLog(storageKey).filter((entry) => entry.slug === slug));
}, [slug, storageKey]);
useEffect(() => {
diff --git a/docs/seojing-pptx-tts-bridge-spike.md b/docs/seojing-pptx-tts-bridge-spike.md
new file mode 100644
index 0000000..5578324
--- /dev/null
+++ b/docs/seojing-pptx-tts-bridge-spike.md
@@ -0,0 +1,39 @@
+# SEOJing PPTX export spike + TTS script bridge
+
+## 결론
+
+PPTX export는 `pptxgenjs` 기반의 lightweight scene deck으로 가능하다. 기존 웹 프레젠테이션 DOM을 픽셀 단위로 캡처하는 방식이 아니라, MDX의 H1/H2/H3 구간을 발표 scene으로 재구성하고 각 scene을 PPTX slide + speaker notes로 내보내는 방식이 현실적이다.
+
+## 현재 구현
+
+- `apps/web/src/shared/presentation/presentation-export.ts`
+ - MDX 본문을 H1/H2/H3 scene 단위로 나눈다.
+ - 각 scene에 `summary`, `bullets`, `speakerScript`, `pptx.layoutHint`를 만든다.
+ - 기존 TTS manifest와 연결해 slide별 `transcriptPath`/`audioPath` bridge를 제공한다.
+- `apps/web/scripts/export-presentation-pptx.ts`
+ - `pnpm --filter @app/web run export:presentation -- --slug `로 실행한다.
+ - `manifest.json`과 `.pptx`를 생성한다.
+ - speaker notes에 scene별 발표 스크립트를 넣고, slide footer에 TTS transcript path를 남긴다.
+
+## Smoke result
+
+실행 명령:
+
+```bash
+pnpm --filter @app/web run export:presentation -- --slug study/backend/day1 --out-dir /tmp/seojing-presentation-export-smoke
+```
+
+검증 결과:
+
+- manifest: `/tmp/seojing-presentation-export-smoke/study/backend/day1/manifest.json`
+- pptx: `/tmp/seojing-presentation-export-smoke/study/backend/day1/study__backend__day1.pptx`
+- manifest scenes: 43
+- pptx slides: 43
+- pptx notes: 43
+
+## 제한과 다음 단계
+
+- 웹 deck의 DOM/애니메이션을 그대로 복제하지 않는다. 발표용 정적 요약 deck으로 취급한다.
+- 코드/이미지는 현재 텍스트 요약과 `layoutHint`까지만 반영한다. 이미지 삽입, 코드 시각화, scene별 디자인 템플릿은 다음 단계에서 별도 확장해야 한다.
+- MP3 파일은 PPTX에 직접 임베드하지 않는다. 현재는 TTS artifact path와 speaker notes bridge를 제공한다. 실제 음성 삽입은 Mac mini TTS cache/API와 export 시점의 파일 존재 여부를 확인한 뒤 opt-in으로 붙이는 편이 안전하다.
+- 모든 글 자동 export보다, `featured`/presentation 가치가 있는 글만 opt-in manifest로 묶는 방향이 맞다.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2655451..7991b3f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,19 +10,19 @@ importers:
devDependencies:
'@commitlint/cli':
specifier: ^20.4.4
- version: 20.4.4(@types/node@20.19.37)(conventional-commits-parser@6.3.0)(typescript@5.9.3)
+ version: 20.4.4(@types/node@22.19.20)(conventional-commits-parser@6.3.0)(typescript@5.9.3)
'@commitlint/config-conventional':
specifier: ^20.4.4
version: 20.4.4
'@commitlint/prompt-cli':
specifier: ^20.4.4
- version: 20.4.4(@types/node@20.19.37)(typescript@5.9.3)
+ version: 20.4.4(@types/node@22.19.20)(typescript@5.9.3)
'@tailwindcss/postcss':
specifier: ^4.2.1
version: 4.2.1
'@tailwindcss/vite':
specifier: ^4.2.1
- version: 4.2.1(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ version: 4.2.1(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
eslint:
specifier: ^9.39.4
version: 9.39.4(jiti@2.6.1)
@@ -129,6 +129,9 @@ importers:
jsdom:
specifier: ^28.1.0
version: 28.1.0
+ pptxgenjs:
+ specifier: ^4.0.1
+ version: 4.0.1
rehype-prism-plus:
specifier: ^2.0.2
version: 2.0.2
@@ -167,7 +170,7 @@ importers:
version: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks:
specifier: latest
- version: 7.0.1(eslint@9.39.4(jiti@2.6.1))
+ version: 7.1.1(eslint@9.39.4(jiti@2.6.1))
typescript-eslint:
specifier: ^8.0.0
version: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
@@ -207,10 +210,10 @@ importers:
version: link:../config/typescript
'@storybook/react-vite':
specifier: ^10.2.19
- version: 10.2.19(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))
+ version: 10.2.19(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))
'@tailwindcss/vite':
specifier: ^4.2.1
- version: 4.2.1(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ version: 4.2.1(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -225,10 +228,10 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^6.0.0
- version: 6.0.0(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ version: 6.0.0(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/coverage-v8':
specifier: ^4.1.0
- version: 4.1.0(vitest@4.1.0(@types/node@20.19.37)(jsdom@28.1.0)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)))
+ version: 4.1.0(vitest@4.1.0(@types/node@22.19.20)(jsdom@28.1.0)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)))
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
@@ -246,7 +249,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.0
- version: 4.1.0(@types/node@20.19.37)(jsdom@28.1.0)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ version: 4.1.0(@types/node@22.19.20)(jsdom@28.1.0)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
packages/utils:
dependencies:
@@ -1684,6 +1687,9 @@ packages:
'@types/node@20.19.37':
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
+ '@types/node@22.19.20':
+ resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==}
+
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
@@ -1768,6 +1774,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unpic/core@1.0.3':
resolution: {integrity: sha512-aum9YNVUGso7MjGLD0Rp/08kywCGLqZ03/q6VQBFFakDBOXWEc8D4kPGcZ8v5wEnGRex3lE+++bOuucBp3KJ/w==}
@@ -2256,6 +2263,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
+ core-util-is@1.0.3:
+ resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+
cosmiconfig-typescript-loader@6.2.0:
resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==}
engines: {node: '>=v18'}
@@ -2520,11 +2530,11 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
- eslint-plugin-react-hooks@7.0.1:
- resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
peerDependencies:
- eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
eslint-plugin-react@7.37.5:
resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
@@ -2861,6 +2871,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
+ https@1.0.0:
+ resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==}
+
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -2881,6 +2894,14 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ image-size@1.2.1:
+ resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==}
+ engines: {node: '>=16.x'}
+ hasBin: true
+
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3081,6 +3102,9 @@ packages:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -3162,6 +3186,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3173,6 +3200,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'}
@@ -3751,6 +3781,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3845,6 +3878,9 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
+ pptxgenjs@4.0.1:
+ resolution: {integrity: sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3858,6 +3894,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -3871,6 +3910,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ queue@6.0.2:
+ resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
+
react-docgen-typescript@2.4.0:
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
peerDependencies:
@@ -3904,6 +3946,7 @@ packages:
react-server-dom-webpack@19.2.5:
resolution: {integrity: sha512-bYhdd2cZJhXHqyJBoloYaJrn8MrL9Egf3ZZVn0OrIODCCORm2goFD7C+xszf6xgfsSJi0rtgB/ichcuHfkJ4yQ==}
engines: {node: '>=0.10.0'}
+ deprecated: High Security Vulnerability in React Server Components
peerDependencies:
react: ^19.2.5
react-dom: ^19.2.5
@@ -3928,6 +3971,9 @@ packages:
resolution: {integrity: sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -4069,6 +4115,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -4123,6 +4172,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -4261,6 +4313,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -5080,11 +5135,11 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20260312.1':
optional: true
- '@commitlint/cli@20.4.4(@types/node@20.19.37)(conventional-commits-parser@6.3.0)(typescript@5.9.3)':
+ '@commitlint/cli@20.4.4(@types/node@22.19.20)(conventional-commits-parser@6.3.0)(typescript@5.9.3)':
dependencies:
'@commitlint/format': 20.4.4
'@commitlint/lint': 20.4.4
- '@commitlint/load': 20.4.4(@types/node@20.19.37)(typescript@5.9.3)
+ '@commitlint/load': 20.4.4(@types/node@22.19.20)(typescript@5.9.3)
'@commitlint/read': 20.4.4(conventional-commits-parser@6.3.0)
'@commitlint/types': 20.4.4
tinyexec: 1.0.2
@@ -5133,14 +5188,14 @@ snapshots:
'@commitlint/rules': 20.4.4
'@commitlint/types': 20.4.4
- '@commitlint/load@20.4.4(@types/node@20.19.37)(typescript@5.9.3)':
+ '@commitlint/load@20.4.4(@types/node@22.19.20)(typescript@5.9.3)':
dependencies:
'@commitlint/config-validator': 20.4.4
'@commitlint/execute-rule': 20.0.0
'@commitlint/resolve-extends': 20.4.4
'@commitlint/types': 20.4.4
cosmiconfig: 9.0.1(typescript@5.9.3)
- cosmiconfig-typescript-loader: 6.2.0(@types/node@20.19.37)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
+ cosmiconfig-typescript-loader: 6.2.0(@types/node@22.19.20)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
is-plain-obj: 4.1.0
lodash.mergewith: 4.6.2
picocolors: 1.1.1
@@ -5156,21 +5211,21 @@ snapshots:
conventional-changelog-angular: 8.3.0
conventional-commits-parser: 6.3.0
- '@commitlint/prompt-cli@20.4.4(@types/node@20.19.37)(typescript@5.9.3)':
+ '@commitlint/prompt-cli@20.4.4(@types/node@22.19.20)(typescript@5.9.3)':
dependencies:
- '@commitlint/prompt': 20.4.4(@types/node@20.19.37)(typescript@5.9.3)
- inquirer: 9.3.8(@types/node@20.19.37)
+ '@commitlint/prompt': 20.4.4(@types/node@22.19.20)(typescript@5.9.3)
+ inquirer: 9.3.8(@types/node@22.19.20)
tinyexec: 1.0.2
transitivePeerDependencies:
- '@types/node'
- typescript
- '@commitlint/prompt@20.4.4(@types/node@20.19.37)(typescript@5.9.3)':
+ '@commitlint/prompt@20.4.4(@types/node@22.19.20)(typescript@5.9.3)':
dependencies:
'@commitlint/ensure': 20.4.4
- '@commitlint/load': 20.4.4(@types/node@20.19.37)(typescript@5.9.3)
+ '@commitlint/load': 20.4.4(@types/node@22.19.20)(typescript@5.9.3)
'@commitlint/types': 20.4.4
- inquirer: 9.3.8(@types/node@20.19.37)
+ inquirer: 9.3.8(@types/node@22.19.20)
picocolors: 1.1.1
transitivePeerDependencies:
- '@types/node'
@@ -5581,20 +5636,20 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
- '@inquirer/external-editor@1.0.3(@types/node@20.19.37)':
+ '@inquirer/external-editor@1.0.3(@types/node@22.19.20)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
- '@types/node': 20.19.37
+ '@types/node': 22.19.20
'@inquirer/figures@1.0.15': {}
- '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))':
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
glob: 13.0.6
react-docgen-typescript: 2.4.0(typescript@5.9.3)
- vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
typescript: 5.9.3
@@ -5870,25 +5925,25 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
- '@storybook/builder-vite@10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))':
+ '@storybook/builder-vite@10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))':
dependencies:
- '@storybook/csf-plugin': 10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))
+ '@storybook/csf-plugin': 10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))
storybook: 10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-dedent: 2.2.0
- vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- esbuild
- rollup
- webpack
- '@storybook/csf-plugin@10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))':
+ '@storybook/csf-plugin@10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))':
dependencies:
storybook: 10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.4
rollup: 4.59.0
- vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
webpack: 5.106.2(esbuild@0.27.4)
'@storybook/global@5.0.0': {}
@@ -5904,11 +5959,11 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
storybook: 10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@storybook/react-vite@10.2.19(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))':
+ '@storybook/react-vite@10.2.19(esbuild@0.27.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))':
dependencies:
- '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
- '@storybook/builder-vite': 10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))
+ '@storybook/builder-vite': 10.2.19(esbuild@0.27.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.106.2(esbuild@0.27.4))
'@storybook/react': 10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
empathic: 2.0.0
magic-string: 0.30.21
@@ -5918,7 +5973,7 @@ snapshots:
resolve: 1.22.11
storybook: 10.2.19(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tsconfig-paths: 4.2.0
- vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- esbuild
- rollup
@@ -6015,6 +6070,13 @@ snapshots:
tailwindcss: 4.2.1
vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ '@tailwindcss/vite@4.2.1(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@tailwindcss/node': 4.2.1
+ '@tailwindcss/oxide': 4.2.1
+ tailwindcss: 4.2.1
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+
'@tanstack/query-core@5.90.20': {}
'@tanstack/react-query@5.90.21(react@19.2.5)':
@@ -6141,6 +6203,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/node@22.19.20':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/prismjs@1.26.6': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)':
@@ -6272,6 +6338,11 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ '@vitejs/plugin-react@6.0.0(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+
'@vitejs/plugin-rsc@0.5.25(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.106.2(esbuild@0.27.4)))(react@19.2.5)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.17
@@ -6302,6 +6373,20 @@ snapshots:
tinyrainbow: 3.1.0
vitest: 4.1.0(@types/node@20.19.37)(jsdom@28.1.0)(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.20)(jsdom@28.1.0)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)))':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.0
+ ast-v8-to-istanbul: 1.0.0
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.2
+ obug: 2.1.1
+ std-env: 4.0.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.0(@types/node@22.19.20)(jsdom@28.1.0)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
@@ -6327,6 +6412,14 @@ snapshots:
optionalDependencies:
vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@vitest/spy': 4.1.0
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@@ -6777,9 +6870,11 @@ snapshots:
cookie@1.1.1: {}
- cosmiconfig-typescript-loader@6.2.0(@types/node@20.19.37)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
+ core-util-is@1.0.3: {}
+
+ cosmiconfig-typescript-loader@6.2.0(@types/node@22.19.20)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
dependencies:
- '@types/node': 20.19.37
+ '@types/node': 22.19.20
cosmiconfig: 9.0.1(typescript@5.9.3)
jiti: 2.6.1
typescript: 5.9.3
@@ -7144,7 +7239,7 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.6.1)
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.0
@@ -7588,6 +7683,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ https@1.0.0: {}
+
husky@9.1.7: {}
iconv-lite@0.7.2:
@@ -7600,6 +7697,12 @@ snapshots:
ignore@7.0.5: {}
+ image-size@1.2.1:
+ dependencies:
+ queue: 6.0.2
+
+ immediate@3.0.6: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -7621,9 +7724,9 @@ snapshots:
inline-style-parser@0.2.7: {}
- inquirer@9.3.8(@types/node@20.19.37):
+ inquirer@9.3.8(@types/node@22.19.20):
dependencies:
- '@inquirer/external-editor': 1.0.3(@types/node@20.19.37)
+ '@inquirer/external-editor': 1.0.3(@types/node@22.19.20)
'@inquirer/figures': 1.0.15
ansi-escapes: 4.3.2
cli-width: 4.1.0
@@ -7795,6 +7898,8 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
+ isarray@1.0.0: {}
+
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -7887,6 +7992,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -7898,6 +8010,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
lightningcss-android-arm64@1.31.1:
optional: true
@@ -8745,6 +8861,8 @@ snapshots:
pako@0.2.9: {}
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -8836,6 +8954,13 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ pptxgenjs@4.0.1:
+ dependencies:
+ '@types/node': 22.19.20
+ https: 1.0.0
+ image-size: 1.2.1
+ jszip: 3.10.1
+
prelude-ls@1.2.1: {}
prettier@3.8.1: {}
@@ -8846,6 +8971,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
+ process-nextick-args@2.0.1: {}
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -8858,6 +8985,10 @@ snapshots:
queue-microtask@1.2.3: {}
+ queue@6.0.2:
+ dependencies:
+ inherits: 2.0.4
+
react-docgen-typescript@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -8925,6 +9056,16 @@ snapshots:
normalize-package-data: 2.5.0
path-type: 1.1.0
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -9185,6 +9326,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
+ safe-buffer@5.1.2: {}
+
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
@@ -9255,6 +9398,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ setimmediate@1.0.5: {}
+
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@@ -9462,6 +9607,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -9876,6 +10025,23 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
+ vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ '@oxc-project/runtime': 0.115.0
+ lightningcss: 1.32.0
+ picomatch: 4.0.3
+ postcss: 8.5.8
+ rolldown: 1.0.0-rc.9
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 22.19.20
+ esbuild: 0.27.4
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ terser: 5.46.2
+ tsx: 4.21.0
+ yaml: 2.8.2
+
vitefu@1.1.3(vite@8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)):
optionalDependencies:
vite: 8.0.0(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
@@ -9908,6 +10074,34 @@ snapshots:
transitivePeerDependencies:
- msw
+ vitest@4.1.0(@types/node@22.19.20)(jsdom@28.1.0)(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)):
+ dependencies:
+ '@vitest/expect': 4.1.0
+ '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/runner': 4.1.0
+ '@vitest/snapshot': 4.1.0
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 8.0.0(@types/node@22.19.20)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.19.20
+ jsdom: 28.1.0
+ transitivePeerDependencies:
+ - msw
+
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
From d5e55b70e8f2683d115a57a3b1c5537d6145d6e5 Mon Sep 17 00:00:00 2001
From: seoJing
Date: Mon, 8 Jun 2026 23:28:42 +0900
Subject: [PATCH 2/7] refactor: share code fence parser
---
apps/web/src/shared/lib/code-fence.ts | 27 ++++++++++++++++
.../presentation/presentation-export.ts | 32 +++----------------
apps/web/src/shared/tts/tts-artifacts.ts | 32 +++----------------
3 files changed, 35 insertions(+), 56 deletions(-)
create mode 100644 apps/web/src/shared/lib/code-fence.ts
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.ts b/apps/web/src/shared/presentation/presentation-export.ts
index 56064ce..346edc1 100644
--- a/apps/web/src/shared/presentation/presentation-export.ts
+++ b/apps/web/src/shared/presentation/presentation-export.ts
@@ -1,3 +1,7 @@
+import {
+ nextCodeFenceState,
+ type CodeFenceState,
+} from "@/shared/lib/code-fence";
import {
buildTtsArticleManifest,
normalizeText,
@@ -75,11 +79,6 @@ interface RawScene {
raw: string;
}
-interface CodeFenceState {
- char: "`" | "~";
- length: number;
-}
-
const MAX_SUMMARY_WORDS = 34;
const MAX_BULLETS = 4;
const MAX_BULLET_WORDS = 18;
@@ -338,26 +337,3 @@ function slugify(value: string): string {
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
-
-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/tts/tts-artifacts.ts b/apps/web/src/shared/tts/tts-artifacts.ts
index e0799f0..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";
@@ -46,11 +50,6 @@ export interface BuildTtsManifestInput {
generatedAt?: string;
}
-interface CodeFenceState {
- char: "`" | "~";
- length: number;
-}
-
const TWO_MINUTE_MAX_WORDS = 360;
const FIVE_MINUTE_MAX_WORDS = 900;
const SECTION_MAX_WORDS = 520;
@@ -235,26 +234,3 @@ function slugify(value: string): string {
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
-
-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;
-}
From 7fa0a65484dc525e1b61d588f31849a9b980fa81 Mon Sep 17 00:00:00 2001
From: seoJing
Date: Mon, 8 Jun 2026 23:46:56 +0900
Subject: [PATCH 3/7] fix: address presentation review feedback
---
.../presentation/presentation-export.test.ts | 28 +++++++++++++++++--
.../presentation/presentation-export.ts | 12 ++++----
apps/web/src/widgets/post-qa/PostQaPanel.tsx | 23 +++++++++++++--
3 files changed, 51 insertions(+), 12 deletions(-)
diff --git a/apps/web/src/shared/presentation/presentation-export.test.ts b/apps/web/src/shared/presentation/presentation-export.test.ts
index 0a9fc3f..30869ac 100644
--- a/apps/web/src/shared/presentation/presentation-export.test.ts
+++ b/apps/web/src/shared/presentation/presentation-export.test.ts
@@ -53,13 +53,22 @@ describe("extractPresentationScenes", () => {
expect(scenes.map((scene) => scene.title)).toEqual(["실제 섹션"]);
});
- it("keeps H2 section ids aligned with TTS even when H1 has the same title", () => {
+ 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("개요");
+ expect(scenes[1]?.headingId).toBe("개요-2");
});
});
@@ -106,4 +115,19 @@ describe("buildPresentationExportManifest", () => {
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
index 346edc1..a320b3c 100644
--- a/apps/web/src/shared/presentation/presentation-export.ts
+++ b/apps/web/src/shared/presentation/presentation-export.ts
@@ -176,11 +176,8 @@ export function extractPresentationScenes(
(candidate) => candidate.level > 1,
).length;
const baseId = slugify(headingTitle) || `section-${sectionCount + 1}`;
- const duplicateCount =
- headingLevel === 1 ? 0 : (seenIds.get(baseId) ?? 0);
- if (headingLevel > 1) {
- seenIds.set(baseId, duplicateCount + 1);
- }
+ const duplicateCount = seenIds.get(baseId) ?? 0;
+ seenIds.set(baseId, duplicateCount + 1);
current = {
kind: headingLevel === 1 ? "title" : "section",
title: headingTitle,
@@ -235,7 +232,7 @@ function findTtsBridgeForScene(
): PresentationTtsBridge | null {
let artifact: TtsArtifact | undefined;
- if (scene.headingId) {
+ if (scene.kind === "section" && scene.headingId) {
artifact = ttsManifest.artifacts.find(
(candidate) =>
candidate.kind === "section" && candidate.sectionId === scene.headingId,
@@ -270,7 +267,8 @@ function inferLayoutHint(
kind: PresentationSceneKind,
): PresentationPptxLayoutHint {
if (kind === "title") return "title";
- if (/(?:`{3,}|~{3,})|;
+ return ;
}
function PostQaPanelInner({
@@ -121,13 +120,24 @@ function PostQaPanelInner({
null,
);
const requestSeq = useRef(0);
+ const requestAbortController = useRef(null);
const panelRef = useRef(null);
const textareaRef = useRef(null);
useEffect(() => {
requestSeq.current += 1;
+ 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;
@@ -162,6 +172,9 @@ function PostQaPanelInner({
: trimmedQuestion;
requestSeq.current += 1;
+ requestAbortController.current?.abort();
+ const controller = new AbortController();
+ requestAbortController.current = controller;
const currentRequestSeq = requestSeq.current;
setPending(true);
@@ -172,6 +185,7 @@ function PostQaPanelInner({
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}`);
@@ -204,13 +218,16 @@ function PostQaPanelInner({
}),
);
}
- } 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) {
+ requestAbortController.current = null;
setPending(false);
}
}
From 561a17b745489dd81bfbbd622715dd459731036e Mon Sep 17 00:00:00 2001
From: seoJing
Date: Mon, 8 Jun 2026 23:51:38 +0900
Subject: [PATCH 4/7] test: cover review edge cases
---
apps/web/src/shared/lib/code-fence.test.ts | 21 +++++++++++++
.../src/widgets/post-qa/PostQaPanel.test.tsx | 31 +++++++++++++++++++
apps/web/src/widgets/post-qa/PostQaPanel.tsx | 15 +++++----
3 files changed, 61 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/shared/lib/code-fence.test.ts
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..c726774
--- /dev/null
+++ b/apps/web/src/shared/lib/code-fence.test.ts
@@ -0,0 +1,21 @@
+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();
+ });
+});
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 4eebb9b..ccb4f9a 100644
--- a/apps/web/src/widgets/post-qa/PostQaPanel.tsx
+++ b/apps/web/src/widgets/post-qa/PostQaPanel.tsx
@@ -111,6 +111,7 @@ function PostQaPanelInner({
}: PostQaPanelProps) {
const [question, setQuestion] = useState("");
const [result, setResult] = useState(null);
+ const resultSlug = useRef(slug);
const [error, setError] = useState(null);
const [pending, setPending] = useState(false);
const [log, setLog] = useState(() =>
@@ -163,6 +164,7 @@ function PostQaPanelInner({
question.trim().length > 0 && question.trim().length <= 500 && !pending,
[pending, question],
);
+ const visibleResult = resultSlug.current === slug ? result : null;
const submitQuestion = async () => {
const trimmedQuestion = question.trim();
@@ -191,6 +193,7 @@ function PostQaPanelInner({
throw new Error(`qa request failed: ${response.status}`);
const body = (await response.json()) as PostQaResult;
if (requestSeq.current !== currentRequestSeq) return;
+ resultSlug.current = slug;
setResult(body);
setQuestion("");
@@ -309,18 +312,18 @@ function PostQaPanelInner({
)}
- {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 (
-
From a9818fc9b223428f0761dea96431d2f2f7cd89e3 Mon Sep 17 00:00:00 2001
From: seoJing
Date: Mon, 8 Jun 2026 23:55:05 +0900
Subject: [PATCH 5/7] fix: avoid ref access during render
---
apps/web/src/widgets/post-qa/PostQaPanel.tsx | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/widgets/post-qa/PostQaPanel.tsx b/apps/web/src/widgets/post-qa/PostQaPanel.tsx
index ccb4f9a..bcbd50a 100644
--- a/apps/web/src/widgets/post-qa/PostQaPanel.tsx
+++ b/apps/web/src/widgets/post-qa/PostQaPanel.tsx
@@ -110,8 +110,10 @@ function PostQaPanelInner({
storageKey = DEFAULT_STORAGE_KEY,
}: PostQaPanelProps) {
const [question, setQuestion] = useState("");
- const [result, setResult] = useState(null);
- const resultSlug = useRef(slug);
+ const [resultState, setResultState] = useState<{
+ slug: string;
+ result: PostQaResult;
+ } | null>(null);
const [error, setError] = useState(null);
const [pending, setPending] = useState(false);
const [log, setLog] = useState(() =>
@@ -143,7 +145,7 @@ function PostQaPanelInner({
const handleContext = (event: WindowEventMap["seojing:qa-context"]) => {
const nextContext = event.detail;
setSectionContext(nextContext);
- setResult(null);
+ setResultState(null);
setError(null);
window.setTimeout(() => {
panelRef.current?.scrollIntoView({
@@ -164,7 +166,7 @@ function PostQaPanelInner({
question.trim().length > 0 && question.trim().length <= 500 && !pending,
[pending, question],
);
- const visibleResult = resultSlug.current === slug ? result : null;
+ const visibleResult = resultState?.slug === slug ? resultState.result : null;
const submitQuestion = async () => {
const trimmedQuestion = question.trim();
@@ -193,8 +195,7 @@ function PostQaPanelInner({
throw new Error(`qa request failed: ${response.status}`);
const body = (await response.json()) as PostQaResult;
if (requestSeq.current !== currentRequestSeq) return;
- resultSlug.current = slug;
- setResult(body);
+ setResultState({ slug, result: body });
setQuestion("");
const nextLog = [
From dc5a8740b366672bc1b044d0e4765872aa7f052c Mon Sep 17 00:00:00 2001
From: seoJing
Date: Tue, 9 Jun 2026 01:01:35 +0900
Subject: [PATCH 6/7] fix: restore presentation fullscreen slicing
---
.../widgets/presentation/PresentationView.tsx | 244 ++++--------------
1 file changed, 57 insertions(+), 187 deletions(-)
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 }}
+ />
+
{/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}