From bfba7167dff1764f6bd0e609bad7fd54fb152ad8 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:38:57 +0200 Subject: [PATCH] feat(export): EPUB toc.ncx + inline images + rendered HTML preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepens the export suite (audit P0). The EPUB generator was already proper EPUB 3.0; this closes the real remaining gaps: - Add toc.ncx (EPUB-2 navigation) alongside nav.xhtml, register it in the manifest and set spine toc="ncx" — restores wide e-reader compatibility. - Bundle inline data-URL markdown images (![alt](data:...)) into OEBPS/images/, rewrite to , add manifest entries; remote URLs degrade to alt text (EPUB cannot bundle remote resources). Applied across chapters + front/back matter via a shared, deduping image registrar. - Refactor TOC collection to a structured list driving BOTH nav.xhtml and the NCX. - Export view: add a Text/Rendered preview toggle. "Rendered" shows the compiled markdown as sanitized HTML (new services/exportPreviewMarkdown.ts, DOMPurify strict allowlist) styled to match output via scoped .export-rendered-preview CSS. i18n: 3 new export.preview.* keys translated across all 19 locales + bundles. Tests: content-capturing JSZip mock asserts ncx/spine/manifest + image bundling + remote-alt fallback; renderer unit tests incl. XSS sanitization; ExportView toggle test. Co-Authored-By: Claude Opus 4.8 --- components/ExportView.tsx | 71 ++++- graphify-out/GRAPH_REPORT.md | 385 +++++++++++------------ index.css | 40 +++ locales/ar/export.json | 3 + locales/de/export.json | 3 + locales/el/export.json | 3 + locales/en/export.json | 3 + locales/es/export.json | 3 + locales/eu/export.json | 3 + locales/fa/export.json | 3 + locales/fi/export.json | 3 + locales/fr/export.json | 3 + locales/he/export.json | 3 + locales/hu/export.json | 3 + locales/is/export.json | 3 + locales/it/export.json | 3 + locales/ja/export.json | 3 + locales/ko/export.json | 3 + locales/pt/export.json | 3 + locales/ru/export.json | 3 + locales/sv/export.json | 3 + locales/zh/export.json | 3 + public/locales/ar/bundle.json | 3 + public/locales/de/bundle.json | 3 + public/locales/el/bundle.json | 3 + public/locales/en/bundle.json | 3 + public/locales/es/bundle.json | 3 + public/locales/eu/bundle.json | 3 + public/locales/fa/bundle.json | 3 + public/locales/fi/bundle.json | 3 + public/locales/fr/bundle.json | 3 + public/locales/he/bundle.json | 3 + public/locales/hu/bundle.json | 3 + public/locales/is/bundle.json | 3 + public/locales/it/bundle.json | 3 + public/locales/ja/bundle.json | 3 + public/locales/ko/bundle.json | 3 + public/locales/pt/bundle.json | 3 + public/locales/ru/bundle.json | 3 + public/locales/sv/bundle.json | 3 + public/locales/zh/bundle.json | 3 + services/epubApiService.ts | 124 ++++++-- services/exportPreviewMarkdown.ts | 79 +++++ tests/unit/ExportView.test.tsx | 10 + tests/unit/epubApiService.test.ts | 127 +++++--- tests/unit/exportPreviewMarkdown.test.ts | 43 +++ 46 files changed, 716 insertions(+), 277 deletions(-) create mode 100644 services/exportPreviewMarkdown.ts create mode 100644 tests/unit/exportPreviewMarkdown.test.ts diff --git a/components/ExportView.tsx b/components/ExportView.tsx index 9ace3abc1..59f4e7450 100644 --- a/components/ExportView.tsx +++ b/components/ExportView.tsx @@ -7,6 +7,7 @@ import { ExportViewContext, useExportViewContext } from '../contexts/ExportViewC import { selectEnableCompileWizard } from '../features/featureFlags/featureFlagsSlice'; import { projectActions } from '../features/project/projectSlice'; import { useExportView } from '../hooks/useExportView'; +import { renderExportMarkdownToHtml } from '../services/exportPreviewMarkdown'; import { AdvancedImportExport } from './AdvancedImportExport'; import { CompileWizardModal } from './CompileWizardModal'; import { Button } from './ui/Button'; @@ -445,9 +446,29 @@ const ExportControls: FC = () => { ); }; +// QNBS-v3: renders the compiled markdown as sanitized HTML so the preview matches real output. +const RenderedPreview: FC<{ markdown: string; style: React.CSSProperties }> = ({ + markdown, + style, +}) => { + const ref = React.useRef(null); + React.useEffect(() => { + if (ref.current) ref.current.innerHTML = renderExportMarkdownToHtml(markdown); + }, [markdown]); + return ( +
+ ); +}; + const ExportPreview: FC = () => { const { t, formattedOutput } = useExportViewContext(); const settings = useAppSelector((state) => state.settings); + const [mode, setMode] = React.useState<'text' | 'rendered'>('text'); const fontMap: Record = { serif: 'serif', @@ -464,23 +485,49 @@ const ExportPreview: FC = () => { return ( -
- -

- {t('export.preview.title')} -

+
+
+ +

+ {t('export.preview.title')} +

+
+ {/* QNBS-v3: Text vs Rendered preview toggle */} +
+ {t('export.preview.modeLabel')} + + +
{/* QNBS-v3: data-testid disambiguates this preview
 from ConsistencyChecker/CriticView 
 elements */}
         {formattedOutput ? (
-          
-            {formattedOutput}
-          
+ mode === 'rendered' ? ( + + ) : ( +
+              {formattedOutput}
+            
+ ) ) : (
diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index 153c9a896..16b4344c6 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,12 +1,12 @@ # Graph Report - StoryCraft-Studio (2026-06-23) ## Corpus Check -- 1153 files · ~1,301,323 words +- 1155 files · ~1,302,334 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 4999 nodes · 8780 edges · 87 communities detected -- Extraction: 76% EXTRACTED · 24% INFERRED · 0% AMBIGUOUS · INFERRED: 2146 edges (avg confidence: 0.8) +- 5009 nodes · 8800 edges · 84 communities detected +- Extraction: 76% EXTRACTED · 24% INFERRED · 0% AMBIGUOUS · INFERRED: 2154 edges (avg confidence: 0.8) - Token cost: 0 input · 0 output ## Community Hubs (Navigation) @@ -39,39 +39,39 @@ - [[_COMMUNITY_Community 26|Community 26]] - [[_COMMUNITY_Community 27|Community 27]] - [[_COMMUNITY_Community 28|Community 28]] -- [[_COMMUNITY_Community 29|Community 29]] -- [[_COMMUNITY_Community 30|Community 30]] -- [[_COMMUNITY_Community 33|Community 33]] +- [[_COMMUNITY_Community 31|Community 31]] +- [[_COMMUNITY_Community 32|Community 32]] - [[_COMMUNITY_Community 34|Community 34]] -- [[_COMMUNITY_Community 35|Community 35]] - [[_COMMUNITY_Community 36|Community 36]] +- [[_COMMUNITY_Community 37|Community 37]] - [[_COMMUNITY_Community 38|Community 38]] -- [[_COMMUNITY_Community 40|Community 40]] - [[_COMMUNITY_Community 41|Community 41]] -- [[_COMMUNITY_Community 42|Community 42]] -- [[_COMMUNITY_Community 45|Community 45]] +- [[_COMMUNITY_Community 43|Community 43]] +- [[_COMMUNITY_Community 44|Community 44]] - [[_COMMUNITY_Community 47|Community 47]] -- [[_COMMUNITY_Community 48|Community 48]] -- [[_COMMUNITY_Community 51|Community 51]] -- [[_COMMUNITY_Community 54|Community 54]] +- [[_COMMUNITY_Community 50|Community 50]] +- [[_COMMUNITY_Community 55|Community 55]] +- [[_COMMUNITY_Community 58|Community 58]] - [[_COMMUNITY_Community 61|Community 61]] +- [[_COMMUNITY_Community 62|Community 62]] - [[_COMMUNITY_Community 63|Community 63]] -- [[_COMMUNITY_Community 65|Community 65]] -- [[_COMMUNITY_Community 66|Community 66]] - [[_COMMUNITY_Community 67|Community 67]] -- [[_COMMUNITY_Community 68|Community 68]] -- [[_COMMUNITY_Community 73|Community 73]] -- [[_COMMUNITY_Community 78|Community 78]] +- [[_COMMUNITY_Community 69|Community 69]] +- [[_COMMUNITY_Community 70|Community 70]] +- [[_COMMUNITY_Community 75|Community 75]] +- [[_COMMUNITY_Community 81|Community 81]] - [[_COMMUNITY_Community 84|Community 84]] -- [[_COMMUNITY_Community 87|Community 87]] -- [[_COMMUNITY_Community 98|Community 98]] -- [[_COMMUNITY_Community 131|Community 131]] -- [[_COMMUNITY_Community 143|Community 143]] -- [[_COMMUNITY_Community 149|Community 149]] -- [[_COMMUNITY_Community 182|Community 182]] -- [[_COMMUNITY_Community 230|Community 230]] -- [[_COMMUNITY_Community 270|Community 270]] -- [[_COMMUNITY_Community 275|Community 275]] +- [[_COMMUNITY_Community 95|Community 95]] +- [[_COMMUNITY_Community 128|Community 128]] +- [[_COMMUNITY_Community 139|Community 139]] +- [[_COMMUNITY_Community 145|Community 145]] +- [[_COMMUNITY_Community 178|Community 178]] +- [[_COMMUNITY_Community 226|Community 226]] +- [[_COMMUNITY_Community 266|Community 266]] +- [[_COMMUNITY_Community 271|Community 271]] +- [[_COMMUNITY_Community 740|Community 740]] +- [[_COMMUNITY_Community 741|Community 741]] +- [[_COMMUNITY_Community 742|Community 742]] - [[_COMMUNITY_Community 743|Community 743]] - [[_COMMUNITY_Community 744|Community 744]] - [[_COMMUNITY_Community 745|Community 745]] @@ -94,14 +94,11 @@ - [[_COMMUNITY_Community 762|Community 762]] - [[_COMMUNITY_Community 763|Community 763]] - [[_COMMUNITY_Community 764|Community 764]] -- [[_COMMUNITY_Community 765|Community 765]] -- [[_COMMUNITY_Community 766|Community 766]] -- [[_COMMUNITY_Community 767|Community 767]] ## God Nodes (most connected - your core abstractions) 1. `mt()` - 104 edges 2. `Bv` - 74 edges -3. `fn()` - 62 edges +3. `fn()` - 63 edges 4. `t()` - 51 edges 5. `Ze()` - 43 edges 6. `wx()` - 41 edges @@ -111,6 +108,8 @@ 10. `tA()` - 34 edges ## Surprising Connections (you probably didn't know these) +- `App()` --calls--> `useApp()` [INFERRED] + App.tsx → hooks/useApp.ts - `useTranslation()` --calls--> `IdbUnlockModal()` [INFERRED] hooks/useTranslation.ts → components/settings/IdbUnlockModal.tsx - `getItem()` --calls--> `readMode()` [INFERRED] @@ -119,466 +118,448 @@ features/featureFlags/featureFlagsStorage.ts → components/copilot/CopilotPanel.tsx - `setItem()` --calls--> `enableDebugLogging()` [INFERRED] features/featureFlags/featureFlagsStorage.ts → services/logger.ts -- `removeItem()` --calls--> `disableDebugLogging()` [INFERRED] - features/featureFlags/featureFlagsStorage.ts → services/logger.ts ## Communities ### Community 0 - "Community 0" Cohesion: 0.01 -Nodes (322): md(), _0, _2(), A0, a2(), aA(), ab(), ac() (+314 more) +Nodes (308): flushMicrotasks(), _0, _2(), A0, a2(), aA(), ac(), ad() (+300 more) ### Community 1 - "Community 1" Cohesion: 0.01 -Nodes (117): recordLatency(), _clearPendingRequestsForTest(), createCancellationToken(), buildConsistencyHints(), buildEntityId(), buildRelationshipEdges(), createStoryCodexEntity(), escapeRegExpLiteral() (+109 more) +Nodes (145): recordLatency(), AiInferenceCacheService, hashKey(), _clearPendingRequestsForTest(), binderDepth(), createCancellationToken(), CloudSyncClient, buildConsistencyHints() (+137 more) ### Community 2 - "Community 2" Cohesion: 0.01 -Nodes (177): af(), ef(), ff(), Ja(), lf(), mt(), nf(), of() (+169 more) +Nodes (171): af(), ef(), ff(), Ja(), lf(), mt(), nf(), of() (+163 more) ### Community 3 - "Community 3" Cohesion: 0.01 -Nodes (108): item(), glossaryTranslate(), loadCheckpoint(), loadGlossary(), main(), maskPlaceholders(), parseArgs(), restorePlaceholders() (+100 more) +Nodes (150): getActiveAiMode(), getLocalFallbackModel(), getOpenRouterFallbackProvider(), getOpenRouterModel(), isCloudOnlyMode(), isOffline(), notifyLocalModelsReady(), shouldRouteLocally() (+142 more) ### Community 4 - "Community 4" Cohesion: 0.01 -Nodes (82): handleCopyForNotion(), handleDocxImport(), handleExport(), handlePasteImport(), loadAgent(), analyticsPersistenceAllowedNow(), isAnalyticsPersistenceAllowed(), setRetryFeedback() (+74 more) +Nodes (103): handleCopyForNotion(), handleDocxImport(), handleExport(), handlePasteImport(), loadAgent(), handleBuildLocalRag(), handleWebllmDownload(), isCustomOllamaModel() (+95 more) ### Community 5 - "Community 5" Cohesion: 0.02 -Nodes (59): isEcoMode(), flushMicrotasks(), decrypt(), decryptJson(), encrypt(), encryptJson(), generateMessageId(), getWorker() (+51 more) +Nodes (59): pipeline(), pipeline(), isEcoMode(), EcoModeService, FeedbackService, handleEcoToggle(), routeTask(), KokoroTtsEngine (+51 more) ### Community 6 - "Community 6" Cohesion: 0.02 -Nodes (73): AiInferenceCacheService, hashKey(), _cleanupPendingRequest(), handleRemoveKey(), handleSaveKey(), handleTestConnection(), binderDepth(), CloudSyncBackend (+65 more) +Nodes (69): accessibilityPresetDefaults(), normalizeAccessibilitySettings(), applyPreset(), handleRemoveKey(), handleSaveKey(), handleTestConnection(), CloudSyncBackend, decryptCloudPayload() (+61 more) ### Community 7 - "Community 7" Cohesion: 0.02 -Nodes (24): a_(), bh, Dh(), eA(), el(), GE(), Gh(), lv() (+16 more) +Nodes (47): AudioNavigator, md(), ab(), av(), br(), bs(), Bv, CA() (+39 more) ### Community 8 - "Community 8" -Cohesion: 0.01 -Nodes (78): categoryFromMessage(), categoryFromStatus(), classificationFor(), classifyAiError(), extractStatus(), getAiErrorMessage(), isOffline(), clampRetryAfter() (+70 more) +Cohesion: 0.03 +Nodes (18): a_(), bh, Dh(), eA(), el(), GE(), Gh(), lv() (+10 more) ### Community 9 - "Community 9" -Cohesion: 0.02 -Nodes (85): handleBuildLocalRag(), handleWebllmDownload(), isCustomOllamaModel(), countWords(), enrichProjectIndex(), extractCharacterNames(), getDb(), indexProject() (+77 more) +Cohesion: 0.01 +Nodes (81): categoryFromMessage(), categoryFromStatus(), classificationFor(), classifyAiError(), extractStatus(), getAiErrorMessage(), isOffline(), clampRetryAfter() (+73 more) ### Community 10 - "Community 10" Cohesion: 0.02 -Nodes (64): AnalyticsBootstrap(), App(), ViewLoader(), BookPreviewView(), useCommandExecutor(), CopilotLauncher(), Header(), useAppDispatch() (+56 more) +Nodes (78): AiModeIndicator(), item(), getLocalUser(), getRandomColor(), handleKeyDown(), sanitizeRoomInput(), stripControlChars(), loadFeatureFlagsState() (+70 more) ### Community 11 - "Community 11" -Cohesion: 0.03 -Nodes (80): AiModeIndicator(), assertCloudAiAllowed(), assertCloudAiAllowedSync(), assertLoraLocalOnly(), _deduplicateRequest(), generateTextSingleProvider(), isAbortError(), _pendingKey() (+72 more) +Cohesion: 0.02 +Nodes (64): AnalyticsBootstrap(), App(), ViewLoader(), BookPreviewView(), useCommandExecutor(), CopilotLauncher(), Header(), useAppDispatch() (+56 more) ### Community 12 - "Community 12" -Cohesion: 0.03 -Nodes (69): collectSubtreeIds(), installDesktopMenu(), installCloseToTray(), installDesktopTray(), buildTimeoutSignal(), createWorldScriptFetch(), resolveTauriFetch(), routeTask() (+61 more) +Cohesion: 0.02 +Nodes (58): AdaptiveAiEngine, _clearLatencyHistory(), estimateLatency(), getTaskConfig(), selectModelForBackend(), start(), clearBenchmarkResults(), getLastBenchmarkResults() (+50 more) ### Community 13 - "Community 13" -Cohesion: 0.04 -Nodes (53): getActiveAiMode(), getLocalFallbackModel(), getOpenRouterFallbackProvider(), getOpenRouterModel(), isCloudOnlyMode(), isOffline(), notifyLocalModelsReady(), shouldRouteLocally() (+45 more) +Cohesion: 0.03 +Nodes (76): countWords(), enrichProjectIndex(), extractCharacterNames(), getDb(), indexProject(), listIndexedProjects(), removeProjectIndex(), semanticSearchProjects() (+68 more) ### Community 14 - "Community 14" Cohesion: 0.03 -Nodes (51): AdaptiveAiEngine, _clearLatencyHistory(), estimateLatency(), getTaskConfig(), selectModelForBackend(), pipeline(), pipeline(), start() (+43 more) +Nodes (43): CollabEncryptionRequiredError, CollaborationService, resolveWebRtcSignalingUrls(), MockDoc, MockWebrtcProvider, createAttentionPipeline(), createComputePipeline(), createKvCachePipeline() (+35 more) ### Community 15 - "Community 15" Cohesion: 0.04 -Nodes (24): assertNoSeriousViolations(), navigateToCollaborationSettings(), connectSrcTokens(), group1(), tauriCsp(), webCsp(), Bv, jb() (+16 more) +Nodes (53): applyTextEdit(), applyReviewEditsToSection(), containsDisallowedControlChar(), isValidRange(), nearestFreeOccurrence(), planAcceptedManuscriptEdits(), validateProposedText(), collectSubtreeIds() (+45 more) ### Community 16 - "Community 16" -Cohesion: 0.09 -Nodes (16): FsAssetStore, FsCodexStore, countProjectWords(), decompressData(), decryptText(), deriveFileSystemCryptoKey(), encryptText(), FsCore (+8 more) +Cohesion: 0.07 +Nodes (22): FsAssetStore, FsCodexStore, deleteIdb(), formatStorageError(), initializeStorage(), resetAllDatabases(), countProjectWords(), decompressData() (+14 more) ### Community 17 - "Community 17" Cohesion: 0.04 -Nodes (35): AudioNavigator, buildEncodedPayload(), makeCommands(), makeProjectData(), Hb(), makeContext(), makeLargeContext(), makeSection() (+27 more) +Nodes (32): assertNoSeriousViolations(), navigateToCollaborationSettings(), connectSrcTokens(), group1(), tauriCsp(), webCsp(), clickNavItem(), ensureBlankProject() (+24 more) ### Community 18 - "Community 18" Cohesion: 0.05 -Nodes (22): CollabEncryptionRequiredError, CollaborationService, resolveWebRtcSignalingUrls(), MockDoc, MockWebrtcProvider, createAttentionPipeline(), createComputePipeline(), createKvCachePipeline() (+14 more) +Nodes (15): installDesktopMenu(), installCloseToTray(), installDesktopTray(), buildTimeoutSignal(), createWorldScriptFetch(), resolveTauriFetch(), StorageManager, registerTauriMenuHandler() (+7 more) ### Community 19 - "Community 19" -Cohesion: 0.06 -Nodes (35): collect(), buildPaletteCommandModels(), collectAllDefinitions(), resolveTitle(), runCommandById(), id, install_app_menu(), run() (+27 more) +Cohesion: 0.07 +Nodes (9): getFocusable(), onKeyDown(), onPointerUp(), k2, n2(), getFocusable(), handleEsc(), handleTabKey() (+1 more) ### Community 20 - "Community 20" -Cohesion: 0.06 -Nodes (23): applyTextEdit(), applyReviewEditsToSection(), containsDisallowedControlChar(), isValidRange(), nearestFreeOccurrence(), planAcceptedManuscriptEdits(), validateProposedText(), mockT() (+15 more) +Cohesion: 0.07 +Nodes (26): applyPreset(), async(), close(), isSidebar(), onKey(), onPointerDown(), readMode(), writeMode() (+18 more) ### Community 21 - "Community 21" Cohesion: 0.07 -Nodes (9): getFocusable(), onKeyDown(), onPointerUp(), k2, n2(), getFocusable(), handleEsc(), handleTabKey() (+1 more) +Nodes (13): createBrowserProForgeCapability(), buildPorts(), runCopilotDiagnostic(), buildNormManuscriptExport(), paginateNormLines(), stripLightMarkdown(), wrapParagraphToLines(), wrapPlainTextToNormLines() (+5 more) ### Community 22 - "Community 22" -Cohesion: 0.06 -Nodes (29): applyPreset(), async(), close(), isSidebar(), onKey(), onPointerDown(), readMode(), writeMode() (+21 more) +Cohesion: 0.07 +Nodes (16): smallProject(), buildCharacter(), buildLargeManuscript(), buildParagraph(), buildSectionContent(), buildWorld(), countWords(), makeRng() (+8 more) ### Community 23 - "Community 23" -Cohesion: 0.07 -Nodes (13): createBrowserProForgeCapability(), buildPorts(), runCopilotDiagnostic(), buildNormManuscriptExport(), paginateNormLines(), stripLightMarkdown(), wrapParagraphToLines(), wrapPlainTextToNormLines() (+5 more) +Cohesion: 0.09 +Nodes (21): collect(), DeadLetterQueue, openDlqDb(), storeClear(), storeGetAll(), analyze_text(), count_sentences(), count_syllables() (+13 more) ### Community 24 - "Community 24" -Cohesion: 0.11 -Nodes (1): StorageManager - -### Community 25 - "Community 25" -Cohesion: 0.07 -Nodes (16): smallProject(), buildCharacter(), buildLargeManuscript(), buildParagraph(), buildSectionContent(), buildWorld(), countWords(), makeRng() (+8 more) - -### Community 26 - "Community 26" Cohesion: 0.23 Nodes (3): LS, xn(), aa -### Community 27 - "Community 27" -Cohesion: 0.1 -Nodes (11): handleEvaluate(), ScoreGauge(), comparePromptOutputs(), computeStyleConsistencyScore(), cosineSimilarity(), getEmbeddingService(), meanSimilarity(), scoreLabel() (+3 more) - -### Community 28 - "Community 28" +### Community 25 - "Community 25" Cohesion: 0.14 Nodes (21): handleToggle(), handleDelete(), handleFileChange(), activateAdapter(), clearDatasetEntries(), deactivateAdapter(), deleteAdapter(), exportAdapter() (+13 more) -### Community 29 - "Community 29" -Cohesion: 0.16 +### Community 26 - "Community 26" +Cohesion: 0.15 Nodes (14): buildExcerpt(), extractCharacters(), extractManuscriptSections(), searchAcrossProjectIndex(), searchAcrossProjects(), normalizeSearch(), scoreAgainstQuery(), subsequenceScore() (+6 more) -### Community 30 - "Community 30" +### Community 27 - "Community 27" Cohesion: 0.35 Nodes (2): cc, Gb() -### Community 33 - "Community 33" -Cohesion: 0.29 -Nodes (1): PriorityTaskQueue - -### Community 34 - "Community 34" -Cohesion: 0.22 -Nodes (4): accessibilityPresetDefaults(), normalizeAccessibilitySettings(), applyPreset(), baseSettings() +### Community 28 - "Community 28" +Cohesion: 0.17 +Nodes (8): check(), extractCatalogFlags(), extractHiddenFlags(), extractSectionFlags(), green(), grep(), hasRuntimeConsumption(), red() -### Community 35 - "Community 35" +### Community 31 - "Community 31" Cohesion: 0.42 Nodes (6): emit(), main(), merge(), ProgressCallback, Emits JSON progress events on each training log step., train() -### Community 36 - "Community 36" +### Community 32 - "Community 32" Cohesion: 0.25 Nodes (3): useManuscriptLayout(), useMediaQuery(), useResizablePanels() -### Community 38 - "Community 38" +### Community 34 - "Community 34" Cohesion: 0.29 Nodes (5): MockAudioContext, MockBufferSource, MockGain, NonEndingSource, TrackingContext -### Community 40 - "Community 40" +### Community 36 - "Community 36" Cohesion: 0.33 Nodes (3): useSwipeGesture(), useWriterLayout(), useWriterViewContext() -### Community 41 - "Community 41" +### Community 37 - "Community 37" Cohesion: 0.53 Nodes (4): buildWebNNExecutionProviders(), detectWebNN(), isDirectMLAvailable(), isDirectMLHeuristic() -### Community 42 - "Community 42" +### Community 38 - "Community 38" Cohesion: 0.7 Nodes (4): check_cuda_and_vram(), check_package(), check_python_version(), main() -### Community 45 - "Community 45" +### Community 41 - "Community 41" Cohesion: 0.5 Nodes (3): createStorageMock(), setupStorage(), SpeechSynthesisUtteranceMock -### Community 47 - "Community 47" +### Community 43 - "Community 43" Cohesion: 0.4 Nodes (4): Room, SignalingConn, WebrtcConn, WebrtcProvider -### Community 48 - "Community 48" +### Community 44 - "Community 44" Cohesion: 0.4 Nodes (2): useDashboardContext(), DashboardHeader() -### Community 51 - "Community 51" +### Community 47 - "Community 47" Cohesion: 0.6 Nodes (4): applyFormula(), computeReadabilitySnapshot(), estimateSyllables(), getSyllablePattern() -### Community 54 - "Community 54" +### Community 50 - "Community 50" Cohesion: 0.67 Nodes (2): makeConfig(), startPipelinePayload() -### Community 61 - "Community 61" +### Community 55 - "Community 55" Cohesion: 0.67 -Nodes (2): defaultProject(), setProjectData() +Nodes (2): make(), noop() -### Community 63 - "Community 63" +### Community 58 - "Community 58" Cohesion: 0.67 -Nodes (2): make(), noop() +Nodes (2): defaultProject(), setProjectData() -### Community 65 - "Community 65" +### Community 61 - "Community 61" Cohesion: 0.83 Nodes (3): makeChars(), makeProject(), makeWorlds() -### Community 66 - "Community 66" +### Community 62 - "Community 62" Cohesion: 0.83 Nodes (3): emptyChars(), emptyWorlds(), makeProject() -### Community 67 - "Community 67" +### Community 63 - "Community 63" Cohesion: 0.5 -Nodes (2): ManuscriptDesktopLayout(), useManuscriptViewContext() +Nodes (3): AsyncDuckDB, AsyncDuckDBConnection, ConsoleLogger -### Community 68 - "Community 68" +### Community 67 - "Community 67" Cohesion: 0.5 -Nodes (3): AsyncDuckDB, AsyncDuckDBConnection, ConsoleLogger +Nodes (2): ManuscriptDesktopLayout(), useManuscriptViewContext() -### Community 73 - "Community 73" +### Community 69 - "Community 69" Cohesion: 0.67 Nodes (2): getQuestionsForArchetype(), getTemplateForArchetype() -### Community 78 - "Community 78" +### Community 70 - "Community 70" +Cohesion: 0.83 +Nodes (3): esc(), inline(), renderExportMarkdownToHtml() + +### Community 75 - "Community 75" Cohesion: 0.67 Nodes (1): makeSection() -### Community 84 - "Community 84" +### Community 81 - "Community 81" Cohesion: 0.67 Nodes (1): MockGoogleGenAI -### Community 87 - "Community 87" +### Community 84 - "Community 84" Cohesion: 0.67 Nodes (1): makeDeps() -### Community 98 - "Community 98" +### Community 95 - "Community 95" Cohesion: 0.67 Nodes (1): TaskError -### Community 131 - "Community 131" +### Community 128 - "Community 128" Cohesion: 1.0 Nodes (1): MockIntersectionObserver -### Community 143 - "Community 143" +### Community 139 - "Community 139" Cohesion: 1.0 Nodes (1): MockWorker -### Community 149 - "Community 149" +### Community 145 - "Community 145" Cohesion: 1.0 Nodes (1): MockBroadcastChannel -### Community 182 - "Community 182" +### Community 178 - "Community 178" Cohesion: 1.0 Nodes (1): MockIntersectionObserver -### Community 230 - "Community 230" +### Community 226 - "Community 226" Cohesion: 1.0 Nodes (1): MockWorker -### Community 270 - "Community 270" +### Community 266 - "Community 266" Cohesion: 1.0 Nodes (1): FileSystemService -### Community 275 - "Community 275" +### Community 271 - "Community 271" Cohesion: 1.0 Nodes (1): IndexedDBService -### Community 743 - "Community 743" +### Community 740 - "Community 740" Cohesion: 1.0 Nodes (1): Remove ANSI escape codes from text. -### Community 744 - "Community 744" +### Community 741 - "Community 741" Cohesion: 1.0 Nodes (1): Remove timestamp strings from text. -### Community 745 - "Community 745" +### Community 742 - "Community 742" Cohesion: 1.0 Nodes (1): Replace long base64 strings with placeholder. -### Community 746 - "Community 746" +### Community 743 - "Community 743" Cohesion: 1.0 Nodes (1): Remove NPM/pnpm warning lines. -### Community 747 - "Community 747" +### Community 744 - "Community 744" Cohesion: 1.0 Nodes (1): Remove redundant success messages. -### Community 748 - "Community 748" +### Community 745 - "Community 745" Cohesion: 1.0 Nodes (1): Apply all preprocessing steps to reduce token payload. -### Community 749 - "Community 749" +### Community 746 - "Community 746" Cohesion: 1.0 Nodes (1): Extract only error-related sections from log. -### Community 750 - "Community 750" +### Community 747 - "Community 747" Cohesion: 1.0 Nodes (1): Pydantic models for CI Analyzer structured output. QNBS-v3: These models enforce -### Community 751 - "Community 751" +### Community 748 - "Community 748" Cohesion: 1.0 Nodes (1): Structured CI error for VS Code problem matcher integration. -### Community 752 - "Community 752" +### Community 749 - "Community 749" Cohesion: 1.0 Nodes (1): Vitest JSON test result structure. -### Community 753 - "Community 753" +### Community 750 - "Community 750" Cohesion: 1.0 Nodes (1): Full Vitest JSON report structure. -### Community 754 - "Community 754" +### Community 751 - "Community 751" Cohesion: 1.0 Nodes (1): Stryker per-file mutation report. -### Community 755 - "Community 755" +### Community 752 - "Community 752" Cohesion: 1.0 Nodes (1): Full Stryker JSON report structure. -### Community 756 - "Community 756" +### Community 753 - "Community 753" Cohesion: 1.0 Nodes (1): Initialize OpenRouter client for Poolside Laguna model. -### Community 757 - "Community 757" +### Community 754 - "Community 754" Cohesion: 1.0 Nodes (1): Analyze Vitest JSON report and raw logs for errors. -### Community 758 - "Community 758" +### Community 755 - "Community 755" Cohesion: 1.0 Nodes (1): Analyze Stryker JSON report for surviving mutants. -### Community 759 - "Community 759" +### Community 756 - "Community 756" Cohesion: 1.0 Nodes (1): Send preprocessed errors to LLM for analysis. -### Community 760 - "Community 760" +### Community 757 - "Community 757" Cohesion: 1.0 Nodes (1): Format errors for VS Code problem matcher. -### Community 761 - "Community 761" +### Community 758 - "Community 758" Cohesion: 1.0 Nodes (1): Main entry point for CI analyzer. -### Community 762 - "Community 762" +### Community 759 - "Community 759" Cohesion: 1.0 Nodes (1): Execute gh CLI command and return parsed JSON output. -### Community 763 - "Community 763" +### Community 760 - "Community 760" Cohesion: 1.0 Nodes (1): Get the ID of the most recent failed CI run. -### Community 764 - "Community 764" +### Community 761 - "Community 761" Cohesion: 1.0 Nodes (1): Download a specific artifact from a workflow run. -### Community 765 - "Community 765" +### Community 762 - "Community 762" Cohesion: 1.0 Nodes (1): Get raw logs from a failed workflow run. -### Community 766 - "Community 766" +### Community 763 - "Community 763" Cohesion: 1.0 Nodes (1): Parse Vitest JSON report for failing tests. -### Community 767 - "Community 767" +### Community 764 - "Community 764" Cohesion: 1.0 Nodes (1): Parse Stryker JSON report for surviving mutants. ## Knowledge Gaps - **53 isolated node(s):** `Emits JSON progress events on each training log step.`, `qb`, `v2`, `MockIntersectionObserver`, `MockWorker` (+48 more) These have ≤1 connection - possible missing edges or undocumented components. -- **Thin community `Community 24`** (36 nodes): `storageService.ts`, `StorageManager`, `.clearApiKey()`, `.clearGeminiApiKey()`, `.constructor()`, `.deleteAllBinderAssetsForProject()`, `.deleteBinderAsset()`, `.deleteImage()`, `.deleteProject()`, `.deleteRagVectors()`, `.deleteSnapshot()`, `.deleteStoryCodex()`, `.getApiKey()`, `.getBackend()`, `.getBinderAsset()`, `.getGeminiApiKey()`, `.getImage()`, `.getRagVectors()`, `.getSnapshotData()`, `.getStoryCodex()`, `.hasSavedData()`, `.initializeBackend()`, `.listBinderAssetIds()`, `.listProjects()`, `.listSnapshots()`, `.loadProject()`, `.loadSettings()`, `.saveApiKey()`, `.saveBinderAsset()`, `.saveGeminiApiKey()`, `.saveImage()`, `.saveProject()`, `.saveRagVectors()`, `.saveSettings()`, `.saveSnapshot()`, `.saveStoryCodex()` - Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 30`** (17 nodes): `cc`, `._applyAttribute()`, `._assert()`, `.constructor()`, `._eof()`, `._isWhitespace()`, `._next()`, `.parse()`, `._peek()`, `._readAttributes()`, `._readIdentifier()`, `._readRegex()`, `._readString()`, `._readStringOrRegex()`, `._skipWhitespace()`, `._throwError()`, `Gb()` - Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 33`** (10 nodes): `taskQueue.ts`, `PriorityTaskQueue`, `.constructor()`, `.dequeue()`, `.effectivePriority()`, `.enqueue()`, `.peek()`, `.promoteStarvedTasks()`, `.stats()`, `.totalDepth()` +- **Thin community `Community 27`** (17 nodes): `cc`, `._applyAttribute()`, `._assert()`, `.constructor()`, `._eof()`, `._isWhitespace()`, `._next()`, `.parse()`, `._peek()`, `._readAttributes()`, `._readIdentifier()`, `._readRegex()`, `._readString()`, `._readStringOrRegex()`, `._skipWhitespace()`, `._throwError()`, `Gb()` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 48`** (5 nodes): `DashboardHeader.tsx`, `DashboardContext.ts`, `useDashboardContext()`, `Chip()`, `DashboardHeader()` +- **Thin community `Community 44`** (5 nodes): `DashboardHeader.tsx`, `DashboardContext.ts`, `useDashboardContext()`, `Chip()`, `DashboardHeader()` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 54`** (4 nodes): `makeConfig()`, `makeReviewItem()`, `startPipelinePayload()`, `proForgeSlice.test.ts` +- **Thin community `Community 50`** (4 nodes): `makeConfig()`, `makeReviewItem()`, `startPipelinePayload()`, `proForgeSlice.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 61`** (4 nodes): `useDashboard.test.ts`, `defaultProject()`, `defaultSection()`, `setProjectData()` +- **Thin community `Community 55`** (4 nodes): `make()`, `noop()`, `aiRetry.test.ts`, `aiRetry.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 63`** (4 nodes): `make()`, `noop()`, `aiRetry.test.ts`, `aiRetry.test.ts` +- **Thin community `Community 58`** (4 nodes): `useDashboard.test.ts`, `defaultProject()`, `defaultSection()`, `setProjectData()` Too small to be a meaningful cluster - may be noise or needs more connections extracted. - **Thin community `Community 67`** (4 nodes): `ManuscriptDesktopLayout.tsx`, `ManuscriptViewContext.ts`, `ManuscriptDesktopLayout()`, `useManuscriptViewContext()` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 73`** (4 nodes): `getAllTemplates()`, `getQuestionsForArchetype()`, `getTemplateForArchetype()`, `characterInterviewTemplates.ts` +- **Thin community `Community 69`** (4 nodes): `getAllTemplates()`, `getQuestionsForArchetype()`, `getTemplateForArchetype()`, `characterInterviewTemplates.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 78`** (3 nodes): `makeSection()`, `plotBoardService.test.ts`, `plotBoardService.test.ts` +- **Thin community `Community 75`** (3 nodes): `makeSection()`, `plotBoardService.test.ts`, `plotBoardService.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 84`** (3 nodes): `makeStream()`, `MockGoogleGenAI`, `geminiService.test.ts` +- **Thin community `Community 81`** (3 nodes): `makeStream()`, `MockGoogleGenAI`, `geminiService.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 87`** (3 nodes): `makeDeps()`, `aiSuggestions.test.ts`, `aiSuggestions.test.ts` +- **Thin community `Community 84`** (3 nodes): `makeDeps()`, `aiSuggestions.test.ts`, `aiSuggestions.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 98`** (3 nodes): `types.ts`, `TaskError`, `.constructor()` +- **Thin community `Community 95`** (3 nodes): `types.ts`, `TaskError`, `.constructor()` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 131`** (2 nodes): `MockIntersectionObserver`, `BookPreviewView.test.tsx` +- **Thin community `Community 128`** (2 nodes): `MockIntersectionObserver`, `BookPreviewView.test.tsx` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 143`** (2 nodes): `MockWorker`, `duckdbClient.test.ts` +- **Thin community `Community 139`** (2 nodes): `MockWorker`, `duckdbClient.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 149`** (2 nodes): `MockBroadcastChannel`, `tabLeaderElection.test.ts` +- **Thin community `Community 145`** (2 nodes): `MockBroadcastChannel`, `tabLeaderElection.test.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 182`** (2 nodes): `useBookPreviewView.test.ts`, `MockIntersectionObserver` +- **Thin community `Community 178`** (2 nodes): `useBookPreviewView.test.ts`, `MockIntersectionObserver` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 230`** (2 nodes): `workerPool.test.ts`, `MockWorker` +- **Thin community `Community 226`** (2 nodes): `workerPool.test.ts`, `MockWorker` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 270`** (2 nodes): `FileSystemService`, `index.ts` +- **Thin community `Community 266`** (2 nodes): `FileSystemService`, `index.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 275`** (2 nodes): `IndexedDBService`, `index.ts` +- **Thin community `Community 271`** (2 nodes): `IndexedDBService`, `index.ts` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 743`** (1 nodes): `Remove ANSI escape codes from text.` +- **Thin community `Community 740`** (1 nodes): `Remove ANSI escape codes from text.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 744`** (1 nodes): `Remove timestamp strings from text.` +- **Thin community `Community 741`** (1 nodes): `Remove timestamp strings from text.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 745`** (1 nodes): `Replace long base64 strings with placeholder.` +- **Thin community `Community 742`** (1 nodes): `Replace long base64 strings with placeholder.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 746`** (1 nodes): `Remove NPM/pnpm warning lines.` +- **Thin community `Community 743`** (1 nodes): `Remove NPM/pnpm warning lines.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 747`** (1 nodes): `Remove redundant success messages.` +- **Thin community `Community 744`** (1 nodes): `Remove redundant success messages.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 748`** (1 nodes): `Apply all preprocessing steps to reduce token payload.` +- **Thin community `Community 745`** (1 nodes): `Apply all preprocessing steps to reduce token payload.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 749`** (1 nodes): `Extract only error-related sections from log.` +- **Thin community `Community 746`** (1 nodes): `Extract only error-related sections from log.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 750`** (1 nodes): `Pydantic models for CI Analyzer structured output. QNBS-v3: These models enforce` +- **Thin community `Community 747`** (1 nodes): `Pydantic models for CI Analyzer structured output. QNBS-v3: These models enforce` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 751`** (1 nodes): `Structured CI error for VS Code problem matcher integration.` +- **Thin community `Community 748`** (1 nodes): `Structured CI error for VS Code problem matcher integration.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 752`** (1 nodes): `Vitest JSON test result structure.` +- **Thin community `Community 749`** (1 nodes): `Vitest JSON test result structure.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 753`** (1 nodes): `Full Vitest JSON report structure.` +- **Thin community `Community 750`** (1 nodes): `Full Vitest JSON report structure.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 754`** (1 nodes): `Stryker per-file mutation report.` +- **Thin community `Community 751`** (1 nodes): `Stryker per-file mutation report.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 755`** (1 nodes): `Full Stryker JSON report structure.` +- **Thin community `Community 752`** (1 nodes): `Full Stryker JSON report structure.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 756`** (1 nodes): `Initialize OpenRouter client for Poolside Laguna model.` +- **Thin community `Community 753`** (1 nodes): `Initialize OpenRouter client for Poolside Laguna model.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 757`** (1 nodes): `Analyze Vitest JSON report and raw logs for errors.` +- **Thin community `Community 754`** (1 nodes): `Analyze Vitest JSON report and raw logs for errors.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 758`** (1 nodes): `Analyze Stryker JSON report for surviving mutants.` +- **Thin community `Community 755`** (1 nodes): `Analyze Stryker JSON report for surviving mutants.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 759`** (1 nodes): `Send preprocessed errors to LLM for analysis.` +- **Thin community `Community 756`** (1 nodes): `Send preprocessed errors to LLM for analysis.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 760`** (1 nodes): `Format errors for VS Code problem matcher.` +- **Thin community `Community 757`** (1 nodes): `Format errors for VS Code problem matcher.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 761`** (1 nodes): `Main entry point for CI analyzer.` +- **Thin community `Community 758`** (1 nodes): `Main entry point for CI analyzer.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 762`** (1 nodes): `Execute gh CLI command and return parsed JSON output.` +- **Thin community `Community 759`** (1 nodes): `Execute gh CLI command and return parsed JSON output.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 763`** (1 nodes): `Get the ID of the most recent failed CI run.` +- **Thin community `Community 760`** (1 nodes): `Get the ID of the most recent failed CI run.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 764`** (1 nodes): `Download a specific artifact from a workflow run.` +- **Thin community `Community 761`** (1 nodes): `Download a specific artifact from a workflow run.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 765`** (1 nodes): `Get raw logs from a failed workflow run.` +- **Thin community `Community 762`** (1 nodes): `Get raw logs from a failed workflow run.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 766`** (1 nodes): `Parse Vitest JSON report for failing tests.` +- **Thin community `Community 763`** (1 nodes): `Parse Vitest JSON report for failing tests.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. -- **Thin community `Community 767`** (1 nodes): `Parse Stryker JSON report for surviving mutants.` +- **Thin community `Community 764`** (1 nodes): `Parse Stryker JSON report for surviving mutants.` Too small to be a meaningful cluster - may be noise or needs more connections extracted. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `mt()` connect `Community 2` to `Community 0`, `Community 1`, `Community 4`, `Community 5`, `Community 7`, `Community 13`, `Community 17`, `Community 20`, `Community 22`, `Community 23`, `Community 26`?** - _High betweenness centrality (0.084) - this node is a cross-community bridge._ -- **Why does `t()` connect `Community 4` to `Community 1`, `Community 2`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 19`, `Community 22`, `Community 28`?** - _High betweenness centrality (0.075) - this node is a cross-community bridge._ -- **Why does `fn()` connect `Community 8` to `Community 1`, `Community 5`, `Community 6`, `Community 13`, `Community 16`, `Community 17`, `Community 22`, `Community 23`?** - _High betweenness centrality (0.052) - this node is a cross-community bridge._ +- **Why does `mt()` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 12`, `Community 15`, `Community 20`, `Community 21`, `Community 24`?** + _High betweenness centrality (0.085) - this node is a cross-community bridge._ +- **Why does `t()` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`, `Community 6`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 18`, `Community 20`, `Community 25`?** + _High betweenness centrality (0.073) - this node is a cross-community bridge._ +- **Why does `wx()` connect `Community 0` to `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 14`, `Community 16`, `Community 20`?** + _High betweenness centrality (0.046) - this node is a cross-community bridge._ - **Are the 87 inferred relationships involving `mt()` (e.g. with `pE()` and `xE()`) actually correct?** _`mt()` has 87 INFERRED edges - model-reasoned connections that need verification._ -- **Are the 61 inferred relationships involving `fn()` (e.g. with `makeMediaQuery()` and `MockSpeechRecognition()`) actually correct?** - _`fn()` has 61 INFERRED edges - model-reasoned connections that need verification._ +- **Are the 62 inferred relationships involving `fn()` (e.g. with `makeMediaQuery()` and `MockSpeechRecognition()`) actually correct?** + _`fn()` has 62 INFERRED edges - model-reasoned connections that need verification._ - **Are the 50 inferred relationships involving `t()` (e.g. with `.flattenForSingleProject()` and `fr()`) actually correct?** _`t()` has 50 INFERRED edges - model-reasoned connections that need verification._ - **What connects `Emits JSON progress events on each training log step.`, `qb`, `v2` to the rest of the system?** diff --git a/index.css b/index.css index 7aa907467..9efcc76bf 100644 --- a/index.css +++ b/index.css @@ -1017,3 +1017,43 @@ body.is-desktop --glass-border: var(--sc-border-subtle); } } + +/* QNBS-v3 (PR2): scoped typography for the Export view's "Rendered" preview. DOMPurify strips + class/style from the sanitized markdown HTML, so these element selectors carry the styling. */ +.export-rendered-preview h1 { + font-size: 1.8em; + font-weight: 700; + text-align: center; + margin: 0.6em 0; +} +.export-rendered-preview h2 { + font-size: 1.4em; + font-weight: 700; + border-bottom: 1px solid var(--sc-border-subtle); + padding-bottom: 0.2em; + margin: 1em 0 0.4em; +} +.export-rendered-preview h3 { + font-size: 1.15em; + font-weight: 600; + margin: 0.8em 0 0.3em; +} +.export-rendered-preview p { + margin: 0.5em 0; + line-height: 1.7; +} +.export-rendered-preview ul { + margin: 0.5em 0; + padding-inline-start: 1.4em; + list-style: disc; +} +.export-rendered-preview img { + max-width: 100%; + height: auto; +} +.export-rendered-preview code { + font-family: monospace; + background: var(--sc-surface-overlay); + padding: 0 0.25em; + border-radius: 3px; +} diff --git a/locales/ar/export.json b/locales/ar/export.json index ff8098921..68087a86f 100644 --- a/locales/ar/export.json +++ b/locales/ar/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "الصق نصًا من Google Docs أو Notion هنا ‏(Ctrl+V)...", "export.pasteSection.titlePlaceholder": "عنوان الفصل (اختياري)", "export.preview.noContent": "اختر محتوى لرؤية معاينة.", + "export.preview.modeLabel": "وضع المعاينة", + "export.preview.modeText": "نص", + "export.preview.modeRendered": "معروض", "export.preview.title": "معاينة حيّة", "export.title": "جناح النشر والتصدير", "export.toggleSection": "تبديل ظهور {{title}}", diff --git a/locales/de/export.json b/locales/de/export.json index ebb892893..1ac696dd2 100644 --- a/locales/de/export.json +++ b/locales/de/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Text aus Google Docs oder Notion hier einfügen (Strg+V)…", "export.pasteSection.titlePlaceholder": "Kapiteltitel (optional)", "export.preview.noContent": "Wählen Sie Inhalte aus, um eine Vorschau zu sehen.", + "export.preview.modeLabel": "Vorschaumodus", + "export.preview.modeText": "Text", + "export.preview.modeRendered": "Gerendert", "export.preview.title": "Live-Vorschau", "export.title": "Publishing-Suite", "export.toggleSection": "Sichtbarkeit für {{title}} umschalten", diff --git a/locales/el/export.json b/locales/el/export.json index a75396a6c..b7ae67f0c 100644 --- a/locales/el/export.json +++ b/locales/el/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Επικολλήστε εδώ κείμενο από τα Έγγραφα Google ή το Notion (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Τίτλος κεφαλαίου (προαιρετικό)", "export.preview.noContent": "Επιλέξτε περιεχόμενο για να δείτε μια προεπισκόπηση.", + "export.preview.modeLabel": "Λειτουργία προεπισκόπησης", + "export.preview.modeText": "Κείμενο", + "export.preview.modeRendered": "Αποδοθέν", "export.preview.title": "Ζωντανή προεπισκόπηση", "export.title": "Εξαγωγή Publishing Suite", "export.toggleSection": "Εναλλαγή ορατότητας για {{title}}", diff --git a/locales/en/export.json b/locales/en/export.json index 7ef959105..63f244255 100644 --- a/locales/en/export.json +++ b/locales/en/export.json @@ -6,6 +6,9 @@ "export.options.copied": "Copied!", "export.preview.title": "Live Preview", "export.preview.noContent": "Select content to see a preview.", + "export.preview.modeLabel": "Preview mode", + "export.preview.modeText": "Text", + "export.preview.modeRendered": "Rendered", "export.loglineLabel": "Logline", "export.charactersLabel": "Characters", "export.appearanceLabel": "Appearance", diff --git a/locales/es/export.json b/locales/es/export.json index 9435050a1..73093b87f 100644 --- a/locales/es/export.json +++ b/locales/es/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Pega texto de Google Docs o Notion aquí (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Título del capítulo (opcional)", "export.preview.noContent": "Selecciona contenido para ver una vista previa.", + "export.preview.modeLabel": "Modo de vista previa", + "export.preview.modeText": "Texto", + "export.preview.modeRendered": "Renderizado", "export.preview.title": "Vista previa en vivo", "export.title": "Suite de publicación Export", "export.toggleSection": "Alternar visibilidad para {{title}}", diff --git a/locales/eu/export.json b/locales/eu/export.json index 50c7b80ad..92666ca28 100644 --- a/locales/eu/export.json +++ b/locales/eu/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Itsatsi Google Docs edo Notion-eko testua hemen (Ktrl+V)...", "export.pasteSection.titlePlaceholder": "Kapituluaren izenburua (aukerakoa)", "export.preview.noContent": "Hautatu edukia aurrebista ikusteko.", + "export.preview.modeLabel": "Aurrebista modua", + "export.preview.modeText": "Testua", + "export.preview.modeRendered": "Errendatua", "export.preview.title": "Zuzeneko aurrebista", "export.title": "Esportatu Argitalpen Suite", "export.toggleSection": "Aldatu ikusgaitasuna {{title}}", diff --git a/locales/fa/export.json b/locales/fa/export.json index 2a804c1c7..9dbd35b38 100644 --- a/locales/fa/export.json +++ b/locales/fa/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "متن را از Google Docs یا Notion در اینجا جای‌گذاری کنید (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "عنوان فصل (اختیاری)", "export.preview.noContent": "برای مشاهده پیش نمایش، محتوا را انتخاب کنید.", + "export.preview.modeLabel": "حالت پیش‌نمایش", + "export.preview.modeText": "متن", + "export.preview.modeRendered": "رندرشده", "export.preview.title": "پیش نمایش زنده", "export.title": "Export Publishing Suite", "export.toggleSection": "تغییر وضعیت دید برای {{title}}", diff --git a/locales/fi/export.json b/locales/fi/export.json index 2fb9abf6e..7ef0b4e80 100644 --- a/locales/fi/export.json +++ b/locales/fi/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Liitä teksti Google Docsista tai Notionista tähän (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Luvun otsikko (valinnainen)", "export.preview.noContent": "Valitse sisältö nähdäksesi esikatselun.", + "export.preview.modeLabel": "Esikatselutila", + "export.preview.modeText": "Teksti", + "export.preview.modeRendered": "Renderöity", "export.preview.title": "Live-esikatselu", "export.title": "Vie Publishing Suite", "export.toggleSection": "Vaihda näkyvyys {{title}}", diff --git a/locales/fr/export.json b/locales/fr/export.json index bad2ee2ee..717f1cc08 100644 --- a/locales/fr/export.json +++ b/locales/fr/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Collez du texte depuis Google Docs ou Notion ici (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Titre du chapitre (facultatif)", "export.preview.noContent": "Sélectionnez du contenu pour voir un aperçu.", + "export.preview.modeLabel": "Mode d'aperçu", + "export.preview.modeText": "Texte", + "export.preview.modeRendered": "Rendu", "export.preview.title": "Aperçu en direct", "export.title": "Suite de publication Export", "export.toggleSection": "Basculer la visibilité pour {{title}}", diff --git a/locales/he/export.json b/locales/he/export.json index 8c7af45df..652d7a969 100644 --- a/locales/he/export.json +++ b/locales/he/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "הדביקו כאן טקסט מ‑Google Docs או מ‑Notion ‏(Ctrl+V)...", "export.pasteSection.titlePlaceholder": "כותרת הפרק (אופציונלי)", "export.preview.noContent": "בחרו תוכן כדי לראות תצוגה מקדימה.", + "export.preview.modeLabel": "מצב תצוגה מקדימה", + "export.preview.modeText": "טקסט", + "export.preview.modeRendered": "מעובד", "export.preview.title": "תצוגה מקדימה חיה", "export.title": "חבילת הוצאה לאור וייצוא", "export.toggleSection": "החלפת נראות עבור {{title}}", diff --git a/locales/hu/export.json b/locales/hu/export.json index 339a0b332..b2f5092e5 100644 --- a/locales/hu/export.json +++ b/locales/hu/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Illesszen be szöveget a Google Docsból vagy a Notionból (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Fejezet címe (nem kötelező)", "export.preview.noContent": "Az előnézet megtekintéséhez válassza ki a tartalmat.", + "export.preview.modeLabel": "Előnézeti mód", + "export.preview.modeText": "Szöveg", + "export.preview.modeRendered": "Megjelenített", "export.preview.title": "Élő előnézet", "export.title": "Export Publishing Suite", "export.toggleSection": "{{title}} láthatóságának váltása", diff --git a/locales/is/export.json b/locales/is/export.json index 1ff7766fe..1fbe41a8a 100644 --- a/locales/is/export.json +++ b/locales/is/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Límdu texta úr Google skjölum eða hugmynd hér (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Kaflaheiti (valfrjálst)", "export.preview.noContent": "Veldu efni til að sjá forskoðun.", + "export.preview.modeLabel": "Forskoðunarhamur", + "export.preview.modeText": "Texti", + "export.preview.modeRendered": "Myndað", "export.preview.title": "Forskoðun í beinni", "export.title": "Flytja út útgáfusvítu", "export.toggleSection": "Skipta á sýnileika fyrir {{title}}", diff --git a/locales/it/export.json b/locales/it/export.json index 132dc3dcf..c2b0106b8 100644 --- a/locales/it/export.json +++ b/locales/it/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Incolla testo da Google Docs o Notion qui (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Titolo capitolo (opzionale)", "export.preview.noContent": "Seleziona contenuto per vedere un'anteprima.", + "export.preview.modeLabel": "Modalità anteprima", + "export.preview.modeText": "Testo", + "export.preview.modeRendered": "Renderizzato", "export.preview.title": "Anteprima in diretta", "export.title": "Suite di pubblicazione Export", "export.toggleSection": "Attiva/disattiva visibilità per {{title}}", diff --git a/locales/ja/export.json b/locales/ja/export.json index 0dec028b5..e6113cef2 100644 --- a/locales/ja/export.json +++ b/locales/ja/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Google ドキュメントまたは Notion からここにテキストを貼り付けます (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "章のタイトル (オプション)", "export.preview.noContent": "コンテンツを選択してプレビューを表示します。", + "export.preview.modeLabel": "プレビューモード", + "export.preview.modeText": "テキスト", + "export.preview.modeRendered": "レンダリング", "export.preview.title": "ライブプレビュー", "export.title": "エクスポート Publishing Suite", "export.toggleSection": "{{title}} の公開設定を切り替えます", diff --git a/locales/ko/export.json b/locales/ko/export.json index 0e04ea627..34c05c7c8 100644 --- a/locales/ko/export.json +++ b/locales/ko/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Google Docs 또는 Notion의 텍스트를 여기에 붙여넣습니다(Ctrl+V)...", "export.pasteSection.titlePlaceholder": "장 제목(선택사항)", "export.preview.noContent": "미리보기를 보려면 콘텐츠를 선택하세요.", + "export.preview.modeLabel": "미리 보기 모드", + "export.preview.modeText": "텍스트", + "export.preview.modeRendered": "렌더링됨", "export.preview.title": "실시간 미리보기", "export.title": "출판 제품군 내보내기", "export.toggleSection": "{{title}}에 대한 공개 여부를 전환합니다.", diff --git a/locales/pt/export.json b/locales/pt/export.json index 0d1f47a62..66a64b171 100644 --- a/locales/pt/export.json +++ b/locales/pt/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Cole o texto do Google Docs ou Notion aqui (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Título do capítulo (opcional)", "export.preview.noContent": "Selecione o conteúdo para ver uma prévia.", + "export.preview.modeLabel": "Modo de pré-visualização", + "export.preview.modeText": "Texto", + "export.preview.modeRendered": "Renderizado", "export.preview.title": "Visualização ao vivo", "export.title": "Exportar Publishing Suite", "export.toggleSection": "Alternar visibilidade para {{title}}", diff --git a/locales/ru/export.json b/locales/ru/export.json index 4b33124e8..c8c4e0f5f 100644 --- a/locales/ru/export.json +++ b/locales/ru/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Вставьте сюда текст из Google Docs или Notion (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Название главы (необязательно)", "export.preview.noContent": "Выберите контент, чтобы просмотреть его.", + "export.preview.modeLabel": "Режим предпросмотра", + "export.preview.modeText": "Текст", + "export.preview.modeRendered": "С разметкой", "export.preview.title": "Живой просмотр", "export.title": "Экспортный издательский пакет", "export.toggleSection": "Переключить видимость для {{title}}", diff --git a/locales/sv/export.json b/locales/sv/export.json index 23a02e33d..2fcd73737 100644 --- a/locales/sv/export.json +++ b/locales/sv/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "Klistra in text från Google Dokument eller Notion här (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Kapiteltitel (valfritt)", "export.preview.noContent": "Välj innehåll för att se en förhandsvisning.", + "export.preview.modeLabel": "Förhandsgranskningsläge", + "export.preview.modeText": "Text", + "export.preview.modeRendered": "Renderad", "export.preview.title": "Live Preview", "export.title": "Exportera Publishing Suite", "export.toggleSection": "Växla synlighet för {{title}}", diff --git a/locales/zh/export.json b/locales/zh/export.json index f3426e547..584d66576 100644 --- a/locales/zh/export.json +++ b/locales/zh/export.json @@ -87,6 +87,9 @@ "export.pasteSection.textPlaceholder": "将 Google Docs 或 Notion 中的文本粘贴到此处 (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "章节标题(可选)", "export.preview.noContent": "选择内容以查看预览。", + "export.preview.modeLabel": "预览模式", + "export.preview.modeText": "文本", + "export.preview.modeRendered": "渲染", "export.preview.title": "实时预览", "export.title": "导出 Publishing Suite", "export.toggleSection": "切换 {{title}} 的可见性", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index 734928be5..e541bb77f 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "الصق نصًا من Google Docs أو Notion هنا ‏(Ctrl+V)...", "export.pasteSection.titlePlaceholder": "عنوان الفصل (اختياري)", "export.preview.noContent": "اختر محتوى لرؤية معاينة.", + "export.preview.modeLabel": "وضع المعاينة", + "export.preview.modeText": "نص", + "export.preview.modeRendered": "معروض", "export.preview.title": "معاينة حيّة", "export.title": "جناح النشر والتصدير", "export.toggleSection": "تبديل ظهور {{title}}", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index 20cd7f8bc..fc5588c5f 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Text aus Google Docs oder Notion hier einfügen (Strg+V)…", "export.pasteSection.titlePlaceholder": "Kapiteltitel (optional)", "export.preview.noContent": "Wählen Sie Inhalte aus, um eine Vorschau zu sehen.", + "export.preview.modeLabel": "Vorschaumodus", + "export.preview.modeText": "Text", + "export.preview.modeRendered": "Gerendert", "export.preview.title": "Live-Vorschau", "export.title": "Publishing-Suite", "export.toggleSection": "Sichtbarkeit für {{title}} umschalten", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 93aeebc54..55295a3d0 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Επικολλήστε εδώ κείμενο από τα Έγγραφα Google ή το Notion (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Τίτλος κεφαλαίου (προαιρετικό)", "export.preview.noContent": "Επιλέξτε περιεχόμενο για να δείτε μια προεπισκόπηση.", + "export.preview.modeLabel": "Λειτουργία προεπισκόπησης", + "export.preview.modeText": "Κείμενο", + "export.preview.modeRendered": "Αποδοθέν", "export.preview.title": "Ζωντανή προεπισκόπηση", "export.title": "Εξαγωγή Publishing Suite", "export.toggleSection": "Εναλλαγή ορατότητας για {{title}}", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index bcc57bd63..89340298f 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -979,6 +979,9 @@ "export.options.copied": "Copied!", "export.preview.title": "Live Preview", "export.preview.noContent": "Select content to see a preview.", + "export.preview.modeLabel": "Preview mode", + "export.preview.modeText": "Text", + "export.preview.modeRendered": "Rendered", "export.loglineLabel": "Logline", "export.charactersLabel": "Characters", "export.appearanceLabel": "Appearance", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 6ffb951bc..45acb0849 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Pega texto de Google Docs o Notion aquí (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Título del capítulo (opcional)", "export.preview.noContent": "Selecciona contenido para ver una vista previa.", + "export.preview.modeLabel": "Modo de vista previa", + "export.preview.modeText": "Texto", + "export.preview.modeRendered": "Renderizado", "export.preview.title": "Vista previa en vivo", "export.title": "Suite de publicación Export", "export.toggleSection": "Alternar visibilidad para {{title}}", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index f27cd167a..c6de49e32 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Itsatsi Google Docs edo Notion-eko testua hemen (Ktrl+V)...", "export.pasteSection.titlePlaceholder": "Kapituluaren izenburua (aukerakoa)", "export.preview.noContent": "Hautatu edukia aurrebista ikusteko.", + "export.preview.modeLabel": "Aurrebista modua", + "export.preview.modeText": "Testua", + "export.preview.modeRendered": "Errendatua", "export.preview.title": "Zuzeneko aurrebista", "export.title": "Esportatu Argitalpen Suite", "export.toggleSection": "Aldatu ikusgaitasuna {{title}}", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index 9b2e9580a..6790d0e5f 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "متن را از Google Docs یا Notion در اینجا جای‌گذاری کنید (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "عنوان فصل (اختیاری)", "export.preview.noContent": "برای مشاهده پیش نمایش، محتوا را انتخاب کنید.", + "export.preview.modeLabel": "حالت پیش‌نمایش", + "export.preview.modeText": "متن", + "export.preview.modeRendered": "رندرشده", "export.preview.title": "پیش نمایش زنده", "export.title": "Export Publishing Suite", "export.toggleSection": "تغییر وضعیت دید برای {{title}}", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index 1e4375fd3..80ea1fe08 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Liitä teksti Google Docsista tai Notionista tähän (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Luvun otsikko (valinnainen)", "export.preview.noContent": "Valitse sisältö nähdäksesi esikatselun.", + "export.preview.modeLabel": "Esikatselutila", + "export.preview.modeText": "Teksti", + "export.preview.modeRendered": "Renderöity", "export.preview.title": "Live-esikatselu", "export.title": "Vie Publishing Suite", "export.toggleSection": "Vaihda näkyvyys {{title}}", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 3abb0ef24..8c95e45c5 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Collez du texte depuis Google Docs ou Notion ici (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Titre du chapitre (facultatif)", "export.preview.noContent": "Sélectionnez du contenu pour voir un aperçu.", + "export.preview.modeLabel": "Mode d'aperçu", + "export.preview.modeText": "Texte", + "export.preview.modeRendered": "Rendu", "export.preview.title": "Aperçu en direct", "export.title": "Suite de publication Export", "export.toggleSection": "Basculer la visibilité pour {{title}}", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index a091b6414..c73f28d9e 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "הדביקו כאן טקסט מ‑Google Docs או מ‑Notion ‏(Ctrl+V)...", "export.pasteSection.titlePlaceholder": "כותרת הפרק (אופציונלי)", "export.preview.noContent": "בחרו תוכן כדי לראות תצוגה מקדימה.", + "export.preview.modeLabel": "מצב תצוגה מקדימה", + "export.preview.modeText": "טקסט", + "export.preview.modeRendered": "מעובד", "export.preview.title": "תצוגה מקדימה חיה", "export.title": "חבילת הוצאה לאור וייצוא", "export.toggleSection": "החלפת נראות עבור {{title}}", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 7d2556b81..b6137e12b 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Illesszen be szöveget a Google Docsból vagy a Notionból (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Fejezet címe (nem kötelező)", "export.preview.noContent": "Az előnézet megtekintéséhez válassza ki a tartalmat.", + "export.preview.modeLabel": "Előnézeti mód", + "export.preview.modeText": "Szöveg", + "export.preview.modeRendered": "Megjelenített", "export.preview.title": "Élő előnézet", "export.title": "Export Publishing Suite", "export.toggleSection": "{{title}} láthatóságának váltása", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index 362b75a63..852dfcd06 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Límdu texta úr Google skjölum eða hugmynd hér (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Kaflaheiti (valfrjálst)", "export.preview.noContent": "Veldu efni til að sjá forskoðun.", + "export.preview.modeLabel": "Forskoðunarhamur", + "export.preview.modeText": "Texti", + "export.preview.modeRendered": "Myndað", "export.preview.title": "Forskoðun í beinni", "export.title": "Flytja út útgáfusvítu", "export.toggleSection": "Skipta á sýnileika fyrir {{title}}", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 97bcffa63..42929743c 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Incolla testo da Google Docs o Notion qui (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Titolo capitolo (opzionale)", "export.preview.noContent": "Seleziona contenuto per vedere un'anteprima.", + "export.preview.modeLabel": "Modalità anteprima", + "export.preview.modeText": "Testo", + "export.preview.modeRendered": "Renderizzato", "export.preview.title": "Anteprima in diretta", "export.title": "Suite di pubblicazione Export", "export.toggleSection": "Attiva/disattiva visibilità per {{title}}", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 12d96a803..452ca0034 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Google ドキュメントまたは Notion からここにテキストを貼り付けます (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "章のタイトル (オプション)", "export.preview.noContent": "コンテンツを選択してプレビューを表示します。", + "export.preview.modeLabel": "プレビューモード", + "export.preview.modeText": "テキスト", + "export.preview.modeRendered": "レンダリング", "export.preview.title": "ライブプレビュー", "export.title": "エクスポート Publishing Suite", "export.toggleSection": "{{title}} の公開設定を切り替えます", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 07c464312..da2ff3891 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Google Docs 또는 Notion의 텍스트를 여기에 붙여넣습니다(Ctrl+V)...", "export.pasteSection.titlePlaceholder": "장 제목(선택사항)", "export.preview.noContent": "미리보기를 보려면 콘텐츠를 선택하세요.", + "export.preview.modeLabel": "미리 보기 모드", + "export.preview.modeText": "텍스트", + "export.preview.modeRendered": "렌더링됨", "export.preview.title": "실시간 미리보기", "export.title": "출판 제품군 내보내기", "export.toggleSection": "{{title}}에 대한 공개 여부를 전환합니다.", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index 5c47266ee..a054af26b 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Cole o texto do Google Docs ou Notion aqui (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Título do capítulo (opcional)", "export.preview.noContent": "Selecione o conteúdo para ver uma prévia.", + "export.preview.modeLabel": "Modo de pré-visualização", + "export.preview.modeText": "Texto", + "export.preview.modeRendered": "Renderizado", "export.preview.title": "Visualização ao vivo", "export.title": "Exportar Publishing Suite", "export.toggleSection": "Alternar visibilidade para {{title}}", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index 8bd828af0..366b4c333 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Вставьте сюда текст из Google Docs или Notion (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Название главы (необязательно)", "export.preview.noContent": "Выберите контент, чтобы просмотреть его.", + "export.preview.modeLabel": "Режим предпросмотра", + "export.preview.modeText": "Текст", + "export.preview.modeRendered": "С разметкой", "export.preview.title": "Живой просмотр", "export.title": "Экспортный издательский пакет", "export.toggleSection": "Переключить видимость для {{title}}", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index cc5b8ad3d..f70af9f64 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "Klistra in text från Google Dokument eller Notion här (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "Kapiteltitel (valfritt)", "export.preview.noContent": "Välj innehåll för att se en förhandsvisning.", + "export.preview.modeLabel": "Förhandsgranskningsläge", + "export.preview.modeText": "Text", + "export.preview.modeRendered": "Renderad", "export.preview.title": "Live Preview", "export.title": "Exportera Publishing Suite", "export.toggleSection": "Växla synlighet för {{title}}", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index 3beb6faaf..0f8b836ce 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -1060,6 +1060,9 @@ "export.pasteSection.textPlaceholder": "将 Google Docs 或 Notion 中的文本粘贴到此处 (Ctrl+V)...", "export.pasteSection.titlePlaceholder": "章节标题(可选)", "export.preview.noContent": "选择内容以查看预览。", + "export.preview.modeLabel": "预览模式", + "export.preview.modeText": "文本", + "export.preview.modeRendered": "渲染", "export.preview.title": "实时预览", "export.title": "导出 Publishing Suite", "export.toggleSection": "切换 {{title}} 的可见性", diff --git a/services/epubApiService.ts b/services/epubApiService.ts index bdefecdcd..0eafecf50 100644 --- a/services/epubApiService.ts +++ b/services/epubApiService.ts @@ -21,11 +21,65 @@ const esc = (s: string) => (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c, ); -const toParagraphs = (text: string) => - text +// QNBS-v3: markdown image token — `![alt](src)`. Only data: URLs are bundled into the EPUB; remote +// URLs are non-conformant inside an EPUB container, so they degrade to their alt text. +const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g; + +/** Registers a data-URL image into the EPUB, returning its in-package href (or null to skip). */ +type ImageRegistrar = (src: string) => string | null; + +interface OebpsFolder { + file: (name: string, data: string, opts?: { base64?: boolean }) => unknown; +} + +/** Book-scoped image registrar: emits data-URL images under images/, dedupes, appends to manifest. */ +function createImageRegistrar(oebps: OebpsFolder, manifest: string[]): ImageRegistrar { + let n = 0; + const cache = new Map(); + return (src: string): string | null => { + if (!src.startsWith('data:image/')) return null; + const cached = cache.get(src); + if (cached) return cached; + const meta = src.split(';')[0] ?? ''; + const mime = meta.split(':')[1] ?? 'application/octet-stream'; + const ext = (mime.split('/')[1] ?? 'bin').replace('jpeg', 'jpg').replace('svg+xml', 'svg'); + const base64 = src.split(',')[1] ?? ''; + if (!base64) return null; + n += 1; + const href = `images/img${n}.${ext}`; + oebps.file(href, base64, { base64: true }); + manifest.push(``); + cache.set(src, href); + return href; + }; +} + +/** Escape a line of text while rewriting markdown image tokens to bundled (or alt fallback). */ +function renderLine(line: string, registerImage: ImageRegistrar): string { + let out = ''; + let last = 0; + for (const m of line.matchAll(IMG_RE)) { + const idx = m.index ?? 0; + out += esc(line.slice(last, idx)); + const alt = m[1] ?? ''; + const href = registerImage(m[2] ?? ''); + out += href ? `${esc(alt)}` : esc(alt); + last = idx + m[0].length; + } + out += esc(line.slice(last)); + return out; +} + +/** Split text into

blocks, rewriting any inline/standalone markdown images to bundled . */ +function renderBody(text: string, registerImage: ImageRegistrar): string { + return text .split('\n') - .map((l) => (l.trim() ? `

${esc(l.trim())}

` : '')) + .map((l) => { + const trimmed = l.trim(); + return trimmed ? `

${renderLine(trimmed, registerImage)}

` : ''; + }) .join(''); +} export async function exportEpub(options: EpubExportOptions): Promise { const { title, author, synopsis, chapters, lang = 'de', coverImage, compileProfile } = options; @@ -67,9 +121,14 @@ p{margin:.6em 0;text-indent:1.6em}p:first-child,.no-indent{text-indent:0} const manifest: string[] = [ ``, ``, + // QNBS-v3: NCX for EPUB-2 backward compatibility (older e-readers ignore the EPUB-3 nav doc). + ``, ]; const spine: string[] = []; - const tocEntries: string[] = []; + // QNBS-v3: structured TOC entries — drive BOTH the EPUB-3 nav.xhtml and the EPUB-2 toc.ncx. + const navList: Array<{ href: string; label: string }> = []; + // QNBS-v3: bundle data-URL images referenced from manuscript/front-matter markdown into the EPUB. + const registerImage = createImageRegistrar(oebps, manifest); // Optional cover image if (coverImage?.startsWith('data:image/')) { @@ -96,7 +155,7 @@ p{margin:.6em 0;text-indent:1.6em}p:first-child,.no-indent{text-indent:0} // Title page — optional Markdown body from compile profile (Scrivener-style front matter). const titleInner = compileProfile?.titlePageMarkdown?.trim() - ? `
${toParagraphs(compileProfile.titlePageMarkdown)}
` + ? `
${renderBody(compileProfile.titlePageMarkdown, registerImage)}
` : `

${esc(title)}

${author ? `

${esc(author)}

` : ''}
`; oebps.file( @@ -108,7 +167,7 @@ ${author ? `

${esc(author)}

` : ''}
`; ); manifest.push(``); spine.push(``); - tocEntries.push(`
  • ${esc(title)}
  • `); + navList.push({ href: 'titlepage.xhtml', label: title }); let extraIdx = 0; const pushCompilePage = (navLabel: string, markdown: string) => { @@ -119,11 +178,11 @@ ${author ? `

    ${esc(author)}

    ` : ''}
    `; ` ${esc(navLabel)} -

    ${esc(navLabel)}

    ${toParagraphs(markdown)}`, +

    ${esc(navLabel)}

    ${renderBody(markdown, registerImage)}`, ); manifest.push(``); spine.push(``); - tocEntries.push(`
  • ${esc(navLabel)}
  • `); + navList.push({ href: file, label: navLabel }); }; if (compileProfile?.dedicationMarkdown?.trim()) { @@ -140,11 +199,11 @@ ${author ? `

    ${esc(author)}

    ` : ''}
    `; ` ${esc(block.title)} -

    ${esc(block.title)}

    ${toParagraphs(block.bodyMarkdown)}`, +

    ${esc(block.title)}

    ${renderBody(block.bodyMarkdown, registerImage)}`, ); manifest.push(``); spine.push(``); - tocEntries.push(`
  • ${esc(block.title)}
  • `); + navList.push({ href: file, label: block.title }); } // Synopsis @@ -154,11 +213,11 @@ ${author ? `

    ${esc(author)}

    ` : ''}
    `; ` Synopsis -

    Synopsis

    ${toParagraphs(synopsis)}
    `, +

    Synopsis

    ${renderBody(synopsis, registerImage)}
    `, ); manifest.push(``); spine.push(``); - tocEntries.push(`
  • Synopsis
  • `); + navList.push({ href: 'synopsis.xhtml', label: 'Synopsis' }); } // Chapters @@ -171,11 +230,11 @@ ${author ? `

    ${esc(author)}

    ` : ''}`; ${esc(ch.title)}

    ${esc(ch.title)}

    -${ch.content?.trim() ? toParagraphs(ch.content) : '

    (Empty Chapter)

    '}`, +${ch.content?.trim() ? renderBody(ch.content, registerImage) : '

    (Empty Chapter)

    '}`, ); manifest.push(``); spine.push(``); - tocEntries.push(`
  • ${esc(ch.title)}
  • `); + navList.push({ href: file, label: ch.title }); }); if (compileProfile?.acknowledgementsMarkdown?.trim()) { @@ -189,20 +248,49 @@ ${ch.content?.trim() ? toParagraphs(ch.content) : '

    (Emp ` ${esc(block.title)} -

    ${esc(block.title)}

    ${toParagraphs(block.bodyMarkdown)}`, +

    ${esc(block.title)}

    ${renderBody(block.bodyMarkdown, registerImage)}`, ); manifest.push(``); spine.push(``); - tocEntries.push(`
  • ${esc(block.title)}
  • `); + navList.push({ href: file, label: block.title }); } // Navigation (EPUB 3) + const navItems = navList + .map((e) => `
  • ${esc(e.label)}
  • `) + .join('\n'); oebps.file( 'nav.xhtml', ` Table of Contents -`, +`, + ); + + // QNBS-v3: NCX (EPUB-2) — same entries as nav.xhtml so EPUB-2-only readers still get a working TOC. + const navPoints = navList + .map( + (e, i) => + `${esc( + e.label, + )}`, + ) + .join('\n '); + oebps.file( + 'toc.ncx', + ` + + + + + + + + ${esc(title)} + + ${navPoints} + +`, ); // content.opf @@ -222,7 +310,7 @@ ${ch.content?.trim() ? toParagraphs(ch.content) : '

    (Emp ${manifest.join('\n ')} - + ${spine.join('\n ')} `, diff --git a/services/exportPreviewMarkdown.ts b/services/exportPreviewMarkdown.ts new file mode 100644 index 000000000..96831d790 --- /dev/null +++ b/services/exportPreviewMarkdown.ts @@ -0,0 +1,79 @@ +// services/exportPreviewMarkdown.ts +// +// QNBS-v3: PR2 — a small, sanitized markdown→HTML renderer for the Export view's "Rendered" +// preview, so the preview reflects real output (headings/paragraphs/emphasis/images) instead of +// only the raw compiled markdown text. Output is always run through DOMPurify with a strict +// allowlist before it reaches innerHTML — no script/style/event-handler vectors survive. + +import DOMPurify from 'dompurify'; + +const esc = (s: string): string => + s.replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c, + ); + +const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g; + +/** Inline span formatting on an already-escaped line: images, bold, italic, inline code. */ +function inline(escaped: string): string { + return escaped + .replace(IMG_RE, (_m, alt: string, src: string) => `${alt}`) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1'); +} + +/** Convert compiled export markdown into a sanitized HTML string for the preview pane. */ +export function renderExportMarkdownToHtml(markdown: string): string { + const lines = markdown.split('\n'); + const out: string[] = []; + let inList = false; + const closeList = () => { + if (inList) { + out.push(''); + inList = false; + } + }; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line.trim()) { + closeList(); + continue; + } + const h1 = /^# (.+)/.exec(line); + const h2 = /^## (.+)/.exec(line); + const h3 = /^### (.+)/.exec(line); + const li = /^[-*] (.+)/.exec(line); + + if (h3) { + closeList(); + out.push(`

    ${inline(esc(h3[1] ?? ''))}

    `); + } else if (h2) { + closeList(); + out.push(`

    ${inline(esc(h2[1] ?? ''))}

    `); + } else if (h1) { + closeList(); + out.push(`

    ${inline(esc(h1[1] ?? ''))}

    `); + } else if (li) { + if (!inList) { + out.push('
      '); + inList = true; + } + out.push(`
    • ${inline(esc(li[1] ?? ''))}
    • `); + } else { + closeList(); + out.push(`

      ${inline(esc(line))}

      `); + } + } + closeList(); + + return DOMPurify.sanitize(out.join(''), { + ALLOWED_TAGS: ['h1', 'h2', 'h3', 'p', 'ul', 'li', 'strong', 'em', 'code', 'img', 'br'], + ALLOWED_ATTR: ['src', 'alt'], + ALLOW_DATA_ATTR: false, + FORBID_ATTR: ['style', 'class'], + SANITIZE_DOM: true, + }); +} diff --git a/tests/unit/ExportView.test.tsx b/tests/unit/ExportView.test.tsx index 012005b7f..0c5c5ef1f 100644 --- a/tests/unit/ExportView.test.tsx +++ b/tests/unit/ExportView.test.tsx @@ -149,4 +149,14 @@ describe('ExportView', () => { expect(screen.getByText('export.options.downloadButton')).toBeTruthy(); expect(screen.getByText('common.copyToClipboard')).toBeTruthy(); }); + + it('switches the preview to rendered HTML mode', () => { + render(); + // Text mode is the default — the
       preview is present.
      +    expect(screen.getByTestId('export-preview')).toBeTruthy();
      +    fireEvent.click(screen.getByText('export.preview.modeRendered'));
      +    // Rendered mode swaps in the sanitized-HTML container.
      +    expect(screen.getByTestId('export-preview-rendered')).toBeTruthy();
      +    expect(screen.queryByTestId('export-preview')).toBeNull();
      +  });
       });
      diff --git a/tests/unit/epubApiService.test.ts b/tests/unit/epubApiService.test.ts
      index 97a5df82b..a66c0f948 100644
      --- a/tests/unit/epubApiService.test.ts
      +++ b/tests/unit/epubApiService.test.ts
      @@ -1,71 +1,108 @@
      -import { describe, expect, it, vi } from 'vitest';
      +import { beforeEach, describe, expect, it, vi } from 'vitest';
       import { exportEpub } from '../../services/epubApiService';
       
      -// Mock JSZip since we're testing logic, not zip internals
      +// QNBS-v3: capture every file written into the EPUB so we can assert on the generated XML/structure.
      +const h = vi.hoisted(() => ({ files: {} as Record }));
      +
       vi.mock('jszip', () => {
      -  const blobResult = new Blob(['mock-epub'], { type: 'application/epub+zip' });
      -  function MockJSZip(this: Record) {
      -    this['file'] = () => this;
      -    this['folder'] = () => this;
      -    this['generateAsync'] = () => Promise.resolve(blobResult);
      +  class MockJSZip {
      +    file(name: string, data: unknown) {
      +      if (typeof data === 'string') h.files[name] = data;
      +      return this;
      +    }
      +    folder() {
      +      // Folder writes land in the same flat map keyed by their bare path (e.g. 'images/img1.png').
      +      return this;
      +    }
      +    generateAsync() {
      +      return Promise.resolve(new Blob(['mock-epub'], { type: 'application/epub+zip' }));
      +    }
         }
         return { default: MockJSZip };
       });
       
      -describe('epubApiService', () => {
      -  it('exports a valid epub blob', async () => {
      -    // Mock URL and anchor methods for download
      -    const mockCreateObjectURL = vi.fn().mockReturnValue('blob:mock-url');
      -    const mockRevokeObjectURL = vi.fn();
      -    Object.defineProperty(URL, 'createObjectURL', {
      -      value: mockCreateObjectURL,
      -      writable: true,
      -    });
      -    Object.defineProperty(URL, 'revokeObjectURL', {
      -      value: mockRevokeObjectURL,
      -      writable: true,
      -    });
      +function stubDownload() {
      +  Object.defineProperty(URL, 'createObjectURL', {
      +    value: vi.fn(() => 'blob:mock'),
      +    writable: true,
      +  });
      +  Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), writable: true });
      +  const anchor = document.createElement('a');
      +  const click = vi.fn();
      +  vi.spyOn(anchor, 'click').mockImplementation(click);
      +  vi.spyOn(anchor, 'remove').mockImplementation(() => {});
      +  vi.spyOn(document, 'createElement').mockReturnValue(anchor);
      +  return { anchor, click };
      +}
       
      -    const mockAnchor = document.createElement('a');
      -    const mockClick = vi.fn();
      -    vi.spyOn(mockAnchor, 'click').mockImplementation(mockClick);
      -    vi.spyOn(mockAnchor, 'remove').mockImplementation(() => {});
      -    vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor);
      +describe('epubApiService', () => {
      +  beforeEach(() => {
      +    h.files = {};
      +    vi.restoreAllMocks();
      +  });
       
      +  it('exports a valid epub blob', async () => {
      +    const { anchor, click } = stubDownload();
           await exportEpub({
             title: 'Test Buch',
             author: 'Test Autor',
             chapters: [{ title: 'Kapitel 1', content: 'Inhalt des ersten Kapitels.' }],
           });
      -
      -    expect(mockClick).toHaveBeenCalledOnce();
      +    expect(click).toHaveBeenCalledOnce();
           // Service sanitizes spaces to underscores in filename
      -    expect(mockAnchor.download).toBe('Test_Buch.epub');
      +    expect(anchor.download).toBe('Test_Buch.epub');
         });
       
         it('uses provided language', async () => {
      -    const mockCreateObjectURL = vi.fn().mockReturnValue('blob:url');
      -    Object.defineProperty(URL, 'createObjectURL', {
      -      value: mockCreateObjectURL,
      -      writable: true,
      -    });
      -    Object.defineProperty(URL, 'revokeObjectURL', {
      -      value: vi.fn(),
      -      writable: true,
      -    });
      -    const mockAnchor2 = document.createElement('a');
      -    const mockClick = vi.fn();
      -    vi.spyOn(mockAnchor2, 'click').mockImplementation(mockClick);
      -    vi.spyOn(mockAnchor2, 'remove').mockImplementation(() => {});
      -    vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor2);
      +    const { click } = stubDownload();
      +    await exportEpub({ title: 'My Book', author: 'Author', chapters: [], lang: 'en' });
      +    expect(click).toHaveBeenCalledOnce();
      +    expect(h.files['toc.ncx']).toContain('xml:lang="en"');
      +  });
       
      +  it('generates an EPUB-2 toc.ncx and references it from the spine + manifest', async () => {
      +    stubDownload();
           await exportEpub({
             title: 'My Book',
             author: 'Author',
      -      chapters: [],
      -      lang: 'en',
      +      chapters: [{ title: 'Chapter One', content: 'Body.' }],
      +    });
      +    const ncx = h.files['toc.ncx'];
      +    expect(ncx).toBeDefined();
      +    expect(ncx).toContain('');
      +    expect(ncx).toContain('');
      +    expect(opf).toContain('media-type="application/x-dtbncx+xml"');
      +  });
      +
      +  it('bundles inline data-URL images and rewrites them to ', async () => {
      +    stubDownload();
      +    await exportEpub({
      +      title: 'Img Book',
      +      author: 'A',
      +      chapters: [{ title: 'Ch', content: 'Before\n![a cat](data:image/png;base64,AAAA)\nAfter' }],
           });
      +    // image emitted into images/ and registered in the manifest
      +    const imgKey = Object.keys(h.files).find((k) => k.startsWith('images/img1.'));
      +    expect(imgKey).toBe('images/img1.png');
      +    expect(h.files['content.opf']).toContain('href="images/img1.png"');
      +    // chapter xhtml references the bundled image with its alt text preserved
      +    expect(h.files['ch1.xhtml']).toContain('a cat');
      +  });
       
      -    expect(mockClick).toHaveBeenCalledOnce();
      +  it('degrades remote image URLs to alt text (EPUB cannot bundle remote)', async () => {
      +    stubDownload();
      +    await exportEpub({
      +      title: 'Remote Book',
      +      author: 'A',
      +      chapters: [{ title: 'Ch', content: '![remote pic](https://example.com/p.png)' }],
      +    });
      +    const ch = h.files['ch1.xhtml'];
      +    expect(ch).toContain('remote pic');
      +    expect(ch).not.toContain(' k.startsWith('images/'))).toBe(false);
         });
       });
      diff --git a/tests/unit/exportPreviewMarkdown.test.ts b/tests/unit/exportPreviewMarkdown.test.ts
      new file mode 100644
      index 000000000..7732212e3
      --- /dev/null
      +++ b/tests/unit/exportPreviewMarkdown.test.ts
      @@ -0,0 +1,43 @@
      +import { describe, expect, it } from 'vitest';
      +import { renderExportMarkdownToHtml } from '../../services/exportPreviewMarkdown';
      +
      +describe('renderExportMarkdownToHtml', () => {
      +  it('renders headings as real heading tags', () => {
      +    const html = renderExportMarkdownToHtml('# Title\n## Section\n### Chapter');
      +    expect(html).toContain('

      Title

      '); + expect(html).toContain('

      Section

      '); + expect(html).toContain('

      Chapter

      '); + }); + + it('renders paragraphs and emphasis', () => { + const html = renderExportMarkdownToHtml('A **bold** and *italic* line.'); + expect(html).toContain('bold'); + expect(html).toContain('italic'); + expect(html).toContain('

      '); + }); + + it('groups consecutive list items into a single

        ', () => { + const html = renderExportMarkdownToHtml('- one\n- two'); + expect(html).toBe('
        • one
        • two
        '); + }); + + it('renders markdown images', () => { + const html = renderExportMarkdownToHtml('![cat](https://example.com/c.png)'); + expect(html).toContain(' { + const html = renderExportMarkdownToHtml(' hello & x'); + expect(html).not.toContain(' survives either + expect(html).not.toContain(''); + expect(html).toContain('hello'); + }); + + it('does not emit class or style attributes (sanitized away)', () => { + const html = renderExportMarkdownToHtml('## Heading'); + expect(html).not.toContain('class='); + expect(html).not.toContain('style='); + }); +});