@@ -894,7 +894,7 @@ describe("export template variable substitution and scope isolation", () => {
894894
895895// ─── Import built-in templates for cross-suite use ───────────────────────────
896896
897- import { BUILTIN_TEMPLATES } from "./exportTemplates" ;
897+ import { ALL_PROFILE_IDS , BUILTIN_TEMPLATES , type ExportProfileId } from "./exportTemplates" ;
898898
899899// ─── Import new export utilities under test ───────────────────────────────────
900900
@@ -1776,7 +1776,7 @@ describe("exportOutline", () => {
17761776
17771777// ─── Import new profile exports ───────────────────────────────────────────────
17781778
1779- import { buildExportHtml , exportWithProfile } from "./export" ;
1779+ import { buildExportHtml , buildBatchExportProfiles , exportWithProfile } from "./export" ;
17801780
17811781// ════════════════════════════════════════════════════════════════════════════
17821782// buildExportHtml — profile-specific HTML generation
@@ -2697,3 +2697,278 @@ describe("exportEpub", () => {
26972697 expect ( chapters [ 0 ] . title ) . toBe ( "Empty Doc" ) ;
26982698 } ) ;
26992699} ) ;
2700+
2701+ // ════════════════════════════════════════════════════════════════════════════
2702+ // buildExportHtml — new rich-text profiles
2703+ // ════════════════════════════════════════════════════════════════════════════
2704+
2705+ /** Simple body fragment used in new-profile tests (no DOM dependency). */
2706+ const SAMPLE_BODY = `
2707+ <h1>Test Document</h1>
2708+ <p>A paragraph with <strong>bold</strong> and <code>inline code</code>.</p>
2709+ <h2>Code Block</h2>
2710+ <pre class="shiki"><code class="language-js">const x = 1;</code></pre>
2711+ <blockquote class="callout callout-note"><p>Note callout</p></blockquote>
2712+ <table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>
2713+ <ul><li>Item one</li><li>Item two</li></ul>
2714+ ` . trim ( ) ;
2715+
2716+ describe ( "buildExportHtml — github-markdown profile" , ( ) => {
2717+ it ( "produces a valid HTML5 document" , ( ) => {
2718+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2719+ expect ( html ) . toMatch ( / ^ < ! d o c t y p e h t m l > / i) ;
2720+ expect ( html ) . toContain ( "<html" ) ;
2721+ expect ( html ) . toContain ( "</html>" ) ;
2722+ } ) ;
2723+
2724+ it ( "sets data-export-profile to github-markdown" , ( ) => {
2725+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2726+ expect ( html ) . toContain ( 'data-export-profile="github-markdown"' ) ;
2727+ } ) ;
2728+
2729+ it ( "inlines the github-markdown profile CSS" , ( ) => {
2730+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2731+ expect ( html ) . toContain ( "github-markdown export profile" ) ;
2732+ } ) ;
2733+
2734+ it ( "preserves the body content" , ( ) => {
2735+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2736+ expect ( html ) . toContain ( "<h1>Test Document</h1>" ) ;
2737+ expect ( html ) . toContain ( "<table>" ) ;
2738+ } ) ;
2739+
2740+ it ( "includes base CSS bundles (themes, markdown, KaTeX)" , ( ) => {
2741+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2742+ expect ( html ) . toContain ( "/* themes-css */" ) ;
2743+ expect ( html ) . toContain ( "/* markdown-css */" ) ;
2744+ expect ( html ) . toContain ( "/* katex-css */" ) ;
2745+ } ) ;
2746+
2747+ it ( "has no external resource references (offline-ready)" , ( ) => {
2748+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2749+ expect ( html ) . not . toMatch ( / < l i n k [ ^ > ] + h r e f = " h t t p s ? : / ) ;
2750+ expect ( html ) . not . toMatch ( / < s c r i p t [ ^ > ] + s r c = " h t t p s ? : / ) ;
2751+ } ) ;
2752+
2753+ it ( "wraps body content in .reading-surface article" , ( ) => {
2754+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2755+ expect ( html ) . toContain ( '<article class="reading-surface">' ) ;
2756+ } ) ;
2757+
2758+ it ( "includes callout block styling in CSS" , ( ) => {
2759+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2760+ expect ( html ) . toContain ( ".callout-note" ) ;
2761+ } ) ;
2762+
2763+ it ( "preserves code blocks from the body" , ( ) => {
2764+ const html = buildExportHtml ( "github-markdown" , SAMPLE_BODY ) ;
2765+ expect ( html ) . toContain ( "const x = 1;" ) ;
2766+ } ) ;
2767+
2768+ it ( "throws for an unknown profileId" , ( ) => {
2769+ expect ( ( ) => buildExportHtml ( "nonexistent-profile" as ExportProfileId , SAMPLE_BODY ) ) . toThrow ( ) ;
2770+ } ) ;
2771+ } ) ;
2772+
2773+ describe ( "buildExportHtml — confluence-wiki profile" , ( ) => {
2774+ it ( "produces a valid HTML5 document" , ( ) => {
2775+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2776+ expect ( html ) . toMatch ( / ^ < ! d o c t y p e h t m l > / i) ;
2777+ expect ( html ) . toContain ( "<html" ) ;
2778+ } ) ;
2779+
2780+ it ( "sets data-export-profile to confluence-wiki" , ( ) => {
2781+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2782+ expect ( html ) . toContain ( 'data-export-profile="confluence-wiki"' ) ;
2783+ } ) ;
2784+
2785+ it ( "inlines the confluence-wiki profile CSS" , ( ) => {
2786+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2787+ expect ( html ) . toContain ( "confluence-wiki export profile" ) ;
2788+ } ) ;
2789+
2790+ it ( "preserves heading and table structure" , ( ) => {
2791+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2792+ expect ( html ) . toContain ( "<h1>Test Document</h1>" ) ;
2793+ expect ( html ) . toContain ( "<table>" ) ;
2794+ } ) ;
2795+
2796+ it ( "includes callout panel styles (.callout-note, .callout-warning)" , ( ) => {
2797+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2798+ expect ( html ) . toContain ( ".callout-note" ) ;
2799+ expect ( html ) . toContain ( ".callout-warning" ) ;
2800+ } ) ;
2801+
2802+ it ( "has no external resource references" , ( ) => {
2803+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2804+ expect ( html ) . not . toMatch ( / < l i n k [ ^ > ] + h r e f = " h t t p s ? : / ) ;
2805+ } ) ;
2806+
2807+ it ( "includes the dark code macro background colour" , ( ) => {
2808+ const html = buildExportHtml ( "confluence-wiki" , SAMPLE_BODY ) ;
2809+ expect ( html ) . toContain ( "#23241f" ) ;
2810+ } ) ;
2811+ } ) ;
2812+
2813+ describe ( "buildExportHtml — slack-rich-markdown profile" , ( ) => {
2814+ it ( "produces a valid HTML5 document" , ( ) => {
2815+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2816+ expect ( html ) . toMatch ( / ^ < ! d o c t y p e h t m l > / i) ;
2817+ } ) ;
2818+
2819+ it ( "sets data-export-profile to slack-rich-markdown" , ( ) => {
2820+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2821+ expect ( html ) . toContain ( 'data-export-profile="slack-rich-markdown"' ) ;
2822+ } ) ;
2823+
2824+ it ( "inlines the slack-rich-markdown profile CSS" , ( ) => {
2825+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2826+ expect ( html ) . toContain ( "slack-rich-markdown export profile" ) ;
2827+ } ) ;
2828+
2829+ it ( "preserves document body including code blocks" , ( ) => {
2830+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2831+ expect ( html ) . toContain ( "const x = 1;" ) ;
2832+ expect ( html ) . toContain ( "<ul>" ) ;
2833+ } ) ;
2834+
2835+ it ( "includes Slack coloured callout panel rules" , ( ) => {
2836+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2837+ expect ( html ) . toContain ( ".callout-note" ) ;
2838+ expect ( html ) . toContain ( ".callout-danger" ) ;
2839+ } ) ;
2840+
2841+ it ( "includes the code block ::before accent stripe" , ( ) => {
2842+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2843+ expect ( html ) . toContain ( "::before" ) ;
2844+ } ) ;
2845+
2846+ it ( "has no external resource references" , ( ) => {
2847+ const html = buildExportHtml ( "slack-rich-markdown" , SAMPLE_BODY ) ;
2848+ expect ( html ) . not . toMatch ( / < l i n k [ ^ > ] + h r e f = " h t t p s ? : / ) ;
2849+ } ) ;
2850+ } ) ;
2851+
2852+ // ════════════════════════════════════════════════════════════════════════════
2853+ // buildBatchExportProfiles — parallel multi-profile export
2854+ // ════════════════════════════════════════════════════════════════════════════
2855+
2856+ describe ( "buildBatchExportProfiles" , ( ) => {
2857+ it ( "returns one result per profile in ALL_PROFILE_IDS by default" , async ( ) => {
2858+ const results = await buildBatchExportProfiles ( ALL_PROFILE_IDS , SAMPLE_BODY ) ;
2859+ expect ( results ) . toHaveLength ( ALL_PROFILE_IDS . length ) ;
2860+ } ) ;
2861+
2862+ it ( "each result has profileId, ok, and html fields" , async ( ) => {
2863+ const results = await buildBatchExportProfiles ( ALL_PROFILE_IDS , SAMPLE_BODY ) ;
2864+ for ( const r of results ) {
2865+ expect ( typeof r . profileId ) . toBe ( "string" ) ;
2866+ expect ( typeof r . ok ) . toBe ( "boolean" ) ;
2867+ expect ( r . html !== undefined ) . toBe ( true ) ;
2868+ }
2869+ } ) ;
2870+
2871+ it ( "all 6 results are ok=true when valid content is provided" , async ( ) => {
2872+ const results = await buildBatchExportProfiles ( ALL_PROFILE_IDS , SAMPLE_BODY ) ;
2873+ for ( const r of results ) {
2874+ expect ( r . ok , `Profile ${ r . profileId } failed: ${ r . error } ` ) . toBe ( true ) ;
2875+ }
2876+ } ) ;
2877+
2878+ it ( "each successful result contains a valid HTML5 document" , async ( ) => {
2879+ const results = await buildBatchExportProfiles ( ALL_PROFILE_IDS , SAMPLE_BODY ) ;
2880+ for ( const r of results ) {
2881+ if ( r . ok ) {
2882+ expect ( r . html ) . toMatch ( / ^ < ! d o c t y p e h t m l > / i) ;
2883+ expect ( r . html ) . toContain ( "</html>" ) ;
2884+ }
2885+ }
2886+ } ) ;
2887+
2888+ it ( "result profileId values match the requested profile ids" , async ( ) => {
2889+ const ids : ExportProfileId [ ] = [ "github-markdown" , "confluence-wiki" ] ;
2890+ const results = await buildBatchExportProfiles ( ids , SAMPLE_BODY ) ;
2891+ expect ( results . map ( ( r ) => r . profileId ) ) . toEqual ( ids ) ;
2892+ } ) ;
2893+
2894+ it ( "accepts a subset of profiles and returns only those" , async ( ) => {
2895+ const subset : ExportProfileId [ ] = [ "slack-rich-markdown" , "notion-html" ] ;
2896+ const results = await buildBatchExportProfiles ( subset , SAMPLE_BODY ) ;
2897+ expect ( results ) . toHaveLength ( 2 ) ;
2898+ expect ( results [ 0 ] . profileId ) . toBe ( "slack-rich-markdown" ) ;
2899+ expect ( results [ 1 ] . profileId ) . toBe ( "notion-html" ) ;
2900+ } ) ;
2901+
2902+ it ( "returns ok=false (not throws) for an unknown profileId" , async ( ) => {
2903+ const ids = [ "nonexistent-profile" ] as unknown as ExportProfileId [ ] ;
2904+ const results = await buildBatchExportProfiles ( ids , SAMPLE_BODY ) ;
2905+ expect ( results ) . toHaveLength ( 1 ) ;
2906+ expect ( results [ 0 ] . ok ) . toBe ( false ) ;
2907+ expect ( results [ 0 ] . html ) . toBeNull ( ) ;
2908+ expect ( typeof results [ 0 ] . error ) . toBe ( "string" ) ;
2909+ } ) ;
2910+
2911+ it ( "returns an empty array when an empty profileIds list is given" , async ( ) => {
2912+ const results = await buildBatchExportProfiles ( [ ] , SAMPLE_BODY ) ;
2913+ expect ( results ) . toHaveLength ( 0 ) ;
2914+ } ) ;
2915+
2916+ it ( "each profile result contains the body content from the input fragment" , async ( ) => {
2917+ const results = await buildBatchExportProfiles ( ALL_PROFILE_IDS , SAMPLE_BODY ) ;
2918+ for ( const r of results ) {
2919+ if ( r . ok ) {
2920+ expect ( r . html ) . toContain ( "Test Document" ) ;
2921+ }
2922+ }
2923+ } ) ;
2924+
2925+ it ( "different profiles produce distinct HTML output (CSS differs per profile)" , async ( ) => {
2926+ const results = await buildBatchExportProfiles (
2927+ [ "github-markdown" , "confluence-wiki" , "slack-rich-markdown" ] ,
2928+ SAMPLE_BODY ,
2929+ ) ;
2930+ const [ ghHtml , cfHtml , slkHtml ] = results . map ( ( r ) => r . html ! ) ;
2931+ expect ( ghHtml ) . not . toBe ( cfHtml ) ;
2932+ expect ( cfHtml ) . not . toBe ( slkHtml ) ;
2933+ expect ( ghHtml ) . not . toBe ( slkHtml ) ;
2934+ } ) ;
2935+
2936+ it ( "github-markdown result contains 980px layout constraint" , async ( ) => {
2937+ const results = await buildBatchExportProfiles ( [ "github-markdown" ] , SAMPLE_BODY ) ;
2938+ expect ( results [ 0 ] . html ) . toContain ( "980px" ) ;
2939+ } ) ;
2940+
2941+ it ( "confluence-wiki result contains 760px layout constraint" , async ( ) => {
2942+ const results = await buildBatchExportProfiles ( [ "confluence-wiki" ] , SAMPLE_BODY ) ;
2943+ expect ( results [ 0 ] . html ) . toContain ( "760px" ) ;
2944+ } ) ;
2945+
2946+ it ( "slack-rich-markdown result contains 600px layout constraint" , async ( ) => {
2947+ const results = await buildBatchExportProfiles ( [ "slack-rich-markdown" ] , SAMPLE_BODY ) ;
2948+ expect ( results [ 0 ] . html ) . toContain ( "600px" ) ;
2949+ } ) ;
2950+
2951+ it ( "partial failure: one bad profile id does not prevent others from succeeding" , async ( ) => {
2952+ const ids = [
2953+ "github-markdown" ,
2954+ "bad-id" as ExportProfileId ,
2955+ "confluence-wiki" ,
2956+ ] ;
2957+ const results = await buildBatchExportProfiles ( ids , SAMPLE_BODY ) ;
2958+ expect ( results ) . toHaveLength ( 3 ) ;
2959+ expect ( results [ 0 ] . ok ) . toBe ( true ) ;
2960+ expect ( results [ 1 ] . ok ) . toBe ( false ) ;
2961+ expect ( results [ 2 ] . ok ) . toBe ( true ) ;
2962+ } ) ;
2963+
2964+ it ( "nested callout in body is preserved in all profile outputs" , async ( ) => {
2965+ const bodyWithCallout = `<blockquote class="callout callout-note"><h3>Nested</h3><p>Inner text</p></blockquote>` ;
2966+ const results = await buildBatchExportProfiles ( ALL_PROFILE_IDS , bodyWithCallout ) ;
2967+ for ( const r of results ) {
2968+ if ( r . ok ) {
2969+ expect ( r . html ) . toContain ( "callout-note" ) ;
2970+ expect ( r . html ) . toContain ( "Inner text" ) ;
2971+ }
2972+ }
2973+ } ) ;
2974+ } ) ;
0 commit comments