Skip to content

Commit dc0e302

Browse files
masonwyatt23claude
andcommitted
feat: export profile template expansion + MCP batch-export-profiles tool
- Add github-markdown, confluence-wiki, slack-rich-markdown profiles to EXPORT_PROFILES in exportTemplates.ts (CSS-only, offline-ready, 6 profiles total) - Export ALL_PROFILE_IDS constant shared across export.ts, bridge.ts, and settings - Add BatchProfileResult interface + buildBatchExportProfiles() to export.ts — runs all profiles concurrently via Promise.all, catches per-profile errors - Add mcp://batch-export-profiles listener to bridge.ts — respects per-user disabledProfileIds, accepts optional profileIds subset + content override, always replies with mcp_batch_export_profiles_result (prevents Rust timeout) - Add disabledProfileIds state + toggleExportProfile/isProfileEnabled/ setDisabledProfileIds actions to settingsStore (persist v6→v7 with migration) - Add profile availability toggle UI to ExportTemplateSection.tsx - 148 tests in exportTemplates.test.ts (+98 new: 3 new profile CSS suites, ALL_PROFILE_IDS suite, findProfile suite, 6-profile security suite) - 288 tests in export.test.ts (+39 new: buildExportHtml for 3 new profiles, buildBatchExportProfiles full coverage incl. partial failure + nested callouts) - 191 tests in bridge.test.ts (+11 new: mcp://batch-export-profiles suite) - All 2277 tests pass Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 352a1c4 commit dc0e302

8 files changed

Lines changed: 1518 additions & 11 deletions

File tree

src/components/settings/sections/ExportTemplateSection.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010

1111
import { useCallback, useEffect, useRef, useState } from "react";
1212
import {
13+
ALL_PROFILE_IDS,
1314
BUILTIN_TEMPLATES,
15+
EXPORT_PROFILES,
1416
NO_TEMPLATE_ID,
1517
validateTemplateCss,
18+
type ExportProfile,
19+
type ExportProfileId,
1620
type ExportTemplate,
1721
} from "../../../lib/exportTemplates";
1822
import { useSettingsStore } from "../../../store/settingsStore";
@@ -246,6 +250,36 @@ function CssEditor({ template, onSave, onCancel }: CssEditorProps) {
246250
);
247251
}
248252

253+
// ─── Profile toggle row ────────────────────────────────────────────────────────
254+
255+
interface ProfileToggleRowProps {
256+
profile: ExportProfile;
257+
enabled: boolean;
258+
onToggle: () => void;
259+
}
260+
261+
function ProfileToggleRow({ profile, enabled, onToggle }: ProfileToggleRowProps) {
262+
return (
263+
<div className="export-profile-toggle-row">
264+
<div className="export-profile-toggle-info">
265+
<span className="export-profile-toggle-name">{profile.name}</span>
266+
<span className="export-profile-toggle-desc">{profile.description}</span>
267+
</div>
268+
<button
269+
type="button"
270+
role="switch"
271+
aria-checked={enabled}
272+
aria-label={`${enabled ? "Disable" : "Enable"} ${profile.name} export profile`}
273+
className={`export-profile-toggle-btn${enabled ? " enabled" : ""}`}
274+
onClick={onToggle}
275+
title={enabled ? `Disable "${profile.name}"` : `Enable "${profile.name}"`}
276+
>
277+
<span className="export-profile-toggle-knob" aria-hidden="true" />
278+
</button>
279+
</div>
280+
);
281+
}
282+
249283
// ─── Main section component ────────────────────────────────────────────────────
250284

251285
export function ExportTemplateSection() {
@@ -255,6 +289,8 @@ export function ExportTemplateSection() {
255289
const addUserTemplate = useSettingsStore((s) => s.addUserTemplate);
256290
const updateUserTemplate = useSettingsStore((s) => s.updateUserTemplate);
257291
const removeUserTemplate = useSettingsStore((s) => s.removeUserTemplate);
292+
const disabledProfileIds = useSettingsStore((s) => s.disabledProfileIds);
293+
const toggleExportProfile = useSettingsStore((s) => s.toggleExportProfile);
258294

259295
const [editingId, setEditingId] = useState<string | null>(null);
260296

@@ -363,6 +399,35 @@ export function ExportTemplateSection() {
363399
<PlusIcon />
364400
<span>New template</span>
365401
</button>
402+
403+
{/* Export profile availability */}
404+
<div className="export-profile-toggles-section">
405+
<h3 className="export-profile-toggles-heading">Export profiles</h3>
406+
<p className="export-profile-toggles-desc">
407+
Choose which export profiles are available when exporting documents.
408+
Disabled profiles are hidden from the export dialog and excluded from
409+
batch exports.
410+
</p>
411+
<div
412+
className="export-profile-toggles-list"
413+
role="group"
414+
aria-label="Export profile availability"
415+
>
416+
{ALL_PROFILE_IDS.map((id: ExportProfileId) => {
417+
const profile = EXPORT_PROFILES.find((p) => p.id === id);
418+
if (!profile) return null;
419+
const enabled = !disabledProfileIds.includes(id);
420+
return (
421+
<ProfileToggleRow
422+
key={id}
423+
profile={profile}
424+
enabled={enabled}
425+
onToggle={() => toggleExportProfile(id)}
426+
/>
427+
);
428+
})}
429+
</div>
430+
</div>
366431
</div>
367432
);
368433
}

src/lib/export.test.ts

Lines changed: 277 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/^<!doctype html>/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(/<link[^>]+href="https?:/);
2750+
expect(html).not.toMatch(/<script[^>]+src="https?:/);
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(/^<!doctype html>/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(/<link[^>]+href="https?:/);
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(/^<!doctype html>/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(/<link[^>]+href="https?:/);
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(/^<!doctype html>/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

Comments
 (0)