feat(export): Chunk #6 Export Pipeline — 7 exporters, ExportDialog & tests
Add the @pubscript/exporters package exporting a flattened ProseMirror project to Markdown, HTML, EPUB, DOCX, ODT and PDF, plus a deterministic generatedAt timestamp threaded through every format (ZIP entry dates, PDF /ID, DOCX dc:created/modified, EPUB id, HTML/XML footers) so golden files are byte-stable. Wire an ExportDialog into the web app (Topbar trigger, App route) with format selection, theme pick, and download helpers. Add round-trip and golden-file determinism tests (22) covering non-empty output, byte-identical re-exports, Markdown round-trip through the editor, and EPUB/ODT ZIP + mimetype + XML validation.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Export themes. A theme is a self-contained CSS stylesheet plus the font and
|
||||
* colour tokens exporters use to set defaults (EPUB reading-mode overrides,
|
||||
* DOCX/ODT font families, PDF margins).
|
||||
*
|
||||
* Four themes ship by default:
|
||||
* - Ink — neutral serif, light (the default trade trim size look)
|
||||
* - Parchment — warm cream paper, dark text
|
||||
* - Nocturne — dark mode, light text
|
||||
* - Terminal — monospace, terminal aesthetic
|
||||
*/
|
||||
|
||||
export interface ThemeColors {
|
||||
text: string;
|
||||
background: string;
|
||||
accent: string;
|
||||
muted: string;
|
||||
heading: string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Body font family. */
|
||||
fontFamily: string;
|
||||
/** Heading font family (falls back to fontFamily when null). */
|
||||
headingFont: string | null;
|
||||
/** Base body font size (CSS length). */
|
||||
fontSize: string;
|
||||
/** Body line height. */
|
||||
lineHeight: string;
|
||||
/** Paragraph indent. */
|
||||
paragraphIndent: string;
|
||||
colors: ThemeColors;
|
||||
/** Full stylesheet, scoped under `.ps-doc`, ready to inline in HTML/EPUB. */
|
||||
css: string;
|
||||
}
|
||||
|
||||
const baseRules = (t: Theme): string => `
|
||||
.ps-doc {
|
||||
font-family: ${t.fontFamily};
|
||||
font-size: ${t.fontSize};
|
||||
line-height: ${t.lineHeight};
|
||||
color: ${t.colors.text};
|
||||
background: ${t.colors.background};
|
||||
}
|
||||
.ps-doc h1, .ps-doc h2, .ps-doc h3 {
|
||||
font-family: ${t.headingFont ?? t.fontFamily};
|
||||
color: ${t.colors.heading};
|
||||
line-height: 1.25;
|
||||
margin: 1.4em 0 0.6em;
|
||||
}
|
||||
.ps-doc h1 { font-size: 2em; }
|
||||
.ps-doc h2 { font-size: 1.5em; }
|
||||
.ps-doc h3 { font-size: 1.2em; }
|
||||
.ps-doc p {
|
||||
margin: 0;
|
||||
text-indent: ${t.paragraphIndent};
|
||||
orphans: 2;
|
||||
widows: 2;
|
||||
}
|
||||
.ps-doc p:first-of-type { text-indent: none; }
|
||||
.ps-doc blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.2em 1em;
|
||||
border-left: 3px solid ${t.colors.accent};
|
||||
color: ${t.colors.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
.ps-doc pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: ${t.colors.background};
|
||||
border: 1px solid ${t.colors.muted};
|
||||
border-radius: 4px;
|
||||
padding: 0.6em 0.8em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ps-doc a { color: ${t.colors.accent}; text-decoration: underline; }
|
||||
.ps-doc .scene-break {
|
||||
text-align: center;
|
||||
margin: 1.4em 0;
|
||||
color: ${t.colors.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
const makeTheme = (spec: Omit<Theme, "css">): Theme => ({
|
||||
...spec,
|
||||
css: baseRules(spec as Theme),
|
||||
});
|
||||
|
||||
const ink: Theme = makeTheme({
|
||||
id: "ink",
|
||||
name: "Ink",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.55",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#1a1a1a",
|
||||
background: "#ffffff",
|
||||
accent: "#7a2e2e",
|
||||
muted: "#6b6b6b",
|
||||
heading: "#101010",
|
||||
},
|
||||
});
|
||||
|
||||
const parchment: Theme = makeTheme({
|
||||
id: "parchment",
|
||||
name: "Parchment",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Garamond", "EB Garamond", Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.6",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#3a3226",
|
||||
background: "#f4ecd8",
|
||||
accent: "#8a5a2b",
|
||||
muted: "#7a6f5c",
|
||||
heading: "#4a3a22",
|
||||
},
|
||||
});
|
||||
|
||||
const nocturne: Theme = makeTheme({
|
||||
id: "nocturne",
|
||||
name: "Nocturne",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.6",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#d8d8d8",
|
||||
background: "#14161a",
|
||||
accent: "#7fb3ff",
|
||||
muted: "#9aa0aa",
|
||||
heading: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
const terminal: Theme = makeTheme({
|
||||
id: "terminal",
|
||||
name: "Terminal",
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace',
|
||||
headingFont: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: "9.5pt",
|
||||
lineHeight: "1.5",
|
||||
paragraphIndent: "0",
|
||||
colors: {
|
||||
text: "#d6ffdb",
|
||||
background: "#0b0f10",
|
||||
accent: "#7dff9c",
|
||||
muted: "#7fae86",
|
||||
heading: "#b6ffbf",
|
||||
},
|
||||
});
|
||||
|
||||
/** All available themes, keyed by id. */
|
||||
export const THEMES: Record<string, Theme> = {
|
||||
ink,
|
||||
parchment,
|
||||
nocturne,
|
||||
terminal,
|
||||
};
|
||||
|
||||
/** The default theme id. */
|
||||
export const DEFAULT_THEME_ID = "ink";
|
||||
|
||||
/** Look up a theme by id, falling back to the default. */
|
||||
export function getTheme(id?: string): Theme {
|
||||
return THEMES[id ?? DEFAULT_THEME_ID] ?? ink;
|
||||
}
|
||||
Reference in New Issue
Block a user