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\n
const answer = 42
\n\n## 코드가 아닌 문장\n\n태그 비슷한 텍스트\n\n## 코드가 아닌 속성\n\n
문장
`, + }); + + expect(manifest.scenes.map((scene) => scene.pptx.layoutHint)).toEqual([ + "code", + "section", + "section", + ]); + }); }); diff --git a/apps/web/src/shared/presentation/presentation-export.ts b/apps/web/src/shared/presentation/presentation-export.ts 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 }} + /> +
    {/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}
    - )} {pcScale.toFixed(1)} {/* 표시 영역 조절 (미러링 등으로 화면 잘릴 때 사용) */} - + {Math.round( @@ -611,7 +481,7 @@ export function PresentationView({