diff --git a/examples/sample.md b/examples/sample.md index aa1c9d3..533d51d 100644 --- a/examples/sample.md +++ b/examples/sample.md @@ -17,12 +17,8 @@ headings: ## Markdown to Figma Slides -### Agenda - -- Introduction -- Features -- Demo -- Q&A +2025.12.31 +Jone Doe --- diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index c6af885..d6be893 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -155,6 +155,11 @@ interface BackgroundYamlConfig { * Full slide configuration from YAML frontmatter */ export interface SlideConfig { + /** + * Treat the first slide as a cover slide (global frontmatter only). + * Default: true + */ + cover?: boolean; /** * Background configuration - unified property that accepts: * - String: auto-detected as color, gradient, image, or Figma component diff --git a/packages/cli/src/markdown.test.ts b/packages/cli/src/markdown.test.ts index 4032229..2a87742 100644 --- a/packages/cli/src/markdown.test.ts +++ b/packages/cli/src/markdown.test.ts @@ -855,6 +855,36 @@ Text`); }); }); + describe("cover frontmatter", () => { + it("should mark the first slide as cover by default", () => { + const result = parseMarkdown(`# Title + +--- + +## Slide 2`); + + expect(result).toHaveLength(2); + expect(result[0].cover).toBe(true); + expect(result[1].cover).toBeUndefined(); + }); + + it("should disable cover when cover: false is set in global frontmatter", () => { + const result = parseMarkdown(`--- +cover: false +--- + +# Title + +--- + +## Slide 2`); + + expect(result).toHaveLength(2); + expect(result[0].cover).toBeUndefined(); + expect(result[1].cover).toBeUndefined(); + }); + }); + describe("footnotes", () => { it("parses footnote references in text", () => { const result = parseMarkdown(`## Test diff --git a/packages/cli/src/markdown.ts b/packages/cli/src/markdown.ts index f609d75..61061b8 100644 --- a/packages/cli/src/markdown.ts +++ b/packages/cli/src/markdown.ts @@ -804,6 +804,7 @@ export function parseMarkdown( let globalDefaultAlign: HorizontalAlign | undefined; let globalDefaultValign: VerticalAlign | undefined; let globalDefaultTransition: SlideTransitionConfig | undefined; + let globalCoverEnabled = true; let contentWithoutGlobalFrontmatter = processedMarkdown; const slides: SlideContent[] = []; @@ -812,6 +813,9 @@ export function parseMarkdown( if (frontmatterMatch) { try { const config = parseYaml(frontmatterMatch[1]) as SlideConfig; + if (typeof config.cover === "boolean") { + globalCoverEnabled = config.cover; + } const { background, styles, @@ -861,5 +865,9 @@ export function parseMarkdown( } } + if (globalCoverEnabled && slides.length > 0) { + slides[0].cover = true; + } + return slides; } diff --git a/packages/docs/src/content/docs/en/api-reference.md b/packages/docs/src/content/docs/en/api-reference.md index b5c1f83..c25aee4 100644 --- a/packages/docs/src/content/docs/en/api-reference.md +++ b/packages/docs/src/content/docs/en/api-reference.md @@ -155,6 +155,7 @@ color: "#58a6ff" | Property | Type | Description | |----------|------|-------------| +| `cover` | `boolean` | Treat the first slide as a cover (default: `true`) | | `background` | `string \| object` | Unified background config: string (color/gradient/image/component) or object (`color`, `gradient`, `template`, `image`, `component`) | | `color` | `string` | Base text color (applied to all elements) | | `headings` | `object` | Heading styles (h1-h4) | diff --git a/packages/docs/src/content/docs/en/markdown-spec.md b/packages/docs/src/content/docs/en/markdown-spec.md index d0c19cc..1ba28aa 100644 --- a/packages/docs/src/content/docs/en/markdown-spec.md +++ b/packages/docs/src/content/docs/en/markdown-spec.md @@ -85,6 +85,7 @@ Back to global settings (dark background, dissolve) | Setting | Description | |---------|-------------| | `figdeck` | Enable VSCode extension features (`true`/`false`) | +| `cover` | Treat the first slide as a cover (`true`/`false`, default: `true`) | | `background` | Unified background: color, gradient, image, template, or Figma component | | `color` | Base text color | | `headings` | Heading styles (h1, h2, h3, h4) | diff --git a/packages/docs/src/content/docs/ja/api-reference.md b/packages/docs/src/content/docs/ja/api-reference.md index f4804d4..5ffc475 100644 --- a/packages/docs/src/content/docs/ja/api-reference.md +++ b/packages/docs/src/content/docs/ja/api-reference.md @@ -155,6 +155,7 @@ color: "#58a6ff" | プロパティ | 型 | 説明 | |------------|------|------| +| `cover` | `boolean` | 1枚目を表紙として扱う(デフォルト: `true`) | | `background` | `string \| object` | 統一された背景設定: string(色/グラデーション/画像/コンポーネント)または object(`color`, `gradient`, `template`, `image`, `component`) | | `color` | `string` | 基本テキスト色(全要素に適用) | | `headings` | `object` | 見出しスタイル(h1〜h4) | diff --git a/packages/docs/src/content/docs/ja/markdown-spec.md b/packages/docs/src/content/docs/ja/markdown-spec.md index e68246c..55fa472 100644 --- a/packages/docs/src/content/docs/ja/markdown-spec.md +++ b/packages/docs/src/content/docs/ja/markdown-spec.md @@ -85,6 +85,7 @@ transition: slide-from-right | 設定 | 説明 | |------|------| | `figdeck` | VSCode 拡張機能の機能を有効化(`true`/`false`) | +| `cover` | 1枚目を表紙として扱う(`true`/`false`、デフォルト: `true`) | | `background` | 統合背景設定:色、グラデーション、画像、テンプレート、Figma コンポーネント | | `color` | ベーステキスト色 | | `headings` | 見出しスタイル(h1, h2, h3, h4) | diff --git a/packages/plugin/src/code.test.ts b/packages/plugin/src/code.test.ts index 64b7e16..319e666 100644 --- a/packages/plugin/src/code.test.ts +++ b/packages/plugin/src/code.test.ts @@ -42,6 +42,38 @@ beforeEach(() => { }); describe("validateAndSanitizeSlides", () => { + it("preserves cover flag when boolean", async () => { + const { validateAndSanitizeSlides } = await import("./code"); + + const result = validateAndSanitizeSlides([ + { + cover: true, + blocks: [{ kind: "paragraph", text: "Cover" }], + }, + ]); + + expect(result.valid).toBe(true); + if (!result.valid) return; + + expect(result.slides[0].cover).toBe(true); + }); + + it("drops cover flag when not boolean", async () => { + const { validateAndSanitizeSlides } = await import("./code"); + + const result = validateAndSanitizeSlides([ + { + cover: "true", + blocks: [{ kind: "paragraph", text: "Not cover" }], + }, + ]); + + expect(result.valid).toBe(true); + if (!result.valid) return; + + expect(result.slides[0].cover).toBeUndefined(); + }); + it("preserves BulletItem hierarchy while sanitizing text", async () => { const { validateAndSanitizeSlides } = await import("./code"); diff --git a/packages/plugin/src/code.ts b/packages/plugin/src/code.ts index 4753bd4..6ff9822 100644 --- a/packages/plugin/src/code.ts +++ b/packages/plugin/src/code.ts @@ -73,6 +73,11 @@ function computeSlideHash(slide: SlideContent): string { // Default spacing between prefix component and title text const DEFAULT_PREFIX_SPACING = 16; +// Cover slide defaults +const COVER_DEFAULT_H1_SIZE = 80; +const COVER_DEFAULT_H2_SIZE = 48; +const COVER_INFO_ITEM_SPACING = 8; + /** * Map CLI's CalloutType (lowercase) to Plugin's AlertType (uppercase) */ @@ -855,6 +860,211 @@ async function fillSlide( } } +/** + * Fill a cover slide with title/subtitle centered and paragraphs bottom-left. + */ +async function fillCoverSlide( + slideNode: SlideNode, + slide: SlideContent, + availableFonts: Set, +) { + const resolvedStyles = applyFontFallbacks( + resolveSlideStyles(slide.styles), + availableFonts, + ); + + // Apply cover-only default heading sizes unless explicitly set in styles. + const styles = Object.assign({}, resolvedStyles, { + h1: Object.assign({}, resolvedStyles.h1, { + fontSize: slide.styles?.headings?.h1?.size ?? COVER_DEFAULT_H1_SIZE, + }), + h2: Object.assign({}, resolvedStyles.h2, { + fontSize: slide.styles?.headings?.h2?.size ?? COVER_DEFAULT_H2_SIZE, + }), + }); + + // Centered container for non-paragraph content + const container = createContentContainer( + slideNode.width, + slideNode.height, + "center", + "middle", + ); + + // Collect nodes that need absolute positioning (to be added after container) + const absoluteNodes: Array<{ node: SceneNode; x: number; y: number }> = []; + + // Track if we've rendered the first title (H1/H2) for prefix support + let firstTitleRendered = false; + + // Collect paragraph blocks for bottom-left placement + const coverInfoBlocks = slide.blocks.filter( + (b): b is Extract => + b.kind === "paragraph", + ); + const mainBlocks = slide.blocks.filter((b) => b.kind !== "paragraph"); + + // Render main blocks into the centered container + for (const block of mainBlocks) { + // Handle figma blocks with custom position separately + if ( + block.kind === "figma" && + (block.link.x !== undefined || block.link.y !== undefined) + ) { + const figmaNode = await renderFigmaLink(block.link); + absoluteNodes.push({ + node: figmaNode, + x: block.link.x ?? 0, + y: block.link.y ?? 0, + }); + continue; + } + + // Handle image blocks with custom position separately + if ( + block.kind === "image" && + block.position && + (block.position.x !== undefined || block.position.y !== undefined) + ) { + const imageNode = await renderImage({ + url: block.url, + alt: block.alt, + mimeType: block.mimeType, + dataBase64: block.dataBase64, + source: block.source, + size: block.size, + }); + absoluteNodes.push({ + node: imageNode, + x: block.position.x ?? 0, + y: block.position.y ?? 0, + }); + continue; + } + + // Determine the style for this block kind + const blockStyle = getStyleForBlock(block.kind, styles); + + // Check if this block has absolute positioning via style + if ( + blockStyle && + (blockStyle.x !== undefined || blockStyle.y !== undefined) + ) { + const blockNode = await renderBlockToNode( + block, + styles, + LAYOUT.CONTENT_WIDTH, + ); + if (blockNode) { + absoluteNodes.push({ + node: blockNode, + x: blockStyle.x ?? 0, + y: blockStyle.y ?? 0, + }); + } + continue; + } + + // Special handling for first H1/H2 heading with titlePrefix + if ( + block.kind === "heading" && + (block.level === 1 || block.level === 2) && + !firstTitleRendered && + slide.titlePrefix + ) { + const titleStyle = block.level === 1 ? styles.h1 : styles.h2; + const titleNode = await renderTitleToNode( + block.text, + titleStyle, + slide.titlePrefix, + ); + container.appendChild(titleNode); + firstTitleRendered = true; + continue; + } + + // Mark first H1/H2 as rendered even without prefix + if ( + block.kind === "heading" && + (block.level === 1 || block.level === 2) && + !firstTitleRendered + ) { + firstTitleRendered = true; + } + + const blockNode = await renderBlockToNode( + block, + styles, + LAYOUT.CONTENT_WIDTH, + ); + if (blockNode) { + container.appendChild(blockNode); + } + } + + // Add container to slide and explicitly set position to origin + slideNode.appendChild(container); + container.x = 0; + container.y = 0; + + // Render cover info (paragraphs) at bottom-left + let coverInfoHeight = 0; + if (coverInfoBlocks.length > 0) { + const infoFrame = figma.createFrame(); + infoFrame.name = "Cover Info"; + infoFrame.layoutMode = "VERTICAL"; + infoFrame.primaryAxisSizingMode = "AUTO"; + infoFrame.counterAxisSizingMode = "AUTO"; + infoFrame.itemSpacing = COVER_INFO_ITEM_SPACING; + infoFrame.fills = []; + infoFrame.primaryAxisAlignItems = "MIN"; + infoFrame.counterAxisAlignItems = "MIN"; + + for (const block of coverInfoBlocks) { + const result = await renderParagraph( + block.text, + block.spans, + styles.paragraph, + 0, + 0, + styles.code.font, + ); + infoFrame.appendChild(result.node); + } + + coverInfoHeight = infoFrame.height; + infoFrame.x = LAYOUT.CONTAINER_PADDING; + infoFrame.y = + slideNode.height - infoFrame.height - LAYOUT.CONTAINER_PADDING; + slideNode.appendChild(infoFrame); + } + + // Add absolutely positioned nodes to slide + for (const { node, x, y } of absoluteNodes) { + slideNode.appendChild(node); + node.x = x; + node.y = y; + } + + // Render footnotes at the bottom of the slide (outside container), above cover info if present + if (slide.footnotes && slide.footnotes.length > 0) { + const footnotesNode = await renderFootnotes( + slide.footnotes, + styles.paragraph.fontSize, + styles.paragraph.fills, + styles.paragraph.font, + styles.code.font, + ); + footnotesNode.x = LAYOUT.CONTAINER_PADDING; + footnotesNode.y = + slideNode.height - + footnotesNode.height - + 40 - + (coverInfoHeight > 0 ? coverInfoHeight + COVER_INFO_ITEM_SPACING : 0); + slideNode.appendChild(footnotesNode); + } +} + function findExistingSlides(): Map { const slideMap = new Map(); const grid = figma.getSlideGrid(); @@ -950,7 +1160,11 @@ async function generateSlides(slides: SlideContent[]) { await applyBackground(node, slide.background); } - await fillSlide(node, slide, availableFonts); + if (i === 0 && slide.cover === true) { + await fillCoverSlide(node, slide, availableFonts); + } else { + await fillSlide(node, slide, availableFonts); + } // Render slide number if configured if (slide.slideNumber) { @@ -1203,6 +1417,11 @@ export function validateAndSanitizeSlides( // Sanitize strings in blocks (use Object.assign for Figma sandbox compatibility) const sanitizedSlide = Object.assign({}, slide) as unknown as SlideContent; + // Only keep the cover flag if it's a boolean + if (typeof slide.cover !== "boolean") { + delete (sanitizedSlide as { cover?: unknown }).cover; + } + // Sanitize text content in blocks if (Array.isArray(sanitizedSlide.blocks)) { const validBlocks: SlideBlock[] = []; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5c5c2bc..d3a4a13 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -383,6 +383,8 @@ export interface SlideContent { styles?: SlideStyles; slideNumber?: SlideNumberConfig; titlePrefix?: TitlePrefixConfig | null; + /** Treat this slide as a cover slide (typically the first slide). */ + cover?: boolean; /** Horizontal alignment of slide content */ align?: HorizontalAlign; /** Vertical alignment of slide content */ diff --git a/packages/vscode/snippets/figdeck.json b/packages/vscode/snippets/figdeck.json index cc272d0..3667fe9 100644 --- a/packages/vscode/snippets/figdeck.json +++ b/packages/vscode/snippets/figdeck.json @@ -7,6 +7,7 @@ "color: \"${2:#ffffff}\"", "align: ${3|left,center,right|}", "valign: ${4|top,middle,bottom|}", + "cover: ${5|true,false|}", "---", "", "$0" @@ -21,15 +22,16 @@ "color: \"${2:#ffffff}\"", "align: ${3|left,center,right|}", "valign: ${4|top,middle,bottom|}", + "cover: ${5|true,false|}", "transition:", - " style: ${5|dissolve,smart-animate,slide-from-left,slide-from-right,slide-from-top,slide-from-bottom,push-from-left,push-from-right,push-from-top,push-from-bottom|}", - " duration: ${6:0.5}", - " curve: ${7|ease-out,ease-in,ease-in-and-out,linear,gentle,quick,bouncy,slow|}", + " style: ${6|dissolve,smart-animate,slide-from-left,slide-from-right,slide-from-top,slide-from-bottom,push-from-left,push-from-right,push-from-top,push-from-bottom|}", + " duration: ${7:0.5}", + " curve: ${8|ease-out,ease-in,ease-in-and-out,linear,gentle,quick,bouncy,slow|}", "slideNumber:", " show: true", - " position: ${8|bottom-right,bottom-left,top-right,top-left|}", - " size: ${9:14}", - " color: \"${10:#888888}\"", + " position: ${9|bottom-right,bottom-left,top-right,top-left|}", + " size: ${10:14}", + " color: \"${11:#888888}\"", " format: \"\\${current} / \\${total}\"", "---", "", diff --git a/packages/vscode/src/diagnostics/analyzer.test.ts b/packages/vscode/src/diagnostics/analyzer.test.ts index 6173e45..e3abf42 100644 --- a/packages/vscode/src/diagnostics/analyzer.test.ts +++ b/packages/vscode/src/diagnostics/analyzer.test.ts @@ -164,6 +164,20 @@ describe("validateFrontmatter", () => { ); }); + it("should accept cover boolean", () => { + const lines = ["---", "cover: false", "---"]; + const issues = validateFrontmatter(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect invalid cover type", () => { + const lines = ["---", "cover: 1", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-type")).toBe( + true, + ); + }); + it("should validate nested properties", () => { const lines = ["---", "slideNumber:", " position: invalid-pos", "---"]; const issues = validateFrontmatter(lines); diff --git a/packages/vscode/src/frontmatter-spec.ts b/packages/vscode/src/frontmatter-spec.ts index 12e0247..fa349cd 100644 --- a/packages/vscode/src/frontmatter-spec.ts +++ b/packages/vscode/src/frontmatter-spec.ts @@ -183,6 +183,10 @@ export const FRONTMATTER_SPEC: Record = { kind: "boolean", description: "Enable figdeck processing for this file", }, + cover: { + kind: "boolean", + description: "Treat the first slide as a cover (default: true)", + }, background: { kind: "oneOf", description: