@mulmocast/deck 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import { renderInlineMarkup, c, cardWrap, slideHeader, resolveAccent } from "../utils.js";
2
+ import { renderContentBlocks } from "../blocks.js";
3
+ export const layoutMatrix = (data) => {
4
+ const parts = [slideHeader(data)];
5
+ const rows = data.rows || 2;
6
+ const cols = data.cols || 2;
7
+ const cells = data.cells || [];
8
+ parts.push(`<div class="flex flex-1 px-12 mt-4 gap-2">`);
9
+ if (data.yAxis) {
10
+ parts.push(`<div class="flex flex-col justify-between items-center w-6 shrink-0 py-4">`);
11
+ parts.push(` <span class="text-xs text-d-dim font-body [writing-mode:vertical-lr] rotate-180">${renderInlineMarkup(data.yAxis.high || "")}</span>`);
12
+ if (data.yAxis.label) {
13
+ parts.push(` <span class="text-xs font-bold text-d-muted font-body [writing-mode:vertical-lr] rotate-180">${renderInlineMarkup(data.yAxis.label)}</span>`);
14
+ }
15
+ parts.push(` <span class="text-xs text-d-dim font-body [writing-mode:vertical-lr] rotate-180">${renderInlineMarkup(data.yAxis.low || "")}</span>`);
16
+ parts.push(`</div>`);
17
+ }
18
+ parts.push(`<div class="flex-1 flex flex-col gap-3">`);
19
+ Array.from({ length: rows }).forEach((_row, r) => {
20
+ parts.push(`<div class="flex gap-3 flex-1">`);
21
+ Array.from({ length: cols }).forEach((_col, ci) => {
22
+ const idx = r * cols + ci;
23
+ const cell = cells[idx] || { label: "" };
24
+ const accent = resolveAccent(cell.accentColor);
25
+ const inner = [];
26
+ inner.push(`<h3 class="text-lg font-bold text-${c(accent)} font-body">${renderInlineMarkup(cell.label)}</h3>`);
27
+ if (cell.items) {
28
+ inner.push(`<ul class="mt-2 space-y-1 text-sm text-d-muted font-body">`);
29
+ cell.items.forEach((item) => {
30
+ inner.push(` <li class="flex gap-2"><span class="text-d-dim shrink-0">&bull;</span><span>${renderInlineMarkup(item)}</span></li>`);
31
+ });
32
+ inner.push(`</ul>`);
33
+ }
34
+ if (cell.content) {
35
+ inner.push(`<div class="mt-2 space-y-2">${renderContentBlocks(cell.content)}</div>`);
36
+ }
37
+ parts.push(cardWrap(accent, inner.join("\n"), "flex-1"));
38
+ });
39
+ parts.push(`</div>`);
40
+ });
41
+ if (data.xAxis) {
42
+ parts.push(`<div class="flex justify-between px-2 mt-1">`);
43
+ parts.push(` <span class="text-xs text-d-dim font-body">${renderInlineMarkup(data.xAxis.low || "")}</span>`);
44
+ if (data.xAxis.label) {
45
+ parts.push(` <span class="text-xs font-bold text-d-muted font-body">${renderInlineMarkup(data.xAxis.label)}</span>`);
46
+ }
47
+ parts.push(` <span class="text-xs text-d-dim font-body">${renderInlineMarkup(data.xAxis.high || "")}</span>`);
48
+ parts.push(`</div>`);
49
+ }
50
+ parts.push(`</div>`);
51
+ parts.push(`</div>`);
52
+ return parts.join("\n");
53
+ };
@@ -0,0 +1,2 @@
1
+ import type { SplitSlide } from "../schema.js";
2
+ export declare const layoutSplit: (data: SplitSlide) => string;
@@ -0,0 +1,51 @@
1
+ import { renderInlineMarkup, c, accentBar, resolveAccent } from "../utils.js";
2
+ import { renderContentBlocks } from "../blocks.js";
3
+ const resolveValign = (valign) => {
4
+ if (valign === "top")
5
+ return "justify-start";
6
+ if (valign === "bottom")
7
+ return "justify-end";
8
+ return "justify-center";
9
+ };
10
+ const buildSplitPanel = (panel, fallbackAccent, ratio) => {
11
+ const accent = panel.accentColor || fallbackAccent;
12
+ const bg = panel.dark ? "bg-d-card" : "";
13
+ const vCls = resolveValign(panel.valign);
14
+ const lines = [];
15
+ lines.push(`<div class="${bg} flex flex-col ${vCls} px-10 py-8" style="flex: ${ratio}">`);
16
+ if (panel.label) {
17
+ if (panel.labelBadge) {
18
+ lines.push(` <span class="inline-block self-start px-6 py-2.5 rounded-lg bg-${c(accent)} text-lg font-bold text-white font-title mb-4">${renderInlineMarkup(panel.label)}</span>`);
19
+ }
20
+ else {
21
+ lines.push(` <p class="text-sm font-bold text-${c(accent)} font-body mb-2">${renderInlineMarkup(panel.label)}</p>`);
22
+ }
23
+ }
24
+ if (panel.title) {
25
+ lines.push(` <h2 class="text-[36px] leading-tight font-title font-bold text-d-text">${renderInlineMarkup(panel.title)}</h2>`);
26
+ }
27
+ if (panel.subtitle) {
28
+ lines.push(` <p class="text-base text-d-dim font-body mt-3">${renderInlineMarkup(panel.subtitle)}</p>`);
29
+ }
30
+ if (panel.content) {
31
+ lines.push(` <div class="mt-6 space-y-3">${renderContentBlocks(panel.content)}</div>`);
32
+ }
33
+ lines.push(`</div>`);
34
+ return lines.join("\n");
35
+ };
36
+ export const layoutSplit = (data) => {
37
+ const accent = resolveAccent(data.accentColor);
38
+ const parts = [];
39
+ parts.push(accentBar(accent));
40
+ const leftRatio = data.left?.ratio || 50;
41
+ const rightRatio = data.right?.ratio || 50;
42
+ parts.push(`<div class="flex h-full">`);
43
+ if (data.left) {
44
+ parts.push(buildSplitPanel(data.left, accent, leftRatio));
45
+ }
46
+ if (data.right) {
47
+ parts.push(buildSplitPanel(data.right, accent, rightRatio));
48
+ }
49
+ parts.push(`</div>`);
50
+ return parts.join("\n");
51
+ };
@@ -0,0 +1,2 @@
1
+ import type { StatsSlide } from "../schema.js";
2
+ export declare const layoutStats: (data: StatsSlide) => string;
@@ -0,0 +1,23 @@
1
+ import { renderInlineMarkup, c, resolveItemColor, resolveChangeColor, centeredSlideHeader, renderOptionalCallout } from "../utils.js";
2
+ export const layoutStats = (data) => {
3
+ const stats = data.stats || [];
4
+ const parts = [];
5
+ parts.push(centeredSlideHeader(data));
6
+ // Stats cards
7
+ parts.push(`<div class="flex gap-6 mt-10">`);
8
+ stats.forEach((stat) => {
9
+ const color = resolveItemColor(stat.color, data.accentColor);
10
+ parts.push(`<div class="flex-1 bg-d-card rounded-lg shadow-lg p-10 text-center">`);
11
+ parts.push(` <div class="h-[3px] bg-${c(color)} rounded-full w-12 mx-auto mb-6"></div>`);
12
+ parts.push(` <p class="text-[52px] font-bold text-${c(color)} font-body leading-none">${renderInlineMarkup(stat.value)}</p>`);
13
+ parts.push(` <p class="text-lg text-d-muted font-body mt-4">${renderInlineMarkup(stat.label)}</p>`);
14
+ if (stat.change) {
15
+ parts.push(` <p class="text-base font-bold text-${c(resolveChangeColor(stat.change))} font-body mt-3">${renderInlineMarkup(stat.change)}</p>`);
16
+ }
17
+ parts.push(`</div>`);
18
+ });
19
+ parts.push(`</div>`);
20
+ parts.push(`</div>`);
21
+ parts.push(renderOptionalCallout(data.callout));
22
+ return parts.join("\n");
23
+ };
@@ -0,0 +1,2 @@
1
+ import type { TableSlide } from "../schema.js";
2
+ export declare const layoutTable: (data: TableSlide) => string;
@@ -0,0 +1,10 @@
1
+ import { slideHeader, renderOptionalCallout } from "../utils.js";
2
+ import { renderTableCore } from "../blocks.js";
3
+ export const layoutTable = (data) => {
4
+ const parts = [slideHeader(data)];
5
+ parts.push(`<div class="px-12 mt-5 flex-1 overflow-auto">`);
6
+ parts.push(renderTableCore(data.headers, data.rows, data.rowHeaders, data.striped));
7
+ parts.push(`</div>`);
8
+ parts.push(renderOptionalCallout(data.callout));
9
+ return parts.join("\n");
10
+ };
@@ -0,0 +1,2 @@
1
+ import type { TimelineSlide } from "../schema.js";
2
+ export declare const layoutTimeline: (data: TimelineSlide) => string;
@@ -0,0 +1,27 @@
1
+ import { renderInlineMarkup, c, resolveItemColor, centeredSlideHeader } from "../utils.js";
2
+ export const layoutTimeline = (data) => {
3
+ const parts = [];
4
+ const items = data.items || [];
5
+ parts.push(centeredSlideHeader(data));
6
+ // Timeline items
7
+ parts.push(`<div class="flex items-start mt-10 relative">`);
8
+ parts.push(`<div class="absolute left-4 right-4 top-[52px] h-[2px] bg-d-alt"></div>`);
9
+ items.forEach((item) => {
10
+ const color = resolveItemColor(item.color, data.accentColor);
11
+ const dotBorder = item.done ? `bg-${c(color)}` : `bg-d-alt`;
12
+ const dotInner = item.done ? "bg-d-text" : `bg-${c(color)}`;
13
+ parts.push(`<div class="flex-1 flex flex-col items-center text-center relative z-10">`);
14
+ parts.push(` <div class="w-10 h-10 rounded-full ${dotBorder} flex items-center justify-center shadow-lg">`);
15
+ parts.push(` <div class="w-4 h-4 rounded-full ${dotInner}"></div>`);
16
+ parts.push(` </div>`);
17
+ parts.push(` <p class="text-sm font-bold text-${c(color)} font-body mt-4">${renderInlineMarkup(item.date)}</p>`);
18
+ parts.push(` <p class="text-base font-bold text-d-text font-body mt-2">${renderInlineMarkup(item.title)}</p>`);
19
+ if (item.description) {
20
+ parts.push(` <p class="text-sm text-d-muted font-body mt-1 px-3">${renderInlineMarkup(item.description)}</p>`);
21
+ }
22
+ parts.push(`</div>`);
23
+ });
24
+ parts.push(`</div>`);
25
+ parts.push(`</div>`);
26
+ return parts.join("\n");
27
+ };
@@ -0,0 +1,2 @@
1
+ import type { TitleSlide } from "../schema.js";
2
+ export declare const layoutTitle: (data: TitleSlide) => string;
@@ -0,0 +1,19 @@
1
+ import { renderInlineMarkup, accentBar } from "../utils.js";
2
+ export const layoutTitle = (data) => {
3
+ return [
4
+ accentBar("primary"),
5
+ `<div class="absolute -top-20 -right-8 w-[360px] h-[360px] rounded-full bg-d-primary opacity-10"></div>`,
6
+ `<div class="absolute -bottom-12 -left-16 w-[280px] h-[280px] rounded-full bg-d-accent opacity-10"></div>`,
7
+ `<div class="flex flex-col justify-center h-full px-16 relative z-10">`,
8
+ ` <h1 class="text-[60px] leading-tight font-title font-bold text-d-text">${renderInlineMarkup(data.title)}</h1>`,
9
+ data.subtitle ? ` <p class="text-2xl text-d-muted mt-6 font-body">${renderInlineMarkup(data.subtitle)}</p>` : "",
10
+ data.author ? ` <p class="text-lg text-d-dim mt-10 font-body">${renderInlineMarkup(data.author)}</p>` : "",
11
+ data.note
12
+ ? ` <div class="bg-d-card px-4 py-2 mt-6 inline-block rounded"><p class="text-sm text-d-dim font-body">${renderInlineMarkup(data.note)}</p></div>`
13
+ : "",
14
+ `</div>`,
15
+ accentBar("accent", "absolute bottom-0 left-0 right-0"),
16
+ ]
17
+ .filter(Boolean)
18
+ .join("\n");
19
+ };
@@ -0,0 +1,2 @@
1
+ import type { WaterfallSlide } from "../schema.js";
2
+ export declare const layoutWaterfall: (data: WaterfallSlide) => string;
@@ -0,0 +1,63 @@
1
+ import { renderInlineMarkup, c, slideHeader, renderOptionalCallout } from "../utils.js";
2
+ /** Height of the chart area as percentage of available space */
3
+ const CHART_HEIGHT_PCT = 75;
4
+ export const layoutWaterfall = (data) => {
5
+ const parts = [slideHeader(data)];
6
+ const items = data.items || [];
7
+ const positions = buildWaterfallPositions(items);
8
+ const globalMax = Math.max(...positions.map((p) => p.top));
9
+ const globalMin = Math.min(...positions.map((p) => p.bottom));
10
+ const range = globalMax - globalMin || 1;
11
+ parts.push(`<div class="flex gap-1 px-12 mt-4 flex-1" style="min-height: 0">`);
12
+ items.forEach((item, i) => {
13
+ const pos = positions[i];
14
+ const isTotal = item.isTotal ?? false;
15
+ const isPositive = item.value >= 0;
16
+ const color = resolveBarColor(item.color, isTotal, isPositive);
17
+ const bottomPct = ((pos.bottom - globalMin) / range) * CHART_HEIGHT_PCT;
18
+ const heightPct = Math.max(((pos.top - pos.bottom) / range) * CHART_HEIGHT_PCT, 1.5);
19
+ const topOfBar = bottomPct + heightPct;
20
+ const labelTopPct = 100 - topOfBar;
21
+ const formattedValue = formatValue(item.value, data.unit, isTotal);
22
+ parts.push(`<div class="flex-1 relative" style="height: 100%">`);
23
+ // Value label (above bar)
24
+ parts.push(` <p class="absolute w-full text-xs font-bold text-d-text font-body text-center" style="top: ${labelTopPct - 4}%">${renderInlineMarkup(formattedValue)}</p>`);
25
+ // Bar (absolute positioned from bottom)
26
+ parts.push(` <div class="absolute left-1 right-1 bg-${c(color)} rounded-t" style="bottom: ${bottomPct}%; height: ${heightPct}%"></div>`);
27
+ // Bottom label
28
+ parts.push(` <p class="absolute bottom-0 w-full text-xs text-d-muted font-body text-center" style="transform: translateY(100%)">${renderInlineMarkup(item.label)}</p>`);
29
+ parts.push(`</div>`);
30
+ });
31
+ parts.push(`</div>`);
32
+ // Labels area
33
+ parts.push(`<div class="h-10 shrink-0"></div>`);
34
+ parts.push(renderOptionalCallout(data.callout));
35
+ return parts.join("\n");
36
+ };
37
+ const buildWaterfallPositions = (items) => {
38
+ let runningTotal = 0;
39
+ return items.map((item) => {
40
+ if (item.isTotal) {
41
+ runningTotal = item.value;
42
+ return { top: Math.max(item.value, 0), bottom: Math.min(item.value, 0) };
43
+ }
44
+ const prevTotal = runningTotal;
45
+ runningTotal += item.value;
46
+ if (item.value >= 0) {
47
+ return { top: runningTotal, bottom: prevTotal };
48
+ }
49
+ return { top: prevTotal, bottom: runningTotal };
50
+ });
51
+ };
52
+ const resolveBarColor = (itemColor, isTotal, isPositive) => {
53
+ if (itemColor)
54
+ return itemColor;
55
+ if (isTotal)
56
+ return "primary";
57
+ return isPositive ? "success" : "danger";
58
+ };
59
+ const formatValue = (value, unit, isTotal) => {
60
+ const prefix = !isTotal && value > 0 ? "+" : "";
61
+ const suffix = unit ? ` ${unit}` : "";
62
+ return `${prefix}${value}${suffix}`;
63
+ };
@@ -0,0 +1,17 @@
1
+ import type { SlideTheme, SlideLayout } from "./schema.js";
2
+ /** Pre-resolved branding data (all sources converted to data URLs) */
3
+ export type ResolvedBranding = {
4
+ logo?: {
5
+ dataUrl: string;
6
+ position: string;
7
+ width: number;
8
+ };
9
+ backgroundImage?: {
10
+ dataUrl: string;
11
+ size: string;
12
+ opacity: number;
13
+ bgOpacity?: number;
14
+ };
15
+ };
16
+ /** Generate a complete HTML document for a single slide */
17
+ export declare const generateSlideHTML: (theme: SlideTheme, slide: SlideLayout, reference?: string, branding?: ResolvedBranding) => string;
package/lib/render.js ADDED
@@ -0,0 +1,101 @@
1
+ import { escapeHtml, buildTailwindConfig, sanitizeHex, detectBlockTypes } from "./utils.js";
2
+ import { renderSlideContent } from "./layouts/index.js";
3
+ /** Determine if a hex color is dark (luminance < 128) */
4
+ const isDarkBg = (hex) => {
5
+ const r = parseInt(hex.slice(0, 2), 16);
6
+ const g = parseInt(hex.slice(2, 4), 16);
7
+ const b = parseInt(hex.slice(4, 6), 16);
8
+ return (r * 299 + g * 587 + b * 114) / 1000 < 128;
9
+ };
10
+ /** Build CDN script tags for chart/mermaid when needed */
11
+ const buildCdnScripts = (theme, slide) => {
12
+ const { hasChart, hasMermaid, chartPlugins } = detectBlockTypes(slide);
13
+ const scripts = [];
14
+ if (hasChart) {
15
+ scripts.push('<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>');
16
+ chartPlugins.forEach((cdn) => {
17
+ scripts.push(`<script src="${cdn}"></script>`);
18
+ });
19
+ }
20
+ if (hasMermaid) {
21
+ const mermaidTheme = isDarkBg(theme.colors.bg) ? "dark" : "default";
22
+ scripts.push(`<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
23
+ <script>mermaid.initialize({startOnLoad:true,theme:'${mermaidTheme}'})</script>`);
24
+ }
25
+ return scripts.join("\n");
26
+ };
27
+ /** Map branding logo position to Tailwind CSS classes */
28
+ const logoPositionClasses = {
29
+ "top-left": "top-5 left-6",
30
+ "top-right": "top-5 right-6",
31
+ "bottom-left": "bottom-5 left-6",
32
+ "bottom-right": "bottom-5 right-6",
33
+ };
34
+ /**
35
+ * Render branding background layers.
36
+ * - Without bgOpacity: image overlaid on slide bg at given opacity
37
+ * - With bgOpacity: image at full opacity, then slide bg color as semi-transparent overlay
38
+ */
39
+ const renderBrandingBackground = (branding, bgHex) => {
40
+ if (!branding.backgroundImage)
41
+ return "";
42
+ const { dataUrl, size, opacity, bgOpacity } = branding.backgroundImage;
43
+ const bgSize = size === "fill" ? "100% 100%" : size;
44
+ if (bgOpacity !== undefined) {
45
+ const parts = [];
46
+ parts.push(`<div class="absolute inset-0 z-0" style="background-image:url('${dataUrl}');background-size:${bgSize};background-position:center;background-repeat:no-repeat;opacity:${opacity}"></div>`);
47
+ parts.push(`<div class="absolute inset-0 z-0" style="background-color:#${bgHex};opacity:${bgOpacity}"></div>`);
48
+ return parts.join("\n");
49
+ }
50
+ return `<div class="absolute inset-0 z-0" style="background-image:url('${dataUrl}');background-size:${bgSize};background-position:center;background-repeat:no-repeat;opacity:${opacity}"></div>`;
51
+ };
52
+ /** Render branding logo element */
53
+ const renderBrandingLogo = (branding) => {
54
+ if (!branding.logo)
55
+ return "";
56
+ const { dataUrl, position, width } = branding.logo;
57
+ const posClasses = logoPositionClasses[position] ?? logoPositionClasses["top-right"];
58
+ return `<img class="absolute ${posClasses} z-10" src="${dataUrl}" width="${width}" alt="" style="pointer-events:none">`;
59
+ };
60
+ /** Generate a complete HTML document for a single slide */
61
+ export const generateSlideHTML = (theme, slide, reference, branding) => {
62
+ const content = renderSlideContent(slide);
63
+ const twConfig = buildTailwindConfig(theme);
64
+ const cdnScripts = buildCdnScripts(theme, slide);
65
+ const slideStyle = slide.style;
66
+ const hasBgOpacity = branding?.backgroundImage?.bgOpacity !== undefined;
67
+ const bgCls = hasBgOpacity || slideStyle?.bgColor ? "" : "bg-d-bg";
68
+ const bgColorStyle = slideStyle?.bgColor ? ` style="background-color:#${sanitizeHex(slideStyle.bgColor)}"` : "";
69
+ const inlineStyle = hasBgOpacity ? "" : bgColorStyle;
70
+ const footer = slideStyle?.footer ? `<p class="absolute bottom-2 right-4 text-xs text-d-dim font-body">${escapeHtml(slideStyle.footer)}</p>` : "";
71
+ const referenceHtml = reference
72
+ ? `<div class="mt-auto px-4 pb-2"><p class="text-sm text-d-muted font-body opacity-80">${escapeHtml(reference)}</p></div>`
73
+ : "";
74
+ const bgHex = sanitizeHex(slideStyle?.bgColor ?? theme.colors.bg);
75
+ const brandingBg = branding ? renderBrandingBackground(branding, bgHex) : "";
76
+ const brandingLogo = branding ? renderBrandingLogo(branding) : "";
77
+ return `<!DOCTYPE html>
78
+ <html lang="en" class="h-full">
79
+ <head>
80
+ <meta charset="UTF-8">
81
+ <meta name="viewport" content="width=1280">
82
+ <script src="https://cdn.tailwindcss.com"></script>
83
+ <script>tailwind.config = ${twConfig}</script>
84
+ ${cdnScripts}
85
+ <style>
86
+ html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; }
87
+ </style>
88
+ </head>
89
+ <body class="h-full">
90
+ <div class="relative overflow-hidden ${bgCls} w-full h-full flex flex-col"${inlineStyle}>
91
+ ${brandingBg}
92
+ <div class="relative z-[1] flex flex-col flex-1">
93
+ ${content}
94
+ ${referenceHtml}
95
+ ${footer}
96
+ </div>
97
+ ${brandingLogo}
98
+ </div>
99
+ </body>
100
+ </html>`;
101
+ };