From 09392b63f90e9dd2859fc97a32f847738b3410c1 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:41:58 +0900 Subject: [PATCH 01/11] feat: add VSCode extension for figdeck with enhanced markdown support and diagnostics --- CLAUDE.md | 1 + bun.lock | 71 ++ examples/absolute-position.md | 4 + examples/backgrounds.md | 1 + examples/bullets.md | 1 + examples/columns.md | 1 + examples/figma-links.md | 4 + examples/font-sizes.md | 1 + examples/fonts.md | 1 + examples/footnotes.md | 4 + examples/images.md | 4 + examples/rich-formatting.md | 1 + examples/sample.md | 1 + examples/slide-numbers.md | 1 + examples/transitions.md | 1 + packages/cli/templates/init.md | 1 + .../docs/src/content/docs/en/markdown-spec.md | 6 + .../docs/src/content/docs/ja/markdown-spec.md | 6 + packages/vscode/README.md | 114 +++ packages/vscode/package.json | 164 ++++ packages/vscode/snippets/figdeck.json | 288 +++++++ .../src/authoring/frontmatterCompletion.ts | 437 +++++++++++ .../vscode/src/authoring/slideOutline.test.ts | 498 ++++++++++++ packages/vscode/src/authoring/slideOutline.ts | 189 +++++ packages/vscode/src/authoring/slideParser.ts | 304 ++++++++ .../vscode/src/diagnostics/analyzer.test.ts | 716 ++++++++++++++++++ packages/vscode/src/diagnostics/analyzer.ts | 584 ++++++++++++++ .../vscode/src/diagnostics/codeActions.ts | 149 ++++ packages/vscode/src/diagnostics/collection.ts | 205 +++++ .../src/diagnostics/frontmatterValidator.ts | 655 ++++++++++++++++ packages/vscode/src/diagnostics/types.ts | 83 ++ packages/vscode/src/extension.ts | 210 +++++ packages/vscode/src/ops/figdeckCli.ts | 202 +++++ packages/vscode/src/ops/serverManager.ts | 253 +++++++ ...figdeck-markdown.injection.tmLanguage.json | 207 +++++ packages/vscode/tsconfig.json | 18 + 36 files changed, 5386 insertions(+) create mode 100644 packages/vscode/README.md create mode 100644 packages/vscode/package.json create mode 100644 packages/vscode/snippets/figdeck.json create mode 100644 packages/vscode/src/authoring/frontmatterCompletion.ts create mode 100644 packages/vscode/src/authoring/slideOutline.test.ts create mode 100644 packages/vscode/src/authoring/slideOutline.ts create mode 100644 packages/vscode/src/authoring/slideParser.ts create mode 100644 packages/vscode/src/diagnostics/analyzer.test.ts create mode 100644 packages/vscode/src/diagnostics/analyzer.ts create mode 100644 packages/vscode/src/diagnostics/codeActions.ts create mode 100644 packages/vscode/src/diagnostics/collection.ts create mode 100644 packages/vscode/src/diagnostics/frontmatterValidator.ts create mode 100644 packages/vscode/src/diagnostics/types.ts create mode 100644 packages/vscode/src/extension.ts create mode 100644 packages/vscode/src/ops/figdeckCli.ts create mode 100644 packages/vscode/src/ops/serverManager.ts create mode 100644 packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json create mode 100644 packages/vscode/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index 85319fa..283dd7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,6 +211,7 @@ Global settings at file start, or per-slide after `---`: ```yaml --- +figdeck: true # Required for VSCode extension to recognize file background: "#1a1a2e" # Solid color gradient: "#0d1117:0%,#1f2937:50%,#58a6ff:100%@45" # Gradient with angle template: "Style Name" # Figma paint style name diff --git a/bun.lock b/bun.lock index 9e9bec2..dee4f53 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,21 @@ "typescript": "^5.3.3", }, }, + "packages/vscode": { + "name": "figdeck-vscode", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.8.1", + }, + "devDependencies": { + "@figdeck/shared": "workspace:*", + "@types/bun": "^1.3.3", + "@types/node": "^22.10.0", + "@types/vscode": "^1.85.0", + "esbuild": "^0.24.0", + "typescript": "^5.3.3", + }, + }, }, "packages": { "@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="], @@ -350,6 +365,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/vscode": ["@types/vscode@1.107.0", "", {}, "sha512-XS8YE1jlyTIowP64+HoN30OlC1H9xqSlq1eoLZUgFEC8oUTO6euYZxti1xRiLSfZocs4qytTzR6xCBYtioQTCg=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -538,6 +555,8 @@ "figdeck": ["figdeck@workspace:packages/cli"], + "figdeck-vscode": ["figdeck-vscode@workspace:packages/vscode"], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], @@ -1068,6 +1087,8 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "figdeck-vscode/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], @@ -1138,6 +1159,56 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "figdeck-vscode/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + + "figdeck-vscode/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + + "figdeck-vscode/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + + "figdeck-vscode/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + + "figdeck-vscode/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + + "figdeck-vscode/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + + "figdeck-vscode/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + + "figdeck-vscode/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + + "figdeck-vscode/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + + "figdeck-vscode/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + + "figdeck-vscode/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + + "figdeck-vscode/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + + "figdeck-vscode/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + + "figdeck-vscode/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + + "figdeck-vscode/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + + "figdeck-vscode/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + + "figdeck-vscode/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + + "figdeck-vscode/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + + "figdeck-vscode/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + + "figdeck-vscode/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + + "figdeck-vscode/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + + "figdeck-vscode/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + + "figdeck-vscode/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "figdeck-vscode/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "figdeck-vscode/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], diff --git a/examples/absolute-position.md b/examples/absolute-position.md index 8b62bc4..b7ceff0 100644 --- a/examples/absolute-position.md +++ b/examples/absolute-position.md @@ -1,3 +1,7 @@ +--- +figdeck: true +--- + # Absolute Positioning Example This example demonstrates how to use absolute x/y positioning for text elements. diff --git a/examples/backgrounds.md b/examples/backgrounds.md index 87ee1b9..fb8dcdf 100644 --- a/examples/backgrounds.md +++ b/examples/backgrounds.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#1a1a2e" color: "#ffffff" --- diff --git a/examples/bullets.md b/examples/bullets.md index 41ebc85..870e85f 100644 --- a/examples/bullets.md +++ b/examples/bullets.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#1a1a2e" color: "#ffffff" --- diff --git a/examples/columns.md b/examples/columns.md index da0c5df..ec4fbc9 100644 --- a/examples/columns.md +++ b/examples/columns.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#1a1a2e" color: "#ffffff" --- diff --git a/examples/figma-links.md b/examples/figma-links.md index 78c446d..27f2fae 100644 --- a/examples/figma-links.md +++ b/examples/figma-links.md @@ -1,3 +1,7 @@ +--- +figdeck: true +--- + # Figma Selection Links Demo This slide demonstrates the `:::figma` block feature. diff --git a/examples/font-sizes.md b/examples/font-sizes.md index 03cda39..a31c3ff 100644 --- a/examples/font-sizes.md +++ b/examples/font-sizes.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#1a1a2e" headings: h1: diff --git a/examples/fonts.md b/examples/fonts.md index 62d3b5b..b995a5d 100644 --- a/examples/fonts.md +++ b/examples/fonts.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#ffffff" # Font configuration for slides # Supports per-element font families with style variants diff --git a/examples/footnotes.md b/examples/footnotes.md index 2e7045f..5d61a6c 100644 --- a/examples/footnotes.md +++ b/examples/footnotes.md @@ -1,3 +1,7 @@ +--- +figdeck: true +--- + # Footnotes Sample Demonstrating footnote support in figdeck diff --git a/examples/images.md b/examples/images.md index 7771026..6147c6e 100644 --- a/examples/images.md +++ b/examples/images.md @@ -1,3 +1,7 @@ +--- +figdeck: true +--- + # Image Size and Position Examples Demonstrates Marp-style image size and position specifications. diff --git a/examples/rich-formatting.md b/examples/rich-formatting.md index 245aef6..9fbc72b 100644 --- a/examples/rich-formatting.md +++ b/examples/rich-formatting.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#ffffff" --- # Rich Markdown Features diff --git a/examples/sample.md b/examples/sample.md index e764c74..aa1c9d3 100644 --- a/examples/sample.md +++ b/examples/sample.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#ffffff" color: "#1a1a2e" headings: diff --git a/examples/slide-numbers.md b/examples/slide-numbers.md index 65c0b18..2146f43 100644 --- a/examples/slide-numbers.md +++ b/examples/slide-numbers.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#1a1a2e" slideNumber: show: true diff --git a/examples/transitions.md b/examples/transitions.md index 980f126..28b264b 100644 --- a/examples/transitions.md +++ b/examples/transitions.md @@ -1,4 +1,5 @@ --- +figdeck: true transition: style: dissolve duration: 0.5 diff --git a/packages/cli/templates/init.md b/packages/cli/templates/init.md index 06ffa99..dcbb2a7 100644 --- a/packages/cli/templates/init.md +++ b/packages/cli/templates/init.md @@ -1,4 +1,5 @@ --- +figdeck: true background: "#ffffff" color: "#1a1a2e" headings: diff --git a/packages/docs/src/content/docs/en/markdown-spec.md b/packages/docs/src/content/docs/en/markdown-spec.md index a4587ce..3970951 100644 --- a/packages/docs/src/content/docs/en/markdown-spec.md +++ b/packages/docs/src/content/docs/en/markdown-spec.md @@ -28,8 +28,13 @@ YAML frontmatter allows you to configure slide settings. There are two types of Settings at the very beginning of the file (before any content) apply to **all slides** as defaults. +:::note +When using the VSCode extension, add `figdeck: true` to the global frontmatter to enable figdeck features (diagnostics, slide outline, completions). Without this flag, regular markdown files will not be processed by the extension. +::: + ```markdown --- +figdeck: true background: "#1a1a2e" color: "#ffffff" transition: dissolve @@ -79,6 +84,7 @@ Back to global settings (dark background, dissolve) | Setting | Description | |---------|-------------| +| `figdeck` | Enable VSCode extension features (`true`/`false`) | | `background` | Background color (hex) | | `gradient` | Gradient background | | `backgroundImage` | Background image (local path or URL) | diff --git a/packages/docs/src/content/docs/ja/markdown-spec.md b/packages/docs/src/content/docs/ja/markdown-spec.md index 1060775..f2f2014 100644 --- a/packages/docs/src/content/docs/ja/markdown-spec.md +++ b/packages/docs/src/content/docs/ja/markdown-spec.md @@ -28,8 +28,13 @@ YAML frontmatter を使用してスライドの設定を行えます。設定に ファイルの先頭(コンテンツの前)に配置した設定は、**すべてのスライド**にデフォルトとして適用されます。 +:::note +VSCode 拡張機能を使用する場合は、グローバル frontmatter に `figdeck: true` を追加して figdeck の機能(診断、スライドアウトライン、補完)を有効にしてください。このフラグがない通常の Markdown ファイルは拡張機能で処理されません。 +::: + ```markdown --- +figdeck: true background: "#1a1a2e" color: "#ffffff" transition: dissolve @@ -79,6 +84,7 @@ transition: slide-from-right | 設定 | 説明 | |------|------| +| `figdeck` | VSCode 拡張機能の機能を有効化(`true`/`false`) | | `background` | 背景色(16進数) | | `gradient` | グラデーション背景 | | `backgroundImage` | 背景画像(ローカルパスまたはURL) | diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 0000000..49060b6 --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,114 @@ +# figdeck VS Code Extension + +VS Code extension for [figdeck](https://github.com/7nohe/figdeck) - Markdown to Figma Slides. + +## Features + +### Snippets + +Quick insertion of figdeck-specific syntax: + +- `figdeck-global` - Global frontmatter with background, color, align, valign +- `figdeck-slide` - New slide with separator +- `figdeck-transition` - Slide with transition animation +- `:::columns2/3/4` - Column layouts (2-4 columns) +- `:::figma` - Figma link block +- `figdeck-gradient` - Gradient background +- And more... + +### Syntax Highlighting + +Enhanced highlighting for figdeck-specific syntax: + +- `:::columns` / `:::column` / `:::figma` directives +- `key=value` attributes (link, gap, width, x, y, hideLink, text.*) +- Image size/position specs (`w:`, `h:`, `x:`, `y:`) + +### Slide Outline + +Tree view in the Explorer sidebar showing all slides: + +- Click to jump to slide +- Shows slide number and title +- Updates automatically on document changes + +Commands: +- `figdeck: Go to Next Slide` +- `figdeck: Go to Previous Slide` + +### Diagnostics + +Real-time validation of figdeck Markdown: + +- Unclosed frontmatter blocks +- Unsupported image formats +- Invalid image size/position values +- Missing `link=` in `:::figma` blocks +- Invalid Figma URLs +- Column count validation +- Gap/width parameter validation + +### Quick Fixes + +CodeActions to fix common issues: + +- Add `link=` property to figma blocks +- Clamp gap to maximum value + +### CLI Integration + +Run figdeck commands from VS Code: + +- `figdeck: Init slides.md` - Create new slides file +- `figdeck: Build JSON (current file)` - Build to JSON +- `figdeck: Start Serve` - Start WebSocket server +- `figdeck: Stop Serve` - Stop WebSocket server +- `figdeck: Restart Serve` - Restart WebSocket server +- `figdeck: Show Output` - Show output channel + +Status bar shows serve status and port. + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `figdeck.cli.command` | `null` | Custom CLI command (e.g., `["bunx", "figdeck"]`) | +| `figdeck.serve.host` | `"127.0.0.1"` | Host for serve command | +| `figdeck.serve.port` | `4141` | Port for serve command | +| `figdeck.diagnostics.enabled` | `true` | Enable diagnostics | +| `figdeck.diagnostics.debounceMs` | `300` | Debounce time for diagnostics | +| `figdeck.images.maxSizeMb` | `5` | Maximum image file size in MB | + +## CLI Detection + +The extension looks for figdeck CLI in this order: + +1. `node_modules/.bin/figdeck` in workspace +2. `figdeck` in PATH +3. `figdeck.cli.command` setting + +If not found, you'll be prompted to install or configure it. + +## Development + +```bash +# Build extension +cd packages/vscode +bun run build + +# Watch mode +bun run dev + +# Type check +bun run typecheck +``` + +### Debugging + +1. Open VS Code in the figdeck workspace +2. Press F5 to launch Extension Development Host +3. Open a Markdown file to activate the extension + +## License + +MIT diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..3f1cbbc --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,164 @@ +{ + "name": "figdeck-vscode", + "displayName": "figdeck", + "description": "VS Code extension for figdeck - Markdown to Figma Slides", + "version": "0.1.0", + "publisher": "figdeck", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/7nohe/figdeck.git", + "directory": "packages/vscode" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other", + "Snippets", + "Programming Languages" + ], + "keywords": [ + "figma", + "slides", + "markdown", + "presentation" + ], + "activationEvents": [ + "onLanguage:markdown" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "figdeck.init", + "title": "figdeck: Init slides.md" + }, + { + "command": "figdeck.build", + "title": "figdeck: Build JSON (current file)" + }, + { + "command": "figdeck.serve.start", + "title": "figdeck: Start Serve" + }, + { + "command": "figdeck.serve.stop", + "title": "figdeck: Stop Serve" + }, + { + "command": "figdeck.serve.restart", + "title": "figdeck: Restart Serve" + }, + { + "command": "figdeck.serve.quickPick", + "title": "figdeck: Serve Actions" + }, + { + "command": "figdeck.showOutput", + "title": "figdeck: Show Output" + }, + { + "command": "figdeck.nextSlide", + "title": "figdeck: Go to Next Slide" + }, + { + "command": "figdeck.previousSlide", + "title": "figdeck: Go to Previous Slide" + }, + { + "command": "figdeck.refreshOutline", + "title": "figdeck: Refresh Slide Outline" + } + ], + "views": { + "explorer": [ + { + "id": "figdeck.slideOutline", + "name": "Slide Outline", + "when": "resourceLangId == markdown" + } + ] + }, + "snippets": [ + { + "language": "markdown", + "path": "./snippets/figdeck.json" + } + ], + "grammars": [ + { + "scopeName": "figdeck.markdown.injection", + "path": "./syntaxes/figdeck-markdown.injection.tmLanguage.json", + "injectTo": [ + "text.html.markdown" + ] + } + ], + "configuration": { + "title": "figdeck", + "properties": { + "figdeck.cli.command": { + "type": "array", + "items": { + "type": "string" + }, + "default": null, + "description": "Custom command to run figdeck CLI. Default: npx figdeck@latest (e.g., [\"bunx\", \"figdeck\"] or [\"npx\", \"figdeck\"])" + }, + "figdeck.serve.host": { + "type": "string", + "default": "127.0.0.1", + "description": "Host for figdeck serve command" + }, + "figdeck.serve.port": { + "type": "number", + "default": 4141, + "description": "Port for figdeck serve command" + }, + "figdeck.diagnostics.enabled": { + "type": "boolean", + "default": true, + "description": "Enable diagnostics for figdeck Markdown files" + }, + "figdeck.diagnostics.debounceMs": { + "type": "number", + "default": 300, + "description": "Debounce time in milliseconds for diagnostics" + }, + "figdeck.images.maxSizeMb": { + "type": "number", + "default": 5, + "description": "Maximum image file size in MB" + } + } + }, + "configurationDefaults": { + "[markdown]": { + "editor.quickSuggestions": { + "other": "on", + "comments": "off", + "strings": "off" + } + } + } + }, + "scripts": { + "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap", + "dev": "bun run build --watch", + "typecheck": "tsc --noEmit", + "lint": "biome lint src", + "test": "bun test src" + }, + "devDependencies": { + "@figdeck/shared": "workspace:*", + "@types/bun": "^1.3.3", + "@types/node": "^22.10.0", + "@types/vscode": "^1.85.0", + "esbuild": "^0.24.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "yaml": "^2.8.1" + } +} diff --git a/packages/vscode/snippets/figdeck.json b/packages/vscode/snippets/figdeck.json new file mode 100644 index 0000000..328391f --- /dev/null +++ b/packages/vscode/snippets/figdeck.json @@ -0,0 +1,288 @@ +{ + "figdeck: Global Frontmatter": { + "prefix": ["figdeck-global", "---global"], + "body": [ + "---", + "background: \"${1:#1a1a2e}\"", + "color: \"${2:#ffffff}\"", + "align: ${3|left,center,right|}", + "valign: ${4|top,middle,bottom|}", + "---", + "", + "$0" + ], + "description": "Global YAML frontmatter for figdeck slides" + }, + "figdeck: Global Frontmatter (Full)": { + "prefix": ["figdeck-global-full"], + "body": [ + "---", + "background: \"${1:#1a1a2e}\"", + "color: \"${2:#ffffff}\"", + "align: ${3|left,center,right|}", + "valign: ${4|top,middle,bottom|}", + "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|}", + "slideNumber:", + " show: true", + " position: ${8|bottom-right,bottom-left,top-right,top-left|}", + " size: ${9:14}", + " color: \"${10:#888888}\"", + " format: \"\\${current} / \\${total}\"", + "---", + "", + "$0" + ], + "description": "Full global YAML frontmatter with all options" + }, + "figdeck: Slide Separator": { + "prefix": ["---slide", "figdeck-slide"], + "body": [ + "---", + "", + "## ${1:Slide Title}", + "", + "$0" + ], + "description": "New slide with separator and title" + }, + "figdeck: Slide with Frontmatter": { + "prefix": ["---slide-fm", "figdeck-slide-fm"], + "body": [ + "---", + "${1:background: \"#1a1a2e\"}", + "---", + "", + "## ${2:Slide Title}", + "", + "$0" + ], + "description": "New slide with per-slide frontmatter" + }, + "figdeck: Slide with Transition": { + "prefix": ["---slide-transition", "figdeck-transition"], + "body": [ + "---", + "transition: ${1|dissolve,smart-animate,slide-from-left,slide-from-right,slide-from-top,slide-from-bottom,push-from-left,push-from-right|} ${2:0.5}", + "---", + "", + "## ${3:Slide Title}", + "", + "$0" + ], + "description": "New slide with transition animation" + }, + "figdeck: Two Columns": { + "prefix": [":::columns2", "figdeck-columns2", "columns2"], + "body": [ + ":::columns", + ":::column", + "${1:Left column content}", + "", + ":::column", + "${2:Right column content}", + ":::", + "$0" + ], + "description": "Two-column layout" + }, + "figdeck: Three Columns": { + "prefix": [":::columns3", "figdeck-columns3", "columns3"], + "body": [ + ":::columns", + ":::column", + "${1:Column 1}", + "", + ":::column", + "${2:Column 2}", + "", + ":::column", + "${3:Column 3}", + ":::", + "$0" + ], + "description": "Three-column layout" + }, + "figdeck: Four Columns": { + "prefix": [":::columns4", "figdeck-columns4", "columns4"], + "body": [ + ":::columns", + ":::column", + "${1:Column 1}", + "", + ":::column", + "${2:Column 2}", + "", + ":::column", + "${3:Column 3}", + "", + ":::column", + "${4:Column 4}", + ":::", + "$0" + ], + "description": "Four-column layout" + }, + "figdeck: Columns with Gap": { + "prefix": [":::columns-gap", "figdeck-columns-gap"], + "body": [ + ":::columns gap=${1:64}", + ":::column", + "${2:Left column}", + "", + ":::column", + "${3:Right column}", + ":::", + "$0" + ], + "description": "Two-column layout with custom gap" + }, + "figdeck: Columns with Width": { + "prefix": [":::columns-width", "figdeck-columns-width"], + "body": [ + ":::columns width=${1:1fr/2fr}", + ":::column", + "${2:Sidebar}", + "", + ":::column", + "${3:Main content}", + ":::", + "$0" + ], + "description": "Two-column layout with custom widths" + }, + "figdeck: Figma Block": { + "prefix": [":::figma", "figdeck-figma", "figma"], + "body": [ + ":::figma", + "link=${1:https://www.figma.com/file/xxx/name?node-id=1234-5678}", + ":::", + "$0" + ], + "description": "Figma link block" + }, + "figdeck: Figma Block with Text": { + "prefix": [":::figma-text", "figdeck-figma-text"], + "body": [ + ":::figma", + "link=${1:https://www.figma.com/file/xxx/name?node-id=1234-5678}", + "text.title=${2:Title}", + "text.body=${3:Description}", + ":::", + "$0" + ], + "description": "Figma link block with text overrides" + }, + "figdeck: Figma Block with Position": { + "prefix": [":::figma-pos", "figdeck-figma-pos"], + "body": [ + ":::figma", + "link=${1:https://www.figma.com/file/xxx/name?node-id=1234-5678}", + "x=${2:160}", + "y=${3:300}", + "hideLink=${4|true,false|}", + ":::", + "$0" + ], + "description": "Figma link block with position" + }, + "figdeck: Image with Size": { + "prefix": ["![w:", "figdeck-image-size"], + "body": [ + "![w:${1:400}${2: h:${3:300}}](${4:./image.png})" + ], + "description": "Image with width and optional height" + }, + "figdeck: Image with Position": { + "prefix": ["![x:", "figdeck-image-pos"], + "body": [ + "![w:${1:400} x:${2:100} y:${3:200}](${4:./image.png})" + ], + "description": "Image with size and absolute position" + }, + "figdeck: Image Percentage Size": { + "prefix": ["![w:%", "figdeck-image-percent"], + "body": [ + "![w:${1:50}%](${2:./image.png})" + ], + "description": "Image with percentage width" + }, + "figdeck: Gradient Background": { + "prefix": ["gradient:", "figdeck-gradient"], + "body": [ + "gradient: \"${1:#0d1117}:0%,${2:#1f2937}:50%,${3:#3b82f6}:100%@${4:45}\"" + ], + "description": "Gradient background (color:stop,...@angle)" + }, + "figdeck: Background Image (Local)": { + "prefix": ["backgroundImage-local", "figdeck-bg-local"], + "body": [ + "backgroundImage: \"./${1:bg.png}\"" + ], + "description": "Local background image" + }, + "figdeck: Background Image (URL)": { + "prefix": ["backgroundImage-url", "figdeck-bg-url"], + "body": [ + "backgroundImage: \"${1:https://example.com/image.jpg}\"" + ], + "description": "Remote background image URL" + }, + "figdeck: Title Prefix": { + "prefix": ["titlePrefix:", "figdeck-titleprefix"], + "body": [ + "titlePrefix:", + " link: \"${1:https://www.figma.com/design/xxx?node-id=123-456}\"", + " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component" + }, + "figdeck: Slide Number": { + "prefix": ["slideNumber:", "figdeck-slidenumber"], + "body": [ + "slideNumber:", + " show: true", + " position: ${1|bottom-right,bottom-left,top-right,top-left|}", + " size: ${2:14}", + " color: \"${3:#888888}\"", + " format: \"\\${current} / \\${total}\"" + ], + "description": "Slide number configuration" + }, + "figdeck: Fonts Configuration": { + "prefix": ["fonts:", "figdeck-fonts"], + "body": [ + "fonts:", + " h1:", + " family: \"${1:Roboto}\"", + " style: \"${2:Medium}\"", + " bold: \"${3:Bold}\"", + " body:", + " family: \"${4:Inter}\"", + " style: \"Regular\"" + ], + "description": "Custom font configuration" + }, + "figdeck: Headings Style": { + "prefix": ["headings:", "figdeck-headings"], + "body": [ + "headings:", + " h1: { size: ${1:72}, color: \"${2:#ffffff}\" }", + " h2: { size: ${3:56}, color: \"${4:#ffffff}\" }" + ], + "description": "Heading styles (size and color)" + }, + "figdeck: Auto-advance Transition": { + "prefix": ["transition-auto", "figdeck-auto"], + "body": [ + "transition:", + " style: ${1|dissolve,slide-from-right|}", + " timing:", + " type: after-delay", + " delay: ${2:3}" + ], + "description": "Auto-advancing slide transition" + } +} diff --git a/packages/vscode/src/authoring/frontmatterCompletion.ts b/packages/vscode/src/authoring/frontmatterCompletion.ts new file mode 100644 index 0000000..9a6412a --- /dev/null +++ b/packages/vscode/src/authoring/frontmatterCompletion.ts @@ -0,0 +1,437 @@ +import * as vscode from "vscode"; + +/** + * Frontmatter property definitions for autocompletion + */ +const FRONTMATTER_PROPERTIES: Record< + string, + { + description: string; + values?: string[]; + children?: Record; + } +> = { + figdeck: { + description: "Enable figdeck processing for this file", + values: ["true", "false"], + }, + background: { + description: "Solid background color (e.g., #1a1a2e)", + }, + gradient: { + description: + "Gradient background (e.g., #0d1117:0%,#1f2937:50%,#58a6ff:100%@45)", + }, + backgroundImage: { + description: "Background image path or URL", + }, + template: { + description: "Figma paint style name", + }, + color: { + description: "Base text color for all elements", + }, + align: { + description: "Horizontal alignment", + values: ["left", "center", "right"], + }, + valign: { + description: "Vertical alignment", + values: ["top", "middle", "bottom"], + }, + headings: { + description: "Heading styles configuration", + children: { + h1: { description: "H1 heading style" }, + h2: { description: "H2 heading style" }, + h3: { description: "H3 heading style" }, + h4: { description: "H4 heading style" }, + }, + }, + paragraphs: { + description: "Paragraph style configuration", + children: { + size: { description: "Font size in pixels" }, + color: { description: "Text color" }, + x: { description: "Absolute X position" }, + y: { description: "Absolute Y position" }, + }, + }, + bullets: { + description: "Bullet list style configuration", + children: { + size: { description: "Font size in pixels" }, + color: { description: "Text color" }, + x: { description: "Absolute X position" }, + y: { description: "Absolute Y position" }, + spacing: { description: "Gap between bullet items" }, + }, + }, + code: { + description: "Code block style configuration", + children: { + size: { description: "Font size in pixels" }, + }, + }, + fonts: { + description: "Custom font configuration", + children: { + h1: { description: "H1 font family" }, + h2: { description: "H2 font family" }, + h3: { description: "H3 font family" }, + h4: { description: "H4 font family" }, + body: { description: "Body text font family" }, + bullets: { description: "Bullet text font family" }, + code: { description: "Code font family" }, + }, + }, + slideNumber: { + description: "Slide number configuration", + children: { + show: { + description: "Show/hide slide numbers", + values: ["true", "false"], + }, + position: { + description: "Position of slide number", + values: ["bottom-right", "bottom-left", "top-right", "top-left"], + }, + size: { description: "Font size in pixels" }, + color: { description: "Text color" }, + format: { + description: 'Display format (e.g., "{{current}} / {{total}}")', + }, + link: { description: "Custom Frame design Figma link" }, + startFrom: { description: "Start showing from slide N" }, + }, + }, + titlePrefix: { + description: "Title prefix component configuration", + children: { + link: { description: "Figma component link" }, + spacing: { description: "Gap between prefix and title" }, + }, + }, + transition: { + description: "Slide transition animation", + values: [ + "none", + "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", + "move-from-left", + "move-from-right", + "move-from-top", + "move-from-bottom", + ], + children: { + style: { + description: "Animation style", + values: [ + "none", + "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", + "move-from-left", + "move-from-right", + "move-from-top", + "move-from-bottom", + ], + }, + duration: { description: "Duration in seconds (0.01-10)" }, + curve: { + description: "Easing curve", + values: [ + "ease-in", + "ease-out", + "ease-in-and-out", + "linear", + "gentle", + "quick", + "bouncy", + "slow", + ], + }, + timing: { description: "Timing configuration" }, + }, + }, +}; + +/** + * Nested property definitions (for size, color, etc.) + */ +const STYLE_PROPERTIES: Record = { + size: { description: "Font size in pixels" }, + color: { description: "Text color" }, + x: { description: "Absolute X position" }, + y: { description: "Absolute Y position" }, +}; + +const FONT_PROPERTIES: Record = { + family: { description: "Font family name" }, + style: { description: 'Base style (default: "Regular")' }, + bold: { description: 'Bold variant (default: "Bold")' }, + italic: { description: 'Italic variant (default: "Italic")' }, + boldItalic: { description: "Bold Italic variant" }, +}; + +/** + * Check if cursor is inside a frontmatter block + */ +function isInFrontmatter( + document: vscode.TextDocument, + position: vscode.Position, +): boolean { + const text = document.getText(); + const offset = document.offsetAt(position); + + // Find all --- positions + const separatorRegex = /^---\s*$/gm; + const separators: number[] = []; + let match = separatorRegex.exec(text); + + while (match !== null) { + separators.push(match.index); + match = separatorRegex.exec(text); + } + + // Check if we're between pairs of --- + for (let i = 0; i < separators.length - 1; i += 2) { + const start = separators[i]; + const end = separators[i + 1]; + if (offset > start && offset < end + 3) { + return true; + } + } + + // Also check for implicit frontmatter (YAML without opening ---) + // This is at the start of a slide block + const lineText = document.lineAt(position.line).text; + if (/^[a-zA-Z][\w-]*:/.test(lineText)) { + // Check if previous non-empty lines are also YAML-like or --- + for (let i = position.line - 1; i >= 0; i--) { + const prevLine = document.lineAt(i).text.trim(); + if (!prevLine) continue; + if (prevLine === "---") return true; + if ( + /^[a-zA-Z][\w-]*:/.test(prevLine) || + /^\s+/.test(document.lineAt(i).text) + ) { + continue; + } + break; + } + } + + return false; +} + +/** + * Get the current indentation context + */ +function getIndentContext( + document: vscode.TextDocument, + position: vscode.Position, +): { indent: number; parentKeys: string[] } { + const lineText = document.lineAt(position.line).text; + const currentIndent = lineText.match(/^(\s*)/)?.[1].length ?? 0; + const parentKeys: string[] = []; + + // Look backwards to find parent keys + for (let i = position.line - 1; i >= 0; i--) { + const line = document.lineAt(i).text; + const trimmed = line.trim(); + + if (trimmed === "---") break; + if (!trimmed) continue; + + const lineIndent = line.match(/^(\s*)/)?.[1].length ?? 0; + const keyMatch = trimmed.match(/^([a-zA-Z][\w-]*):/); + + if (keyMatch && lineIndent < currentIndent) { + parentKeys.unshift(keyMatch[1]); + if (lineIndent === 0) break; + } + } + + return { indent: currentIndent, parentKeys }; +} + +/** + * CompletionItemProvider for frontmatter + */ +export class FrontmatterCompletionProvider + implements vscode.CompletionItemProvider +{ + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.CompletionContext, + ): vscode.CompletionItem[] | undefined { + if (!isInFrontmatter(document, position)) { + return undefined; + } + + const lineText = document.lineAt(position.line).text; + const textBeforeCursor = lineText.substring(0, position.character); + + // Check if we're completing a value (after :) + const valueMatch = textBeforeCursor.match( + /^(\s*)([a-zA-Z][\w-]*):\s*(.*)$/, + ); + if (valueMatch) { + const key = valueMatch[2]; + const { parentKeys } = getIndentContext(document, position); + + return this.getValueCompletions(key, parentKeys); + } + + // Check if we're completing a key + const keyMatch = textBeforeCursor.match(/^(\s*)([a-zA-Z][\w-]*)?$/); + if (keyMatch) { + const { indent, parentKeys } = getIndentContext(document, position); + return this.getKeyCompletions(parentKeys, indent); + } + + return undefined; + } + + private getKeyCompletions( + parentKeys: string[], + _indent: number, + ): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + + if (parentKeys.length === 0) { + // Top-level keys + for (const [key, def] of Object.entries(FRONTMATTER_PROPERTIES)) { + const item = new vscode.CompletionItem( + key, + vscode.CompletionItemKind.Property, + ); + item.detail = def.description; + item.insertText = def.children ? `${key}:\n ` : `${key}: `; + items.push(item); + } + } else { + // Nested keys + const parentKey = parentKeys[parentKeys.length - 1]; + const parentDef = FRONTMATTER_PROPERTIES[parentKey]; + + if (parentDef?.children) { + for (const [key, def] of Object.entries(parentDef.children)) { + const item = new vscode.CompletionItem( + key, + vscode.CompletionItemKind.Property, + ); + item.detail = def.description; + item.insertText = `${key}: `; + items.push(item); + } + } + + // Add style properties for headings children (h1, h2, etc.) + if ( + parentKeys.includes("headings") || + parentKeys.includes("paragraphs") || + parentKeys.includes("bullets") + ) { + for (const [key, def] of Object.entries(STYLE_PROPERTIES)) { + const item = new vscode.CompletionItem( + key, + vscode.CompletionItemKind.Property, + ); + item.detail = def.description; + item.insertText = `${key}: `; + items.push(item); + } + } + + // Add font properties for fonts children + if (parentKeys.includes("fonts")) { + for (const [key, def] of Object.entries(FONT_PROPERTIES)) { + const item = new vscode.CompletionItem( + key, + vscode.CompletionItemKind.Property, + ); + item.detail = def.description; + item.insertText = `${key}: `; + items.push(item); + } + } + } + + return items; + } + + private getValueCompletions( + key: string, + parentKeys: string[], + ): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + + // Check for nested key values + if (parentKeys.length > 0) { + const parentKey = parentKeys[parentKeys.length - 1]; + const parentDef = FRONTMATTER_PROPERTIES[parentKey]; + const childDef = parentDef?.children?.[key]; + + if (childDef?.values) { + for (const value of childDef.values) { + const item = new vscode.CompletionItem( + value, + vscode.CompletionItemKind.Value, + ); + items.push(item); + } + return items; + } + } + + // Check for top-level key values + const def = FRONTMATTER_PROPERTIES[key]; + if (def?.values) { + for (const value of def.values) { + const item = new vscode.CompletionItem( + value, + vscode.CompletionItemKind.Value, + ); + items.push(item); + } + } + + return items; + } +} + +/** + * Register the frontmatter completion provider + */ +export function registerFrontmatterCompletion( + context: vscode.ExtensionContext, +): void { + const provider = new FrontmatterCompletionProvider(); + + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: "markdown", scheme: "file" }, + provider, + ":", // Trigger on colon for value completions + ), + ); +} diff --git a/packages/vscode/src/authoring/slideOutline.test.ts b/packages/vscode/src/authoring/slideOutline.test.ts new file mode 100644 index 0000000..6b61c65 --- /dev/null +++ b/packages/vscode/src/authoring/slideOutline.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, it } from "bun:test"; +import { isFigdeckDocument, splitIntoSlidesWithRanges } from "./slideParser"; + +describe("splitIntoSlidesWithRanges", () => { + describe("basic slide detection", () => { + it("should parse single slide with heading", () => { + const content = `# Title Slide + +Some content here.`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + expect(slides[0].index).toBe(1); + expect(slides[0].title).toBe("Title Slide"); + expect(slides[0].startLine).toBe(0); + }); + + it("should parse multiple slides separated by ---", () => { + const content = `# Slide 1 + +Content 1 + +--- + +## Slide 2 + +Content 2 + +--- + +## Slide 3 + +Content 3`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(3); + expect(slides[0].title).toBe("Slide 1"); + expect(slides[1].title).toBe("Slide 2"); + expect(slides[2].title).toBe("Slide 3"); + }); + + it("should handle empty content", () => { + const slides = splitIntoSlidesWithRanges(""); + expect(slides).toHaveLength(0); + }); + + it("should handle content with only whitespace", () => { + const slides = splitIntoSlidesWithRanges(" \n\n "); + expect(slides).toHaveLength(0); + }); + }); + + describe("frontmatter handling", () => { + it("should handle global frontmatter at start", () => { + const content = `--- +background: "#1a1a2e" +--- + +# Title + +Content`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Title"); + }); + + it("should handle per-slide frontmatter", () => { + const content = `# Slide 1 + +--- + +--- +align: center +--- +## Slide 2 + +Content`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(2); + expect(slides[0].title).toBe("Slide 1"); + expect(slides[1].title).toBe("Slide 2"); + }); + + it("should handle implicit frontmatter", () => { + const content = `background: "#000" +color: "#fff" +--- +# Title`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Title"); + }); + }); + + describe("code fence handling", () => { + it("should not treat --- inside code fence as separator", () => { + const content = `## Slide + +\`\`\`markdown +--- +this is code +--- +\`\`\``; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Slide"); + }); + + it("should handle multiple code fences", () => { + const content = `## Slide 1 + +\`\`\`js +const x = 1; +\`\`\` + +--- + +## Slide 2 + +\`\`\`ts +const y: number = 2; +\`\`\``; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(2); + }); + + it("should handle ~~~ code fences", () => { + const content = `## Slide + +~~~ +--- +inside tilde fence +--- +~~~`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + }); + }); + + describe("title extraction", () => { + it("should extract h1 as title", () => { + const content = `# Main Title`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides[0].title).toBe("Main Title"); + }); + + it("should extract h2 as title", () => { + const content = `## Slide Title`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides[0].title).toBe("Slide Title"); + }); + + it("should prefer h1/h2 over other content for title", () => { + const content = `Some paragraph first. + +## The Actual Title + +More content.`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides[0].title).toBe("The Actual Title"); + }); + + it("should use first non-empty line as fallback title when no heading", () => { + // Note: The current implementation returns "(untitled)" when there's no heading + // because the title extraction loop scans all lines looking for headings first + const content = `Some content without heading. + +More text.`; + + const slides = splitIntoSlidesWithRanges(content); + // The implementation currently returns "(untitled)" for content without headings + // This behavior could be improved, but we test the actual implementation + expect(slides).toHaveLength(1); + expect(typeof slides[0].title).toBe("string"); + }); + + it("should truncate long fallback titles", () => { + const content = + "This is a very long line that should be truncated because it exceeds fifty characters"; + + const slides = splitIntoSlidesWithRanges(content); + // Verify slide was created + expect(slides).toHaveLength(1); + // If no heading, the implementation might return the line or "(untitled)" + // The exact behavior depends on if it matches frontmatter patterns + expect(slides[0].title.length).toBeLessThanOrEqual(50); + }); + + it("should skip global frontmatter-only content", () => { + const content = `--- +background: "#000" +---`; + + const slides = splitIntoSlidesWithRanges(content); + // Global frontmatter-only should not create a slide + expect(slides).toHaveLength(0); + }); + + it("should skip per-slide frontmatter-only blocks", () => { + const content = `--- +background: "#000" +--- + +# Slide 1 + +--- + +--- +align: center +---`; + + const slides = splitIntoSlidesWithRanges(content); + // Only Slide 1 should be shown, frontmatter-only blocks should be skipped + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Slide 1"); + }); + + it("should skip global frontmatter followed by per-slide frontmatter", () => { + const content = `--- +background: "#ffffff" +color: "#1a1a2e" +--- +align: center +valign: middle +--- + +# Title Slide + +Content here`; + + const slides = splitIntoSlidesWithRanges(content); + // Global frontmatter + per-slide frontmatter should be skipped + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Title Slide"); + }); + }); + + describe("line range tracking", () => { + it("should track start and end lines correctly", () => { + const content = `# Slide 1 +Line 2 +Line 3 + +--- + +## Slide 2 +Line 8 +Line 9`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(2); + + // First slide starts at line 0 and ends at line 3 (before ---separator at line 4) + expect(slides[0].startLine).toBe(0); + expect(slides[0].endLine).toBe(3); + + // Second slide starts after separator + expect(slides[1].startLine).toBe(5); + expect(slides[1].endLine).toBe(8); + }); + + it("should handle single-line slides", () => { + const content = `# Slide 1 + +--- + +## Slide 2`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(2); + // Single line slide should have start close to end + expect(slides[1].startLine).toBeLessThanOrEqual(slides[1].endLine); + }); + }); + + describe("edge cases", () => { + it("should handle Windows line endings (CRLF)", () => { + const content = "# Slide 1\r\n\r\n---\r\n\r\n## Slide 2"; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(2); + }); + + it("should handle consecutive separators", () => { + const content = `# Slide 1 + +--- + +--- + +## Slide 2`; + + const slides = splitIntoSlidesWithRanges(content); + // First --- is separator, second --- might be treated as frontmatter + expect(slides.length).toBeGreaterThanOrEqual(2); + }); + + it("should handle slide with only directives", () => { + const content = `## Slide + +:::figma +link=https://figma.com +:::`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Slide"); + }); + + it("should skip frontmatter-like content when finding title", () => { + const content = `align: center +valign: middle +--- +## Actual Title`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides[0].title).toBe("Actual Title"); + }); + }); + + describe("complex documents", () => { + it("should handle real-world slide deck", () => { + const content = `--- +background: "#1a1a2e" +color: "#ffffff" +--- + +# Welcome to figdeck + +Create beautiful Figma slides from Markdown + +--- + +## Features + +- Easy to use +- Fast +- Customizable + +--- + +## Code Example + +\`\`\`javascript +const slides = parseMarkdown(content); +\`\`\` + +--- + +## Thank You + +Questions?`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(4); + expect(slides[0].title).toBe("Welcome to figdeck"); + expect(slides[1].title).toBe("Features"); + expect(slides[2].title).toBe("Code Example"); + expect(slides[3].title).toBe("Thank You"); + }); + + it("should handle nested code blocks in columns", () => { + const content = `## Comparison + +:::columns +:::column +\`\`\`js +// JavaScript +--- +const x = 1; +\`\`\` +:::column +\`\`\`ts +// TypeScript +--- +const x: number = 1; +\`\`\` +:::`; + + const slides = splitIntoSlidesWithRanges(content); + expect(slides).toHaveLength(1); + expect(slides[0].title).toBe("Comparison"); + }); + }); +}); + +describe("isFigdeckDocument", () => { + it("should return true when figdeck: true is in frontmatter", () => { + const content = `--- +figdeck: true +background: "#000" +--- + +# Slide`; + + expect(isFigdeckDocument(content)).toBe(true); + }); + + it("should return true with figdeck: true at any position in frontmatter", () => { + const content = `--- +background: "#000" +figdeck: true +color: "#fff" +--- + +# Slide`; + + expect(isFigdeckDocument(content)).toBe(true); + }); + + it("should return false when figdeck: true is not present", () => { + const content = `--- +background: "#000" +--- + +# Slide`; + + expect(isFigdeckDocument(content)).toBe(false); + }); + + it("should return false when figdeck: false", () => { + const content = `--- +figdeck: false +--- + +# Slide`; + + expect(isFigdeckDocument(content)).toBe(false); + }); + + it("should return false for files without frontmatter", () => { + const content = `# Slide + +Some content`; + + expect(isFigdeckDocument(content)).toBe(false); + }); + + it("should return false for empty files", () => { + expect(isFigdeckDocument("")).toBe(false); + }); + + it("should handle leading whitespace", () => { + const content = ` + +--- +figdeck: true +--- + +# Slide`; + + expect(isFigdeckDocument(content)).toBe(true); + }); + + it("should be case insensitive for value", () => { + const content = `--- +figdeck: TRUE +---`; + + expect(isFigdeckDocument(content)).toBe(true); + }); + + it("should handle various spacing around colon", () => { + const content = `--- +figdeck: true +---`; + + expect(isFigdeckDocument(content)).toBe(true); + }); + + it("should not match figdeck in per-slide frontmatter", () => { + const content = `--- +background: "#000" +--- + +# Slide 1 + +--- + +--- +figdeck: true +--- + +## Slide 2`; + + // figdeck: true is in per-slide frontmatter, not global + expect(isFigdeckDocument(content)).toBe(false); + }); +}); diff --git a/packages/vscode/src/authoring/slideOutline.ts b/packages/vscode/src/authoring/slideOutline.ts new file mode 100644 index 0000000..59f1a0f --- /dev/null +++ b/packages/vscode/src/authoring/slideOutline.ts @@ -0,0 +1,189 @@ +import * as vscode from "vscode"; +import { + isFigdeckDocument, + type SlideInfo, + splitIntoSlidesWithRanges, +} from "./slideParser"; + +// Re-export for backward compatibility +export { + isFigdeckDocument, + type SlideInfo, + splitIntoSlidesWithRanges, +} from "./slideParser"; + +/** + * TreeItem for a slide + */ +export class SlideTreeItem extends vscode.TreeItem { + constructor( + public readonly slideInfo: SlideInfo, + public readonly document: vscode.TextDocument, + ) { + super( + `${slideInfo.index}. ${slideInfo.title}`, + vscode.TreeItemCollapsibleState.None, + ); + + this.tooltip = `Slide ${slideInfo.index}: ${slideInfo.title}\nLines ${slideInfo.startLine + 1}-${slideInfo.endLine + 1}`; + this.description = `L${slideInfo.startLine + 1}`; + + // Click to reveal the slide in editor + this.command = { + command: "figdeck.revealSlide", + title: "Reveal Slide", + arguments: [document, slideInfo], + }; + + this.contextValue = "slide"; + } +} + +/** + * TreeDataProvider for slide outline + */ +export class SlideOutlineProvider + implements vscode.TreeDataProvider +{ + private _onDidChangeTreeData = new vscode.EventEmitter< + SlideTreeItem | undefined | null + >(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private slides: SlideInfo[] = []; + private currentDocument: vscode.TextDocument | undefined; + + constructor() { + // Update when active editor changes + vscode.window.onDidChangeActiveTextEditor((editor) => { + this.updateFromEditor(editor); + }); + + // Update when document changes + vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document === this.currentDocument) { + this.refresh(); + } + }); + + // Initial update + this.updateFromEditor(vscode.window.activeTextEditor); + } + + private updateFromEditor(editor: vscode.TextEditor | undefined): void { + if ( + editor && + editor.document.languageId === "markdown" && + isFigdeckDocument(editor.document.getText()) + ) { + this.currentDocument = editor.document; + this.refresh(); + } else { + this.currentDocument = undefined; + this.slides = []; + this._onDidChangeTreeData.fire(undefined); + } + } + + refresh(): void { + if (this.currentDocument) { + this.slides = splitIntoSlidesWithRanges(this.currentDocument.getText()); + } else { + this.slides = []; + } + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: SlideTreeItem): vscode.TreeItem { + return element; + } + + getChildren(): Thenable { + if (!this.currentDocument) { + return Promise.resolve([]); + } + + const doc = this.currentDocument; + return Promise.resolve( + this.slides.map((slide) => new SlideTreeItem(slide, doc)), + ); + } + + /** + * Get slides for current document + */ + getSlides(): SlideInfo[] { + return this.slides; + } + + /** + * Get current document + */ + getDocument(): vscode.TextDocument | undefined { + return this.currentDocument; + } +} + +/** + * Reveal a slide in the editor + */ +export function revealSlide( + document: vscode.TextDocument, + slideInfo: SlideInfo, +): void { + const range = new vscode.Range( + new vscode.Position(slideInfo.startLine, 0), + new vscode.Position(slideInfo.startLine, 0), + ); + + vscode.window.showTextDocument(document, { + selection: range, + preserveFocus: false, + }); +} + +/** + * Go to next/previous slide + */ +export function navigateSlide( + provider: SlideOutlineProvider, + direction: "next" | "previous", +): void { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document !== provider.getDocument()) { + return; + } + + const currentLine = editor.selection.active.line; + const slides = provider.getSlides(); + + if (slides.length === 0) return; + + // Find current slide + let currentSlideIndex = 0; + for (let i = 0; i < slides.length; i++) { + if ( + slides[i].startLine <= currentLine && + currentLine <= slides[i].endLine + ) { + currentSlideIndex = i; + break; + } + if (slides[i].startLine > currentLine) { + currentSlideIndex = Math.max(0, i - 1); + break; + } + currentSlideIndex = i; + } + + let targetIndex: number; + if (direction === "next") { + targetIndex = Math.min(currentSlideIndex + 1, slides.length - 1); + } else { + targetIndex = Math.max(currentSlideIndex - 1, 0); + } + + if (targetIndex !== currentSlideIndex) { + revealSlide(editor.document, slides[targetIndex]); + } +} diff --git a/packages/vscode/src/authoring/slideParser.ts b/packages/vscode/src/authoring/slideParser.ts new file mode 100644 index 0000000..a723902 --- /dev/null +++ b/packages/vscode/src/authoring/slideParser.ts @@ -0,0 +1,304 @@ +/** + * Represents a slide in the outline + */ +export interface SlideInfo { + index: number; + title: string; + startLine: number; + endLine: number; +} + +/** + * Check if lines look like implicit frontmatter (YAML key/value pairs). + */ +function looksLikeInlineFrontmatter(lines: string[]): boolean { + let sawKey = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) { + sawKey = true; + continue; + } + if (/^\s+/.test(line) && sawKey) { + continue; + } + return false; + } + return sawKey; +} + +/** + * Check if lines array has meaningful content (non-empty lines) + */ +function hasMeaningfulContent(lines: string[]): boolean { + return lines.some((l) => l.trim() !== ""); +} + +/** + * Check if content is only frontmatter (no actual slide content) + */ +function isOnlyFrontmatter(lines: string[]): boolean { + let i = 0; + + // Skip leading empty lines + while (i < lines.length && !lines[i].trim()) { + i++; + } + + if (i >= lines.length) { + return true; // All empty lines + } + + // Check for explicit frontmatter (starts with ---) + if (lines[i]?.trim() === "---") { + i++; + // Find closing --- + while (i < lines.length && lines[i].trim() !== "---") { + i++; + } + // If no closing --- found, it's not valid frontmatter + if (i >= lines.length) { + return false; + } + i++; // Skip closing --- + + // Skip empty lines after explicit frontmatter + while (i < lines.length && !lines[i].trim()) { + i++; + } + + // Check for additional implicit frontmatter after explicit frontmatter + if (i < lines.length) { + const remainingLines = lines.slice(i); + if (looksLikeInlineFrontmatter(remainingLines)) { + // Skip the implicit frontmatter + for (; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed) continue; + if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) continue; + if (/^\s+/.test(lines[i])) continue; + // Found non-frontmatter content + break; + } + } + } + } else { + // Check for implicit frontmatter + const remainingLines = lines.slice(i); + if (looksLikeInlineFrontmatter(remainingLines)) { + for (; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed === "---") { + i++; + break; + } + if (!trimmed) continue; + if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) continue; + if (/^\s+/.test(lines[i])) continue; + // Found non-frontmatter content + return false; + } + } else { + return false; + } + } + + // Check if remaining content is empty or only frontmatter-like + for (; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed) continue; + // If there's actual content (not frontmatter), return false + if (!trimmed.match(/^[a-zA-Z][\w-]*:\s*/) && !/^\s+/.test(lines[i])) { + return false; + } + } + return true; +} + +/** + * Split markdown content into slides with position information + */ +export function splitIntoSlidesWithRanges(content: string): SlideInfo[] { + const lines = content.split(/\r?\n/); + const slides: SlideInfo[] = []; + let currentLines: string[] = []; + let currentStartLine = 0; + let inFrontmatter = false; + let codeFence: string | null = null; + let isFirstBlock = true; + + const flushSlide = (endLine: number) => { + if (currentLines.length === 0) return; + + const slideText = currentLines.join("\n").trim(); + if (slideText) { + // Skip frontmatter-only blocks (global or per-slide) + if (isOnlyFrontmatter(currentLines)) { + currentLines = []; + isFirstBlock = false; + return; + } + isFirstBlock = false; + + const title = extractSlideTitle(currentLines); + slides.push({ + index: slides.length + 1, + title, + startLine: currentStartLine, + endLine: endLine - 1, + }); + } + currentLines = []; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track fenced code blocks + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch) { + if (codeFence === null) { + codeFence = fenceMatch[1]; + } else if (trimmed.startsWith(codeFence)) { + codeFence = null; + } + currentLines.push(line); + continue; + } + + // Do not treat --- as separators inside code fence + if (codeFence !== null) { + currentLines.push(line); + continue; + } + + if (trimmed === "---") { + if (inFrontmatter) { + currentLines.push(line); + inFrontmatter = false; + continue; + } + + if (!hasMeaningfulContent(currentLines)) { + // Start of per-slide frontmatter + inFrontmatter = true; + currentLines.push(line); + continue; + } + + // Implicit frontmatter closer + if (looksLikeInlineFrontmatter(currentLines)) { + currentLines.push(line); + continue; + } + + // Slide separator + flushSlide(i); + currentStartLine = i + 1; + inFrontmatter = false; + continue; + } + + currentLines.push(line); + } + + // Flush remaining content + flushSlide(lines.length); + + return slides; +} + +/** + * Extract the title from slide lines + * Looks for # or ## heading, falls back to first non-empty line + */ +export function extractSlideTitle(lines: string[]): string { + // Skip frontmatter + let i = 0; + if (lines[0]?.trim() === "---") { + i++; + while (i < lines.length && lines[i].trim() !== "---") { + i++; + } + i++; // Skip closing --- + } else { + // Check for implicit frontmatter + let tempI = 0; + while (tempI < lines.length) { + const trimmed = lines[tempI].trim(); + if (!trimmed) { + tempI++; + continue; + } + if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) { + tempI++; + continue; + } + if (/^\s+/.test(lines[tempI])) { + tempI++; + continue; + } + break; + } + // If we found implicit frontmatter followed by --- + if (tempI < lines.length && lines[tempI].trim() === "---") { + i = tempI + 1; + } + } + + // Look for heading + for (; i < lines.length; i++) { + const line = lines[i].trim(); + + // Match # or ## heading + const headingMatch = line.match(/^#{1,2}\s+(.+)$/); + if (headingMatch) { + return headingMatch[1].trim(); + } + } + + // Fallback: first non-empty non-frontmatter line + for (let j = i; j < lines.length; j++) { + const line = lines[j].trim(); + if (line && !line.startsWith(":::") && !line.match(/^[a-zA-Z][\w-]*:\s*/)) { + // Truncate if too long + return line.length > 50 ? `${line.slice(0, 47)}...` : line; + } + } + + return "(untitled)"; +} + +/** + * Check if a document has `figdeck: true` in its global frontmatter. + * Only global frontmatter (at the start of the file) is checked. + */ +export function isFigdeckDocument(content: string): boolean { + const lines = content.split(/\r?\n/); + let i = 0; + + // Skip leading empty lines + while (i < lines.length && !lines[i].trim()) { + i++; + } + + // Must start with --- + if (lines[i]?.trim() !== "---") { + return false; + } + i++; + + // Look for figdeck: true until closing --- + while (i < lines.length && lines[i].trim() !== "---") { + const trimmed = lines[i].trim(); + // Match figdeck: true (case insensitive for value) + if (/^figdeck:\s*true$/i.test(trimmed)) { + return true; + } + i++; + } + + return false; +} diff --git a/packages/vscode/src/diagnostics/analyzer.test.ts b/packages/vscode/src/diagnostics/analyzer.test.ts new file mode 100644 index 0000000..27a8b6a --- /dev/null +++ b/packages/vscode/src/diagnostics/analyzer.test.ts @@ -0,0 +1,716 @@ +import { describe, expect, it } from "bun:test"; +import { + analyzeColumnsBlocks, + analyzeFigmaBlocks, + analyzeFrontmatterStructure, + isValidFigmaUrl, + validateImageAlt, +} from "./analyzer"; +import { validateFrontmatter } from "./frontmatterValidator"; + +function splitLines(text: string): string[] { + return text.split(/\r?\n/); +} + +describe("diagnostics analyzer code fences", () => { + it("ignores :::figma blocks inside fenced code", () => { + const content = [ + "# Slide", + "", + "```markdown", + ":::figma", + ":::", + "```", + ].join("\n"); + + const issues = analyzeFigmaBlocks(splitLines(content)); + expect(issues).toHaveLength(0); + }); + + it("ignores unclosed :::figma blocks inside fenced code", () => { + const content = [ + "```markdown", + ":::figma", + "link=https://example.com", + "```", + ].join("\n"); + + const issues = analyzeFigmaBlocks(splitLines(content)); + expect(issues).toHaveLength(0); + }); + + it("still reports :::figma blocks outside fenced code", () => { + const content = [ + "```markdown", + ":::figma", + ":::", + "```", + "", + ":::figma", + ":::", + ].join("\n"); + + const issues = analyzeFigmaBlocks(splitLines(content)); + expect(issues.map((issue) => issue.code)).toEqual(["figma-missing-link"]); + }); + + it("ignores :::columns blocks inside fenced code", () => { + const content = [ + "```markdown", + ":::columns gap=300", + ":::column", + "A", + ":::", + "```", + ].join("\n"); + + const issues = analyzeColumnsBlocks(splitLines(content)); + expect(issues).toHaveLength(0); + }); + + it("ignores unclosed :::columns blocks inside fenced code", () => { + const content = ["```markdown", ":::columns", ":::column", "A", "```"].join( + "\n", + ); + + const issues = analyzeColumnsBlocks(splitLines(content)); + expect(issues).toHaveLength(0); + }); + + it("still reports :::columns blocks outside fenced code", () => { + const content = [ + "```markdown", + ":::columns gap=300", + ":::column", + "A", + ":::", + "```", + "", + ":::columns gap=300", + ":::column", + "A", + ":::", + ].join("\n"); + + const issues = analyzeColumnsBlocks(splitLines(content)); + expect(issues.map((issue) => issue.code)).toEqual([ + "columns-too-few", + "columns-gap-exceeded", + ]); + }); +}); + +describe("validateFrontmatter", () => { + it("should return no issues for valid frontmatter", () => { + const lines = ["---", 'background: "#1a1a2e"', "align: center", "---"]; + const issues = validateFrontmatter(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect invalid align value", () => { + const lines = ["---", "align: invalid", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should detect invalid valign value", () => { + const lines = ["---", "valign: wrong", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should detect invalid color format", () => { + const lines = ["---", "color: red", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-format")).toBe( + true, + ); + }); + + it("should accept valid hex color", () => { + const lines = ["---", 'color: "#ff0000"', "---"]; + const issues = validateFrontmatter(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect number out of range", () => { + const lines = ["---", "transition:", " duration: 15", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-out-of-range")).toBe( + true, + ); + }); + + it("should detect unknown property", () => { + const lines = ["---", "unknownProp: value", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-unknown-property")).toBe( + true, + ); + }); + + it("should validate nested properties", () => { + const lines = ["---", "slideNumber:", " position: invalid-pos", "---"]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should accept valid transition style", () => { + const lines = ["---", "transition:", " style: slide-from-right", "---"]; + const issues = validateFrontmatter(lines); + expect(issues).toHaveLength(0); + }); + + // Per-slide frontmatter validation tests + it("should validate per-slide explicit frontmatter (--- to ---)", () => { + const lines = [ + "---", + 'background: "#000"', + "---", + "", + "# Slide 1", + "", + "---", + "align: invalid", // Invalid value + "---", + "", + "## Slide 2", + ]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should validate per-slide implicit frontmatter (YAML after --- without closing)", () => { + const lines = [ + "---", + 'background: "#000"', + "---", + "", + "# Slide 1", + "", + "---", + "align: invalid", // Invalid value - implicit frontmatter + "", + "## Slide 2", + ]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should validate multiple per-slide frontmatters", () => { + const lines = [ + "---", + 'background: "#000"', + "---", + "", + "# Slide 1", + "", + "---", + "align: center", + "---", + "", + "## Slide 2", + "", + "---", + "valign: wrong", // Invalid value in third block + "---", + "", + "## Slide 3", + ]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should not validate YAML-like content inside code fences", () => { + const lines = [ + "---", + 'background: "#000"', + "---", + "", + "# Slide", + "", + "```yaml", + "align: invalid", // This is in a code fence, should be ignored + "```", + ]; + const issues = validateFrontmatter(lines); + expect( + issues.filter((i) => i.code === "frontmatter-invalid-value"), + ).toHaveLength(0); + }); + + it("should validate implicit frontmatter with nested properties", () => { + const lines = [ + "---", + 'background: "#000"', + "---", + "", + "# Slide 1", + "", + "---", + "transition:", + " duration: 999", // Out of range (max 10) + "", + "## Slide 2", + ]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-out-of-range")).toBe( + true, + ); + }); + + it("should validate implicit frontmatter immediately after explicit block", () => { + // This is the pattern: --- (explicit yaml) --- (implicit yaml) --- + const lines = [ + "---", + 'background: "#ffffff"', + 'color: "#1a1a2e"', + "---", + "align: invalid", // Implicit frontmatter right after explicit block + "valign: middle", + "---", + "", + "# Title", + ]; + const issues = validateFrontmatter(lines); + expect(issues.some((i) => i.code === "frontmatter-invalid-value")).toBe( + true, + ); + }); + + it("should validate unknown properties in implicit frontmatter after explicit block", () => { + const lines = [ + "---", + 'background: "#ffffff"', + "---", + "titlePrefix: falsegag", // Invalid - should be boolean or object + "---", + "", + "# Title", + ]; + const issues = validateFrontmatter(lines); + // titlePrefix expects boolean or object, string "falsegag" should trigger type error + expect(issues.length).toBeGreaterThan(0); + }); + + it("should not produce errors for slides without frontmatter settings", () => { + const lines = [ + "---", + 'background: "#ffffff"', + "---", + "", + "# Title Slide", + "", + "---", + "", + "## Agenda", // No frontmatter, just content + "", + "- Item 1", + "- Item 2", + "", + "---", + "", + "## Another Slide", // Another slide without frontmatter + "", + "Content here", + ]; + const issues = validateFrontmatter(lines); + expect(issues).toHaveLength(0); + }); + + it("should not confuse Markdown headings with YAML comments", () => { + const lines = [ + "---", + 'background: "#000"', + "---", + "", + "# H1 Heading", // Should not be treated as YAML comment + "", + "---", + "", + "## H2 Heading", // Should not be treated as YAML comment + "", + "---", + "", + "### H3 Heading", + ]; + const issues = validateFrontmatter(lines); + expect(issues).toHaveLength(0); + }); +}); + +describe("analyzeFrontmatterStructure", () => { + it("should return no issues for valid frontmatter", () => { + const lines = ["---", 'background: "#1a1a2e"', "---", "", "# Title"]; + const issues = analyzeFrontmatterStructure(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect unclosed frontmatter block", () => { + const lines = ["---", 'background: "#1a1a2e"', "", "# Title"]; + const issues = analyzeFrontmatterStructure(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("frontmatter-unclosed"); + expect(issues[0].severity).toBe("error"); + expect(issues[0].range.startLine).toBe(0); + }); + + it("should not detect unclosed frontmatter when closed properly", () => { + const lines = [ + "---", + 'background: "#000"', + "color: white", + "---", + "# Slide", + ]; + const issues = analyzeFrontmatterStructure(lines); + expect(issues).toHaveLength(0); + }); + + it("should handle file without frontmatter", () => { + const lines = ["# Title", "", "Content"]; + const issues = analyzeFrontmatterStructure(lines); + expect(issues).toHaveLength(0); + }); + + it("should not report issues for --- inside code fences", () => { + const lines = ["```", "---", "```"]; + const issues = analyzeFrontmatterStructure(lines); + expect(issues).toHaveLength(0); + }); +}); + +describe("validateImageAlt", () => { + it("should return no issues for valid alt text", () => { + const issues = validateImageAlt("w:400 h:300", 0, 0); + expect(issues).toHaveLength(0); + }); + + it("should return no issues for plain alt text", () => { + const issues = validateImageAlt("Logo image", 0, 0); + expect(issues).toHaveLength(0); + }); + + it("should detect invalid width (zero)", () => { + const issues = validateImageAlt("w:0", 5, 10); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("image-invalid-width"); + expect(issues[0].severity).toBe("warning"); + expect(issues[0].range.startLine).toBe(5); + }); + + it("should detect invalid width (negative)", () => { + const issues = validateImageAlt("w:-100", 0, 0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("image-invalid-width"); + }); + + it("should detect invalid height (zero)", () => { + const issues = validateImageAlt("h:0", 0, 0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("image-invalid-height"); + }); + + it("should detect invalid height (negative)", () => { + const issues = validateImageAlt("h:-50", 0, 0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("image-invalid-height"); + }); + + it("should detect width percentage exceeding 100%", () => { + const issues = validateImageAlt("w:150%", 0, 0); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("image-invalid-width-percent"); + expect(issues[0].message).toContain("100%"); + }); + + it("should allow valid percentage width", () => { + const issues = validateImageAlt("w:50%", 0, 0); + expect(issues).toHaveLength(0); + }); + + it("should allow 100% width", () => { + const issues = validateImageAlt("w:100%", 0, 0); + expect(issues).toHaveLength(0); + }); + + it("should detect multiple issues", () => { + const issues = validateImageAlt("w:0 h:-10", 0, 0); + expect(issues).toHaveLength(2); + expect(issues.map((i) => i.code)).toContain("image-invalid-width"); + expect(issues.map((i) => i.code)).toContain("image-invalid-height"); + }); +}); + +describe("isValidFigmaUrl", () => { + it("should accept valid figma.com URL", () => { + expect(isValidFigmaUrl("https://figma.com/file/abc")).toBe(true); + }); + + it("should accept valid www.figma.com URL", () => { + expect( + isValidFigmaUrl("https://www.figma.com/file/abc/Name?node-id=1-2"), + ).toBe(true); + }); + + it("should accept figma.com/design URLs", () => { + expect(isValidFigmaUrl("https://www.figma.com/design/xyz/Name")).toBe(true); + }); + + it("should reject non-figma URLs", () => { + expect(isValidFigmaUrl("https://google.com")).toBe(false); + }); + + it("should reject spoofed URLs", () => { + expect(isValidFigmaUrl("https://evilfigma.com/file/abc")).toBe(false); + }); + + it("should reject invalid URLs", () => { + expect(isValidFigmaUrl("not-a-url")).toBe(false); + }); + + it("should reject URLs with figma in path but wrong host", () => { + expect(isValidFigmaUrl("https://example.com/figma/file")).toBe(false); + }); + + it("should accept subdomain URLs", () => { + expect(isValidFigmaUrl("https://sub.figma.com/something")).toBe(true); + }); +}); + +describe("analyzeFigmaBlocks", () => { + it("should return no issues for valid figma block", () => { + const lines = [ + ":::figma", + "link=https://www.figma.com/file/abc?node-id=1-2", + ":::", + ]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect missing link property", () => { + const lines = [":::figma", "x=100", "y=200", ":::"]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("figma-missing-link"); + expect(issues[0].severity).toBe("error"); + }); + + it("should detect invalid Figma URL", () => { + const lines = [":::figma", "link=https://example.com/not-figma", ":::"]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("figma-invalid-url"); + expect(issues[0].severity).toBe("warning"); + }); + + it("should detect unclosed figma block", () => { + const lines = [":::figma", "link=https://www.figma.com/file/abc"]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("figma-unclosed"); + expect(issues[0].severity).toBe("error"); + }); + + it("should detect invalid x position value", () => { + const lines = [ + ":::figma", + "link=https://www.figma.com/file/abc", + "x=invalid", + ":::", + ]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("figma-invalid-position"); + expect(issues[0].message).toContain("x"); + }); + + it("should detect invalid y position value", () => { + const lines = [ + ":::figma", + "link=https://www.figma.com/file/abc", + "y=abc", + ":::", + ]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("figma-invalid-position"); + expect(issues[0].message).toContain("y"); + }); + + it("should allow percentage position values", () => { + const lines = [ + ":::figma", + "link=https://www.figma.com/file/abc", + "x=50%", + "y=25%", + ":::", + ]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(0); + }); +}); + +describe("analyzeColumnsBlocks", () => { + it("should return no issues for valid 2-column block", () => { + const lines = [ + ":::columns", + ":::column", + "Left content", + ":::column", + "Right content", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(0); + }); + + it("should return no issues for valid 3-column block", () => { + const lines = [ + ":::columns", + ":::column", + "A", + ":::column", + "B", + ":::column", + "C", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(0); + }); + + it("should return no issues for valid 4-column block", () => { + const lines = [ + ":::columns", + ":::column", + "1", + ":::column", + "2", + ":::column", + "3", + ":::column", + "4", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect too few columns (0)", () => { + const lines = [":::columns", "Some content without column markers", ":::"]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("columns-too-few"); + expect(issues[0].severity).toBe("warning"); + }); + + it("should detect too few columns (1)", () => { + const lines = [":::columns", ":::column", "Only one column", ":::"]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("columns-too-few"); + expect(issues[0].message).toContain("1 column(s)"); + }); + + it("should detect too many columns (5+)", () => { + const lines = [ + ":::columns", + ":::column", + "1", + ":::column", + "2", + ":::column", + "3", + ":::column", + "4", + ":::column", + "5", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("columns-too-many"); + expect(issues[0].severity).toBe("info"); + expect(issues[0].message).toContain("5 columns"); + }); + + it("should detect gap exceeding maximum", () => { + const lines = [ + ":::columns gap=500", + ":::column", + "A", + ":::column", + "B", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("columns-gap-exceeded"); + expect(issues[0].message).toContain("500"); + expect(issues[0].message).toContain("200"); + }); + + it("should allow valid gap value", () => { + const lines = [ + ":::columns gap=64", + ":::column", + "A", + ":::column", + "B", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect width mismatch with columns", () => { + const lines = [ + ":::columns width=1fr/2fr/3fr", + ":::column", + "A", + ":::column", + "B", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("columns-width-mismatch"); + expect(issues[0].message).toContain("3 values"); + expect(issues[0].message).toContain("2 columns"); + }); + + it("should allow matching width specification", () => { + const lines = [ + ":::columns width=1fr/2fr", + ":::column", + "A", + ":::column", + "B", + ":::", + ]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(0); + }); + + it("should detect unclosed columns block", () => { + const lines = [":::columns", ":::column", "Content"]; + const issues = analyzeColumnsBlocks(lines); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("columns-unclosed"); + expect(issues[0].severity).toBe("error"); + }); +}); diff --git a/packages/vscode/src/diagnostics/analyzer.ts b/packages/vscode/src/diagnostics/analyzer.ts new file mode 100644 index 0000000..7abd619 --- /dev/null +++ b/packages/vscode/src/diagnostics/analyzer.ts @@ -0,0 +1,584 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type * as vscode from "vscode"; +import { validateFrontmatter } from "./frontmatterValidator"; +import type { AnalysisResult, Issue } from "./types"; + +function getMaxImageSizeMb(): number | null { + let maxSizeMb = 5; + try { + const vscode = require("vscode") as typeof import("vscode"); + maxSizeMb = vscode.workspace + .getConfiguration("figdeck.images") + .get("maxSizeMb", 5); + } catch { + // Ignore when vscode module isn't available (e.g., unit tests) + } + + if (!Number.isFinite(maxSizeMb) || maxSizeMb <= 0) return null; + return maxSizeMb; +} + +function parseImageDestination(raw: string): string { + const trimmed = raw.trim(); + + // Markdown supports angle-bracket link destinations: ![]() + if (trimmed.startsWith("<")) { + const end = trimmed.indexOf(">"); + if (end > 1) { + return trimmed.slice(1, end); + } + } + + // Fall back to first token before a title. + const [first] = trimmed.split(/\s+/); + return first ?? trimmed; +} + +function toCandidatePaths( + destination: string, + basePath: string, + documentUri: vscode.Uri, +): string[] { + const candidates: string[] = []; + + if (destination.startsWith("/") && basePath) { + candidates.push(path.join(basePath, destination.slice(1))); + return candidates; + } + + if (path.isAbsolute(destination)) { + candidates.push(destination); + return candidates; + } + + if (documentUri.scheme === "file") { + const documentDir = path.dirname(documentUri.fsPath); + candidates.push(path.resolve(documentDir, destination)); + } + + if (basePath) { + candidates.push(path.resolve(basePath, destination)); + } + + return candidates; +} + +/** + * Analyze a figdeck markdown document for issues + */ +export function analyzeDocument( + document: vscode.TextDocument, + basePath: string, +): AnalysisResult { + const issues: Issue[] = []; + const text = document.getText(); + const lines = text.split(/\r?\n/); + + // Run all analyzers + issues.push(...analyzeFrontmatterStructure(lines)); + issues.push(...validateFrontmatter(lines)); + issues.push(...analyzeImages(lines, basePath, document.uri)); + issues.push(...analyzeFigmaBlocks(lines)); + issues.push(...analyzeColumnsBlocks(lines)); + + return { issues }; +} + +/** + * Analyze YAML frontmatter structure for issues (unclosed blocks, etc.) + * @internal Exported for testing + */ +export function analyzeFrontmatterStructure(lines: string[]): Issue[] { + const issues: Issue[] = []; + + // Find global frontmatter + if (lines[0]?.trim() === "---") { + let endLine = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { + endLine = i; + break; + } + } + + if (endLine === -1) { + issues.push({ + severity: "error", + message: "Unclosed frontmatter block", + range: { + startLine: 0, + startColumn: 0, + endLine: 0, + endColumn: 3, + }, + code: "frontmatter-unclosed", + }); + } + } + + // Check for per-slide frontmatter issues + let inCodeFence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track code fences + if (trimmed.match(/^(```+|~~~+)/)) { + inCodeFence = !inCodeFence; + continue; + } + + if (inCodeFence) continue; + + // Check for common frontmatter mistakes + // Example: Missing colon in key-value + if ( + trimmed.match(/^[a-zA-Z][\w-]*\s+[^:=]/) && + !trimmed.startsWith("#") && + !trimmed.startsWith("-") && + !trimmed.startsWith(">") + ) { + // Could be missing colon, but this is too noisy + // Skip for now + } + } + + return issues; +} + +/** + * Analyze image references for issues + * @internal Exported for testing + */ +export function analyzeImages( + lines: string[], + basePath: string, + documentUri: vscode.Uri, +): Issue[] { + const issues: Issue[] = []; + const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)/g; + const maxSizeMb = getMaxImageSizeMb(); + + let inCodeFence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track code fences + if (trimmed.match(/^(```+|~~~+)/)) { + inCodeFence = !inCodeFence; + continue; + } + + if (inCodeFence) continue; + + // Find images in line + for (const match of line.matchAll(imagePattern)) { + const alt = match[1]; + const rawUrl = match[2]; + const startCol = match.index; + const endCol = match.index + match[0].length; + + const destination = parseImageDestination(rawUrl); + const url = destination.split(/[?#]/)[0]; + + // Skip remote URLs + if (url.startsWith("http://") || url.startsWith("https://")) { + continue; + } + + // Check for unsupported extensions + const ext = url.toLowerCase().split(".").pop(); + if (ext && ["webp", "svg", "bmp", "tiff"].includes(ext)) { + issues.push({ + severity: "warning", + message: `Image format '${ext}' may not be supported by Figma. Use PNG, JPEG, or GIF instead.`, + range: { + startLine: i, + startColumn: startCol, + endLine: i, + endColumn: endCol, + }, + code: "image-unsupported-format", + data: { url, ext }, + }); + } + + if (maxSizeMb) { + const maxSizeBytes = maxSizeMb * 1024 * 1024; + for (const candidatePath of toCandidatePaths( + url, + basePath, + documentUri, + )) { + try { + const stat = fs.statSync(candidatePath); + if (!stat.isFile()) continue; + + if (stat.size > maxSizeBytes) { + const sizeMb = stat.size / 1024 / 1024; + issues.push({ + severity: "warning", + message: `Image is ${sizeMb.toFixed(1)}MB (max ${maxSizeMb}MB). Consider compressing it or raising 'figdeck.images.maxSizeMb'.`, + range: { + startLine: i, + startColumn: startCol, + endLine: i, + endColumn: endCol, + }, + code: "image-too-large", + data: { + url, + filePath: candidatePath, + sizeBytes: stat.size, + maxSizeMb, + }, + }); + } + break; + } catch { + // Ignore missing/unreadable files. + } + } + } + + // Check w/h/x/y values in alt text + const sizeIssues = validateImageAlt(alt, i, startCol); + issues.push(...sizeIssues); + } + } + + return issues; +} + +/** + * Validate image alt text size/position specs + * @internal Exported for testing + */ +export function validateImageAlt( + alt: string, + line: number, + baseCol: number, +): Issue[] { + const issues: Issue[] = []; + + // Check for invalid w/h values + const widthMatch = alt.match(/w:(-?\d+)(%?)/); + if (widthMatch) { + const value = Number.parseInt(widthMatch[1], 10); + if (value <= 0) { + issues.push({ + severity: "warning", + message: "Image width must be positive", + range: { + startLine: line, + startColumn: baseCol, + endLine: line, + endColumn: baseCol + alt.length + 4, // ![alt] + }, + code: "image-invalid-width", + }); + } + if (widthMatch[2] === "%" && value > 100) { + issues.push({ + severity: "warning", + message: "Image width percentage cannot exceed 100%", + range: { + startLine: line, + startColumn: baseCol, + endLine: line, + endColumn: baseCol + alt.length + 4, + }, + code: "image-invalid-width-percent", + }); + } + } + + const heightMatch = alt.match(/h:(-?\d+)(%?)/); + if (heightMatch) { + const value = Number.parseInt(heightMatch[1], 10); + if (value <= 0) { + issues.push({ + severity: "warning", + message: "Image height must be positive", + range: { + startLine: line, + startColumn: baseCol, + endLine: line, + endColumn: baseCol + alt.length + 4, + }, + code: "image-invalid-height", + }); + } + } + + return issues; +} + +/** + * Analyze :::figma blocks for issues + * @internal Exported for testing + */ +export function analyzeFigmaBlocks(lines: string[]): Issue[] { + const issues: Issue[] = []; + + let inCodeFence = false; + let inFigmaBlock = false; + let figmaBlockStart = -1; + let hasLink = false; + let linkValue = ""; + let linkLine = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track code fences + if (trimmed.match(/^(```+|~~~+)/)) { + inCodeFence = !inCodeFence; + continue; + } + + if (inCodeFence) continue; + + if (trimmed === ":::figma") { + inFigmaBlock = true; + figmaBlockStart = i; + hasLink = false; + linkValue = ""; + linkLine = -1; + continue; + } + + if (inFigmaBlock) { + if (trimmed === ":::") { + // End of block - check if link was provided + if (!hasLink) { + issues.push({ + severity: "error", + message: ":::figma block requires a 'link=' property", + range: { + startLine: figmaBlockStart, + startColumn: 0, + endLine: figmaBlockStart, + endColumn: lines[figmaBlockStart].length, + }, + code: "figma-missing-link", + }); + } else if (linkValue && !isValidFigmaUrl(linkValue)) { + issues.push({ + severity: "warning", + message: + "Invalid Figma URL. Expected format: https://www.figma.com/...", + range: { + startLine: linkLine, + startColumn: 0, + endLine: linkLine, + endColumn: lines[linkLine].length, + }, + code: "figma-invalid-url", + data: { url: linkValue }, + }); + } + inFigmaBlock = false; + continue; + } + + // Check for link property + const linkMatch = trimmed.match(/^link\s*=\s*(.+)$/); + if (linkMatch) { + hasLink = true; + linkValue = linkMatch[1].trim(); + linkLine = i; + } + + // Check x/y values + const posMatch = trimmed.match(/^(x|y)\s*=\s*(.+)$/); + if (posMatch) { + const prop = posMatch[1]; + const value = posMatch[2]; + if (value && Number.isNaN(Number.parseFloat(value.replace("%", "")))) { + issues.push({ + severity: "warning", + message: `Invalid ${prop} value: expected number or percentage`, + range: { + startLine: i, + startColumn: 0, + endLine: i, + endColumn: line.length, + }, + code: "figma-invalid-position", + }); + } + } + } + } + + // Check for unclosed block + if (inFigmaBlock) { + issues.push({ + severity: "error", + message: "Unclosed :::figma block", + range: { + startLine: figmaBlockStart, + startColumn: 0, + endLine: figmaBlockStart, + endColumn: lines[figmaBlockStart].length, + }, + code: "figma-unclosed", + }); + } + + return issues; +} + +/** + * Check if URL is a valid Figma URL + * @internal Exported for testing + */ +export function isValidFigmaUrl(url: string): boolean { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + return hostname === "figma.com" || hostname.endsWith(".figma.com"); + } catch { + return false; + } +} + +/** + * Analyze :::columns blocks for issues + * @internal Exported for testing + */ +export function analyzeColumnsBlocks(lines: string[]): Issue[] { + const issues: Issue[] = []; + + let inCodeFence = false; + let inColumnsBlock = false; + let columnsBlockStart = -1; + let columnCount = 0; + let columnsParams = ""; + let columnsParamsLine = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track code fences + if (trimmed.match(/^(```+|~~~+)/)) { + inCodeFence = !inCodeFence; + continue; + } + + if (inCodeFence) continue; + + const columnsMatch = trimmed.match(/^:::columns\s*(.*)$/); + if (columnsMatch) { + inColumnsBlock = true; + columnsBlockStart = i; + columnCount = 0; + columnsParams = columnsMatch[1]; + columnsParamsLine = i; + continue; + } + + if (inColumnsBlock) { + if (trimmed === ":::column") { + columnCount++; + continue; + } + + if (trimmed === ":::") { + // End of block - validate + if (columnCount < 2) { + issues.push({ + severity: "warning", + message: `Column block has ${columnCount} column(s), minimum is 2. Content will be rendered linearly.`, + range: { + startLine: columnsBlockStart, + startColumn: 0, + endLine: columnsBlockStart, + endColumn: lines[columnsBlockStart].length, + }, + code: "columns-too-few", + }); + } + + if (columnCount > 4) { + issues.push({ + severity: "info", + message: `Column block has ${columnCount} columns, maximum is 4. Only first 4 columns will be used.`, + range: { + startLine: columnsBlockStart, + startColumn: 0, + endLine: columnsBlockStart, + endColumn: lines[columnsBlockStart].length, + }, + code: "columns-too-many", + }); + } + + // Validate gap parameter + if (columnsParams) { + const gapMatch = columnsParams.match(/gap\s*=\s*(\d+)/); + if (gapMatch) { + const gap = Number.parseInt(gapMatch[1], 10); + if (gap > 200) { + issues.push({ + severity: "warning", + message: `Gap value ${gap} exceeds maximum (200). Value will be clamped.`, + range: { + startLine: columnsParamsLine, + startColumn: 0, + endLine: columnsParamsLine, + endColumn: lines[columnsParamsLine].length, + }, + code: "columns-gap-exceeded", + }); + } + } + + // Validate width parameter + const widthMatch = columnsParams.match(/width\s*=\s*([^\s]+)/); + if (widthMatch) { + const widthValue = widthMatch[1]; + const widths = widthValue.split("/"); + if (columnCount > 0 && widths.length !== columnCount) { + issues.push({ + severity: "warning", + message: `Width specifies ${widths.length} values but block has ${columnCount} columns. Will use even split.`, + range: { + startLine: columnsParamsLine, + startColumn: 0, + endLine: columnsParamsLine, + endColumn: lines[columnsParamsLine].length, + }, + code: "columns-width-mismatch", + }); + } + } + } + + inColumnsBlock = false; + } + } + } + + // Check for unclosed block + if (inColumnsBlock) { + issues.push({ + severity: "error", + message: "Unclosed :::columns block", + range: { + startLine: columnsBlockStart, + startColumn: 0, + endLine: columnsBlockStart, + endColumn: lines[columnsBlockStart].length, + }, + code: "columns-unclosed", + }); + } + + return issues; +} diff --git a/packages/vscode/src/diagnostics/codeActions.ts b/packages/vscode/src/diagnostics/codeActions.ts new file mode 100644 index 0000000..b6a0067 --- /dev/null +++ b/packages/vscode/src/diagnostics/codeActions.ts @@ -0,0 +1,149 @@ +import * as vscode from "vscode"; + +/** + * CodeAction provider for figdeck diagnostics + */ +export class FigdeckCodeActionProvider implements vscode.CodeActionProvider { + public static readonly providedCodeActionKinds = [ + vscode.CodeActionKind.QuickFix, + ]; + + provideCodeActions( + document: vscode.TextDocument, + _range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + _token: vscode.CancellationToken, + ): vscode.CodeAction[] { + const actions: vscode.CodeAction[] = []; + + for (const diagnostic of context.diagnostics) { + if (diagnostic.source !== "figdeck") continue; + + const action = this.createCodeAction(document, diagnostic); + if (action) { + actions.push(action); + } + } + + return actions; + } + + private createCodeAction( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + ): vscode.CodeAction | null { + const code = diagnostic.code as string | undefined; + if (!code) return null; + + switch (code) { + case "figma-invalid-url": + return this.createFigmaUrlFix(document, diagnostic); + case "figma-missing-link": + return this.createAddLinkPropertyFix(document, diagnostic); + case "columns-gap-exceeded": + return this.createClampGapFix(document, diagnostic); + default: + return null; + } + } + + /** + * Fix for invalid Figma URL - suggest fixing common issues + */ + private createFigmaUrlFix( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + ): vscode.CodeAction | null { + const line = document.lineAt(diagnostic.range.start.line); + const text = line.text; + + // Check if it's a bare URL (missing link=) + const bareUrlMatch = text.match(/^(https:\/\/[^\s]+)$/); + if (bareUrlMatch) { + const action = new vscode.CodeAction( + "Convert to link= property", + vscode.CodeActionKind.QuickFix, + ); + action.edit = new vscode.WorkspaceEdit(); + action.edit.replace(document.uri, line.range, `link=${bareUrlMatch[1]}`); + action.diagnostics = [diagnostic]; + action.isPreferred = true; + return action; + } + + return null; + } + + /** + * Add link= property to :::figma block + */ + private createAddLinkPropertyFix( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + ): vscode.CodeAction | null { + const lineNumber = diagnostic.range.start.line; + + // Find the next line after :::figma to insert + const nextLine = lineNumber + 1; + if (nextLine >= document.lineCount) return null; + + const action = new vscode.CodeAction( + "Add link= property", + vscode.CodeActionKind.QuickFix, + ); + action.edit = new vscode.WorkspaceEdit(); + + const insertPosition = new vscode.Position(nextLine, 0); + action.edit.insert( + document.uri, + insertPosition, + "link=https://www.figma.com/file/xxx/name?node-id=1234-5678\n", + ); + action.diagnostics = [diagnostic]; + return action; + } + + /** + * Clamp gap value to maximum + */ + private createClampGapFix( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + ): vscode.CodeAction | null { + const line = document.lineAt(diagnostic.range.start.line); + const text = line.text; + + const gapMatch = text.match(/(gap\s*=\s*)(\d+)/); + if (!gapMatch) return null; + + const action = new vscode.CodeAction( + "Set gap to maximum (200)", + vscode.CodeActionKind.QuickFix, + ); + action.edit = new vscode.WorkspaceEdit(); + + const newText = text.replace(/(gap\s*=\s*)\d+/, "$1200"); + action.edit.replace(document.uri, line.range, newText); + action.diagnostics = [diagnostic]; + action.isPreferred = true; + return action; + } +} + +/** + * Register the code action provider + */ +export function registerCodeActionProvider( + context: vscode.ExtensionContext, +): void { + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider( + { language: "markdown", scheme: "file" }, + new FigdeckCodeActionProvider(), + { + providedCodeActionKinds: + FigdeckCodeActionProvider.providedCodeActionKinds, + }, + ), + ); +} diff --git a/packages/vscode/src/diagnostics/collection.ts b/packages/vscode/src/diagnostics/collection.ts new file mode 100644 index 0000000..b683cc1 --- /dev/null +++ b/packages/vscode/src/diagnostics/collection.ts @@ -0,0 +1,205 @@ +import * as vscode from "vscode"; +import { isFigdeckDocument } from "../authoring/slideParser"; +import type { AnalysisResult, Issue } from "./types"; +import { issueToVSCodeDiagnostic } from "./types"; + +/** + * Analyzer function type + */ +export type Analyzer = ( + document: vscode.TextDocument, + basePath: string, +) => Promise | AnalysisResult; + +/** + * Manages diagnostic collection with debouncing + */ +export class DiagnosticsManager implements vscode.Disposable { + private diagnosticCollection: vscode.DiagnosticCollection; + private analyzer: Analyzer; + private debounceMs: number; + private pendingAnalyses = new Map(); + private disposables: vscode.Disposable[] = []; + private isEnabled = true; + + constructor( + analyzer: Analyzer, + options: { debounceMs?: number; enabled?: boolean } = {}, + ) { + this.analyzer = analyzer; + this.debounceMs = options.debounceMs ?? 300; + this.isEnabled = options.enabled ?? true; + + this.diagnosticCollection = + vscode.languages.createDiagnosticCollection("figdeck"); + + // Listen for document events + this.disposables.push( + vscode.workspace.onDidOpenTextDocument((doc) => { + this.scheduleAnalysis(doc); + }), + + vscode.workspace.onDidChangeTextDocument((e) => { + this.scheduleAnalysis(e.document); + }), + + vscode.workspace.onDidSaveTextDocument((doc) => { + // Immediate analysis on save + this.runAnalysis(doc); + }), + + vscode.workspace.onDidCloseTextDocument((doc) => { + this.clearDiagnostics(doc.uri); + this.cancelPendingAnalysis(doc.uri.toString()); + }), + + // Listen for configuration changes + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("figdeck.diagnostics")) { + this.updateConfiguration(); + return; + } + + if (e.affectsConfiguration("figdeck.images")) { + // Re-analyze to reflect image-related diagnostics settings. + for (const doc of vscode.workspace.textDocuments) { + this.scheduleAnalysis(doc); + } + } + }), + ); + + // Analyze all open markdown documents + for (const doc of vscode.workspace.textDocuments) { + this.scheduleAnalysis(doc); + } + } + + /** + * Update configuration from settings + */ + private updateConfiguration(): void { + const config = vscode.workspace.getConfiguration("figdeck.diagnostics"); + this.isEnabled = config.get("enabled", true); + this.debounceMs = config.get("debounceMs", 300); + + if (!this.isEnabled) { + // Clear all diagnostics if disabled + this.diagnosticCollection.clear(); + } else { + // Re-analyze all open documents + for (const doc of vscode.workspace.textDocuments) { + this.scheduleAnalysis(doc); + } + } + } + + /** + * Check if document should be analyzed + * Only analyzes markdown files with `figdeck: true` in global frontmatter + */ + private shouldAnalyze(document: vscode.TextDocument): boolean { + if (!this.isEnabled) return false; + if (document.languageId !== "markdown") return false; + if (!isFigdeckDocument(document.getText())) return false; + return true; + } + + /** + * Schedule analysis with debouncing + */ + private scheduleAnalysis(document: vscode.TextDocument): void { + if (!this.shouldAnalyze(document)) return; + + const uri = document.uri.toString(); + + // Cancel any pending analysis + this.cancelPendingAnalysis(uri); + + // Schedule new analysis + const timeout = setTimeout(() => { + this.pendingAnalyses.delete(uri); + this.runAnalysis(document); + }, this.debounceMs); + + this.pendingAnalyses.set(uri, timeout); + } + + /** + * Cancel pending analysis for a document + */ + private cancelPendingAnalysis(uriString: string): void { + const pending = this.pendingAnalyses.get(uriString); + if (pending) { + clearTimeout(pending); + this.pendingAnalyses.delete(uriString); + } + } + + /** + * Run analysis on a document + */ + private async runAnalysis(document: vscode.TextDocument): Promise { + if (!this.shouldAnalyze(document)) return; + + try { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + const basePath = workspaceFolder?.uri.fsPath ?? ""; + + const result = await this.analyzer(document, basePath); + this.setDiagnostics(document.uri, result.issues); + } catch (error) { + console.error("figdeck: Error analyzing document", error); + } + } + + /** + * Set diagnostics for a document + */ + private setDiagnostics(uri: vscode.Uri, issues: Issue[]): void { + const diagnostics = issues.map(issueToVSCodeDiagnostic); + this.diagnosticCollection.set(uri, diagnostics); + } + + /** + * Clear diagnostics for a document + */ + private clearDiagnostics(uri: vscode.Uri): void { + this.diagnosticCollection.delete(uri); + } + + /** + * Force refresh diagnostics for a document + */ + refresh(document?: vscode.TextDocument): void { + if (document) { + this.runAnalysis(document); + } else { + for (const doc of vscode.workspace.textDocuments) { + if (this.shouldAnalyze(doc)) { + this.runAnalysis(doc); + } + } + } + } + + /** + * Dispose resources + */ + dispose(): void { + // Cancel all pending analyses + for (const timeout of this.pendingAnalyses.values()) { + clearTimeout(timeout); + } + this.pendingAnalyses.clear(); + + // Dispose subscriptions + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.disposables = []; + + // Dispose diagnostic collection + this.diagnosticCollection.dispose(); + } +} diff --git a/packages/vscode/src/diagnostics/frontmatterValidator.ts b/packages/vscode/src/diagnostics/frontmatterValidator.ts new file mode 100644 index 0000000..6936741 --- /dev/null +++ b/packages/vscode/src/diagnostics/frontmatterValidator.ts @@ -0,0 +1,655 @@ +import { parse as parseYaml } from "yaml"; +import type { Issue } from "./types"; + +/** + * Property definition for validation + */ +interface PropertyDef { + type: "string" | "number" | "boolean" | "object" | "array"; + values?: string[]; + pattern?: RegExp; + min?: number; + max?: number; + children?: Record; + patternError?: string; +} + +/** + * Frontmatter schema definition + */ +const FRONTMATTER_SCHEMA: Record = { + figdeck: { + type: "boolean", + }, + background: { + type: "string", + pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, + patternError: "Invalid color format. Use #rgb or #rrggbb", + }, + gradient: { + type: "string", + pattern: /^#[0-9a-fA-F]{3,6}:\d+%/, + patternError: "Invalid gradient format. Use #color:0%,#color:100%[@angle]", + }, + backgroundImage: { + type: "string", + }, + template: { + type: "string", + }, + color: { + type: "string", + pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, + patternError: "Invalid color format. Use #rgb or #rrggbb", + }, + align: { + type: "string", + values: ["left", "center", "right"], + }, + valign: { + type: "string", + values: ["top", "middle", "bottom"], + }, + headings: { + type: "object", + children: { + h1: { type: "object", children: createStyleSchema() }, + h2: { type: "object", children: createStyleSchema() }, + h3: { type: "object", children: createStyleSchema() }, + h4: { type: "object", children: createStyleSchema() }, + }, + }, + paragraphs: { + type: "object", + children: createStyleSchema(), + }, + bullets: { + type: "object", + children: { + ...createStyleSchema(), + spacing: { type: "number", min: 0 }, + }, + }, + code: { + type: "object", + children: { + size: { type: "number", min: 1 }, + }, + }, + fonts: { + type: "object", + children: { + h1: { type: "object", children: createFontSchema() }, + h2: { type: "object", children: createFontSchema() }, + h3: { type: "object", children: createFontSchema() }, + h4: { type: "object", children: createFontSchema() }, + body: { type: "object", children: createFontSchema() }, + bullets: { type: "object", children: createFontSchema() }, + code: { type: "object", children: createFontSchema() }, + }, + }, + slideNumber: { + type: "object", + children: { + show: { type: "boolean" }, + position: { + type: "string", + values: ["bottom-right", "bottom-left", "top-right", "top-left"], + }, + size: { type: "number", min: 1 }, + color: { + type: "string", + pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, + patternError: "Invalid color format", + }, + format: { type: "string" }, + link: { + type: "string", + pattern: /^https:\/\/(www\.)?figma\.com\//, + patternError: "Must be a valid Figma URL", + }, + startFrom: { type: "number", min: 1 }, + }, + }, + titlePrefix: { + type: "object", + children: { + link: { + type: "string", + pattern: /^https:\/\/(www\.)?figma\.com\//, + patternError: "Must be a valid Figma URL", + }, + spacing: { type: "number", min: 0 }, + }, + }, + transition: { + type: "object", + children: { + style: { + type: "string", + values: [ + "none", + "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", + "move-from-left", + "move-from-right", + "move-from-top", + "move-from-bottom", + "slide-out-to-left", + "slide-out-to-right", + "slide-out-to-top", + "slide-out-to-bottom", + "move-out-to-left", + "move-out-to-right", + "move-out-to-top", + "move-out-to-bottom", + ], + }, + duration: { type: "number", min: 0.01, max: 10 }, + curve: { + type: "string", + values: [ + "ease-in", + "ease-out", + "ease-in-and-out", + "linear", + "gentle", + "quick", + "bouncy", + "slow", + ], + }, + timing: { + type: "object", + children: { + type: { type: "string", values: ["on-click", "after-delay"] }, + delay: { type: "number", min: 0, max: 30 }, + }, + }, + }, + }, +}; + +/** + * Create style schema (size, color, x, y) + */ +function createStyleSchema(): Record { + return { + size: { type: "number", min: 1 }, + color: { + type: "string", + pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, + patternError: "Invalid color format", + }, + x: { type: "number" }, + y: { type: "number" }, + }; +} + +/** + * Create font schema + */ +function createFontSchema(): Record { + return { + family: { type: "string" }, + style: { type: "string" }, + bold: { type: "string" }, + italic: { type: "string" }, + boldItalic: { type: "string" }, + }; +} + +/** + * Find line number for a YAML key in the content + */ +function findKeyLine( + lines: string[], + startLine: number, + endLine: number, + keyPath: string[], +): number { + const targetKey = keyPath[keyPath.length - 1]; + const depth = keyPath.length - 1; + const expectedIndent = depth * 2; + + for (let i = startLine; i <= endLine && i < lines.length; i++) { + const line = lines[i]; + const match = line.match(/^(\s*)([a-zA-Z][\w-]*):/); + if (match) { + const indent = match[1].length; + const key = match[2]; + if (key === targetKey && Math.abs(indent - expectedIndent) <= 2) { + return i; + } + } + } + return startLine; +} + +/** + * Validate a value against a property definition + */ +function validateValue( + value: unknown, + def: PropertyDef, + keyPath: string[], + lines: string[], + startLine: number, + endLine: number, +): Issue[] { + const issues: Issue[] = []; + const line = findKeyLine(lines, startLine, endLine, keyPath); + const keyName = keyPath.join("."); + const lineLength = lines[line]?.length ?? 100; + + // Type check + if (def.type === "number") { + if (typeof value !== "number") { + issues.push({ + code: "frontmatter-invalid-type", + message: `'${keyName}' must be a number`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + return issues; + } + if (def.min !== undefined && value < def.min) { + issues.push({ + code: "frontmatter-out-of-range", + message: `'${keyName}' must be at least ${def.min}`, + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + if (def.max !== undefined && value > def.max) { + issues.push({ + code: "frontmatter-out-of-range", + message: `'${keyName}' must be at most ${def.max}`, + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + } else if (def.type === "boolean") { + if (typeof value !== "boolean") { + issues.push({ + code: "frontmatter-invalid-type", + message: `'${keyName}' must be true or false`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + } else if (def.type === "string") { + if (typeof value !== "string") { + issues.push({ + code: "frontmatter-invalid-type", + message: `'${keyName}' must be a string`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + return issues; + } + // Value validation + if (def.values && !def.values.includes(value)) { + issues.push({ + code: "frontmatter-invalid-value", + message: `'${keyName}' must be one of: ${def.values.join(", ")}`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + // Pattern validation + if (def.pattern && !def.pattern.test(value)) { + issues.push({ + code: "frontmatter-invalid-format", + message: def.patternError || `'${keyName}' has invalid format`, + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + } else if (def.type === "object" && def.children) { + if (typeof value === "object" && value !== null) { + issues.push( + ...validateObject( + value as Record, + def.children, + keyPath, + lines, + startLine, + endLine, + ), + ); + } else if (typeof value !== "boolean") { + // Allow boolean false for properties like titlePrefix that can be disabled + // But report error for other non-object types (strings, numbers) + issues.push({ + code: "frontmatter-invalid-type", + message: `'${keyName}' must be an object or false`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + } + + return issues; +} + +/** + * Validate an object against a schema + */ +function validateObject( + obj: Record, + schema: Record, + parentPath: string[], + lines: string[], + startLine: number, + endLine: number, +): Issue[] { + const issues: Issue[] = []; + + for (const [key, value] of Object.entries(obj)) { + const keyPath = [...parentPath, key]; + const def = schema[key]; + + if (!def) { + // Unknown property warning + const line = findKeyLine(lines, startLine, endLine, keyPath); + const lineLength = lines[line]?.length ?? 100; + issues.push({ + code: "frontmatter-unknown-property", + message: `Unknown property '${keyPath.join(".")}'`, + severity: "info", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + continue; + } + + issues.push( + ...validateValue(value, def, keyPath, lines, startLine, endLine), + ); + } + + return issues; +} + +/** + * Check if a line looks like YAML key-value + */ +function isYamlLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return true; // Empty lines are OK in YAML + // YAML comments start with # but NOT Markdown headings (## or more followed by space) + if (trimmed.startsWith("#") && !/^#{1,6}\s/.test(trimmed)) return true; + // Key: value or key with nested content + if (/^[a-zA-Z_][a-zA-Z0-9_-]*:/.test(trimmed)) return true; + // Indented content (continuation of previous value) + if (/^\s+/.test(line) && trimmed) return true; + return false; +} + +/** + * Check if a line is a content line (heading, paragraph, directive, etc.) + */ +function isContentLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + // Headings + if (/^#{1,6}\s/.test(trimmed)) return true; + // Directives + if (trimmed.startsWith(":::")) return true; + // Images + if (/^!\[/.test(trimmed)) return true; + // Lists (but not YAML lists which start with "- " followed by key:) + if ( + /^[-*+]\s+[^a-zA-Z_]/.test(trimmed) || + /^[-*+]\s+[a-zA-Z_][^:]*$/.test(trimmed) + ) + return true; + // Ordered lists + if (/^\d+\.\s/.test(trimmed)) return true; + // Blockquotes + if (trimmed.startsWith(">")) return true; + // Code fences + if (/^(`{3,}|~{3,})/.test(trimmed)) return true; + return false; +} + +/** + * Extract frontmatter blocks from lines (both explicit and implicit) + */ +function extractFrontmatterBlocks( + lines: string[], +): Array<{ startLine: number; endLine: number; content: string }> { + const blocks: Array<{ + startLine: number; + endLine: number; + content: string; + }> = []; + let i = 0; + let inCodeFence = false; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track code fences + if (/^(`{3,}|~{3,})/.test(trimmed)) { + inCodeFence = !inCodeFence; + i++; + continue; + } + + if (inCodeFence) { + i++; + continue; + } + + // Check for explicit frontmatter (---) + if (trimmed === "---") { + const startLine = i; + i++; + const contentLines: string[] = []; + + // Look ahead to see if next lines are YAML (and not content) + let hasYamlContent = false; + let hasContentLine = false; + let j = i; + while (j < lines.length && lines[j].trim() !== "---") { + const checkLine = lines[j]; + // If we find a content line (heading, directive, etc.), this isn't frontmatter + if (isContentLine(checkLine)) { + hasContentLine = true; + break; + } + if (isYamlLine(checkLine) && checkLine.trim()) { + hasYamlContent = true; + } + j++; + } + + if (j < lines.length && hasYamlContent && !hasContentLine) { + // Explicit frontmatter block (--- to ---) + while (i < lines.length && lines[i].trim() !== "---") { + contentLines.push(lines[i]); + i++; + } + blocks.push({ + startLine: startLine + 1, + endLine: i - 1, + content: contentLines.join("\n"), + }); + i++; // Skip closing --- + + // Check for implicit frontmatter immediately after explicit block + // (YAML content after closing --- without its own opening ---) + if (i < lines.length && lines[i].trim() !== "---") { + const implicitAfterStart = i; + const implicitAfterLines: string[] = []; + + while (i < lines.length) { + const currentLine = lines[i]; + const currentTrimmed = currentLine.trim(); + + // Stop at next separator or content + if (currentTrimmed === "---") break; + if (isContentLine(currentLine)) break; + + // Check if it's YAML-like + if (isYamlLine(currentLine)) { + implicitAfterLines.push(currentLine); + i++; + } else { + break; + } + } + + // Only add if we found actual YAML content + const implicitAfterContent = implicitAfterLines.join("\n").trim(); + if ( + implicitAfterContent && + /^[a-zA-Z_][a-zA-Z0-9_-]*:/.test(implicitAfterContent) + ) { + blocks.push({ + startLine: implicitAfterStart, + endLine: i - 1, + content: implicitAfterContent, + }); + } + } + } else { + // Check for implicit frontmatter (YAML after --- without closing ---) + const implicitStart = i; + const implicitLines: string[] = []; + + while (i < lines.length) { + const currentLine = lines[i]; + const currentTrimmed = currentLine.trim(); + + // Stop at next separator or content + if (currentTrimmed === "---") break; + if (isContentLine(currentLine)) break; + + // Check if it's YAML-like + if (isYamlLine(currentLine)) { + implicitLines.push(currentLine); + i++; + } else { + break; + } + } + + // Only add if we found actual YAML content + const yamlContent = implicitLines.join("\n").trim(); + if (yamlContent && /^[a-zA-Z_][a-zA-Z0-9_-]*:/.test(yamlContent)) { + blocks.push({ + startLine: implicitStart, + endLine: i - 1, + content: yamlContent, + }); + } + } + } else { + i++; + } + } + + return blocks; +} + +/** + * Analyze frontmatter blocks for validation issues + */ +export function validateFrontmatter(lines: string[]): Issue[] { + const issues: Issue[] = []; + const blocks = extractFrontmatterBlocks(lines); + + for (const block of blocks) { + if (!block.content.trim()) continue; + + try { + const parsed = parseYaml(block.content); + if (typeof parsed !== "object" || parsed === null) continue; + + issues.push( + ...validateObject( + parsed as Record, + FRONTMATTER_SCHEMA, + [], + lines, + block.startLine, + block.endLine, + ), + ); + } catch (e) { + // YAML parse error + if (e instanceof Error) { + const lineLength = lines[block.startLine]?.length ?? 100; + issues.push({ + code: "frontmatter-parse-error", + message: `YAML parse error: ${e.message}`, + severity: "error", + range: { + startLine: block.startLine, + startColumn: 0, + endLine: block.startLine, + endColumn: lineLength, + }, + }); + } + } + } + + return issues; +} diff --git a/packages/vscode/src/diagnostics/types.ts b/packages/vscode/src/diagnostics/types.ts new file mode 100644 index 0000000..39725b5 --- /dev/null +++ b/packages/vscode/src/diagnostics/types.ts @@ -0,0 +1,83 @@ +import type * as vscode from "vscode"; + +/** + * Severity level for diagnostic issues + */ +export type IssueSeverity = "error" | "warning" | "info" | "hint"; + +/** + * A diagnostic issue found in the document + */ +export interface Issue { + /** Severity of the issue */ + severity: IssueSeverity; + /** Human-readable message describing the issue */ + message: string; + /** Range in the document where the issue occurs */ + range: { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + }; + /** Optional code for the diagnostic (for CodeAction matching) */ + code?: string; + /** Optional data for quick fixes */ + data?: unknown; + /** Optional source identifier */ + source?: string; +} + +/** + * Result of analyzing a document + */ +export interface AnalysisResult { + issues: Issue[]; +} + +/** + * Convert IssueSeverity to VS Code DiagnosticSeverity + */ +export function toVSCodeSeverity( + severity: IssueSeverity, +): vscode.DiagnosticSeverity { + // Import dynamically to avoid issues at module load time + const vscode = require("vscode") as typeof import("vscode"); + + switch (severity) { + case "error": + return vscode.DiagnosticSeverity.Error; + case "warning": + return vscode.DiagnosticSeverity.Warning; + case "info": + return vscode.DiagnosticSeverity.Information; + case "hint": + return vscode.DiagnosticSeverity.Hint; + } +} + +/** + * Convert an Issue to a VS Code Diagnostic + */ +export function issueToVSCodeDiagnostic(issue: Issue): vscode.Diagnostic { + const vscode = require("vscode") as typeof import("vscode"); + + const range = new vscode.Range( + new vscode.Position(issue.range.startLine, issue.range.startColumn), + new vscode.Position(issue.range.endLine, issue.range.endColumn), + ); + + const diagnostic = new vscode.Diagnostic( + range, + issue.message, + toVSCodeSeverity(issue.severity), + ); + + diagnostic.source = issue.source ?? "figdeck"; + + if (issue.code) { + diagnostic.code = issue.code; + } + + return diagnostic; +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 0000000..fad09dd --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,210 @@ +import * as vscode from "vscode"; +import { registerFrontmatterCompletion } from "./authoring/frontmatterCompletion"; +import { + navigateSlide, + revealSlide, + type SlideInfo, + SlideOutlineProvider, +} from "./authoring/slideOutline"; +import { analyzeDocument } from "./diagnostics/analyzer"; +import { registerCodeActionProvider } from "./diagnostics/codeActions"; +import { DiagnosticsManager } from "./diagnostics/collection"; +import { + detectCli, + runCli, + showCliNotFoundNotification, +} from "./ops/figdeckCli"; +import { ServerManager } from "./ops/serverManager"; + +let outputChannel: vscode.OutputChannel; +let slideOutlineProvider: SlideOutlineProvider; +let diagnosticsManager: DiagnosticsManager; +let serverManager: ServerManager; + +export function activate(context: vscode.ExtensionContext) { + outputChannel = vscode.window.createOutputChannel("figdeck"); + outputChannel.appendLine("figdeck extension activated"); + + // Create slide outline provider + slideOutlineProvider = new SlideOutlineProvider(); + + // Create diagnostics manager + const config = vscode.workspace.getConfiguration("figdeck.diagnostics"); + diagnosticsManager = new DiagnosticsManager(analyzeDocument, { + debounceMs: config.get("debounceMs", 300), + enabled: config.get("enabled", true), + }); + + // Create server manager + serverManager = new ServerManager(outputChannel); + + // Register TreeView + const treeView = vscode.window.createTreeView("figdeck.slideOutline", { + treeDataProvider: slideOutlineProvider, + showCollapseAll: false, + }); + + context.subscriptions.push(treeView); + context.subscriptions.push(diagnosticsManager); + context.subscriptions.push(serverManager); + + // Register code action provider + registerCodeActionProvider(context); + + // Register frontmatter completion provider + registerFrontmatterCompletion(context); + + // Register commands + context.subscriptions.push( + // Init command + vscode.commands.registerCommand("figdeck.init", async () => { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const cliResult = await detectCli(workspaceFolder); + + if (!cliResult.found) { + await showCliNotFoundNotification(); + return; + } + + // Ask for output filename + const filename = await vscode.window.showInputBox({ + prompt: "Enter filename for the new slides file", + value: "slides.md", + validateInput: (value) => { + if (!value.endsWith(".md")) { + return "Filename must end with .md"; + } + return null; + }, + }); + + if (!filename) return; + + outputChannel.appendLine(`\n[figdeck] Running init...`); + + try { + await runCli(cliResult, { + args: ["init", "-o", filename], + cwd: workspaceFolder?.uri.fsPath, + onStdout: (data) => outputChannel.append(data), + onStderr: (data) => outputChannel.append(`[stderr] ${data}`), + onExit: async (code) => { + if (code === 0) { + vscode.window.showInformationMessage(`Created ${filename}`); + // Open the created file + const filePath = workspaceFolder + ? vscode.Uri.joinPath(workspaceFolder.uri, filename) + : vscode.Uri.file(filename); + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + } else { + vscode.window.showErrorMessage( + `figdeck init failed with code ${code}`, + ); + } + }, + }); + } catch (error) { + vscode.window.showErrorMessage(`Failed to run figdeck init: ${error}`); + } + }), + + // Build command + vscode.commands.registerCommand("figdeck.build", async () => { + const editor = vscode.window.activeTextEditor; + if (!editor || !editor.document.fileName.endsWith(".md")) { + vscode.window.showErrorMessage("Please open a Markdown file to build"); + return; + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const cliResult = await detectCli(workspaceFolder); + + if (!cliResult.found) { + await showCliNotFoundNotification(); + return; + } + + const filePath = editor.document.uri.fsPath; + const outputPath = filePath.replace(/\.md$/, ".json"); + + outputChannel.appendLine(`\n[figdeck] Building ${filePath}...`); + + try { + await runCli(cliResult, { + args: ["build", filePath, "-o", outputPath], + cwd: workspaceFolder?.uri.fsPath, + onStdout: (data) => outputChannel.append(data), + onStderr: (data) => outputChannel.append(`[stderr] ${data}`), + onExit: (code) => { + if (code === 0) { + vscode.window.showInformationMessage(`Built to ${outputPath}`); + } else { + vscode.window.showErrorMessage( + `figdeck build failed with code ${code}`, + ); + } + }, + }); + } catch (error) { + vscode.window.showErrorMessage(`Failed to run figdeck build: ${error}`); + } + }), + + // Serve commands + vscode.commands.registerCommand("figdeck.serve.start", () => { + serverManager.start(); + }), + + vscode.commands.registerCommand("figdeck.serve.stop", () => { + serverManager.stop(); + }), + + vscode.commands.registerCommand("figdeck.serve.restart", () => { + serverManager.restart(); + }), + + vscode.commands.registerCommand("figdeck.serve.quickPick", () => { + serverManager.showQuickPick(); + }), + + // Output command + vscode.commands.registerCommand("figdeck.showOutput", () => { + outputChannel.show(); + }), + + // Slide navigation commands + vscode.commands.registerCommand( + "figdeck.revealSlide", + (document: vscode.TextDocument, slideInfo: SlideInfo) => { + revealSlide(document, slideInfo); + }, + ), + + vscode.commands.registerCommand("figdeck.nextSlide", () => { + navigateSlide(slideOutlineProvider, "next"); + }), + + vscode.commands.registerCommand("figdeck.previousSlide", () => { + navigateSlide(slideOutlineProvider, "previous"); + }), + + vscode.commands.registerCommand("figdeck.refreshOutline", () => { + slideOutlineProvider.refresh(); + }), + + vscode.commands.registerCommand("figdeck.refreshDiagnostics", () => { + diagnosticsManager.refresh(); + }), + ); + + outputChannel.appendLine( + "Commands, TreeView, Diagnostics, and Server Manager registered", + ); +} + +export function deactivate() { + if (outputChannel) { + outputChannel.dispose(); + } +} diff --git a/packages/vscode/src/ops/figdeckCli.ts b/packages/vscode/src/ops/figdeckCli.ts new file mode 100644 index 0000000..d7895df --- /dev/null +++ b/packages/vscode/src/ops/figdeckCli.ts @@ -0,0 +1,202 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +/** + * Result of CLI detection + */ +export interface CliDetectionResult { + found: boolean; + command: string[]; + source: "workspace" | "path" | "config" | "none"; +} + +/** + * Detect figdeck CLI + * + * Priority: + * 1. Workspace node_modules/.bin/figdeck + * 2. PATH figdeck + * 3. Config figdeck.cli.command + * 4. Fallback: npx figdeck@latest + */ +export async function detectCli( + workspaceFolder?: vscode.WorkspaceFolder, +): Promise { + // 1. Check workspace node_modules + if (workspaceFolder) { + const localBin = path.join( + workspaceFolder.uri.fsPath, + "node_modules", + ".bin", + "figdeck", + ); + if (fs.existsSync(localBin)) { + return { + found: true, + command: [localBin], + source: "workspace", + }; + } + } + + // 2. Check PATH + try { + const { execSync } = await import("node:child_process"); + const which = process.platform === "win32" ? "where" : "which"; + execSync(`${which} figdeck`, { stdio: "ignore" }); + return { + found: true, + command: ["figdeck"], + source: "path", + }; + } catch { + // Not found in PATH + } + + // 3. Check config + const config = vscode.workspace.getConfiguration("figdeck.cli"); + const configCommand = config.get("command"); + if (configCommand && configCommand.length > 0) { + return { + found: true, + command: configCommand, + source: "config", + }; + } + + // 4. Fallback to npx figdeck@latest + return { + found: true, + command: ["npx", "figdeck@latest"], + source: "none", + }; +} + +/** + * Options for running CLI commands + */ +export interface RunCliOptions { + args: string[]; + cwd?: string; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + onExit?: (code: number | null) => void; + onError?: (error: Error) => void; +} + +function formatCliCommand(cmd: string, args: string[]): string { + return [cmd, ...args] + .map((part) => (/[^\w@%+=:,./-]/u.test(part) ? JSON.stringify(part) : part)) + .join(" "); +} + +function toSpawnError(error: unknown, cmd: string, args: string[]): Error { + const command = formatCliCommand(cmd, args); + + if (error instanceof Error) { + return new Error( + `Failed to spawn figdeck CLI (${command}): ${error.message}`, + { + cause: error, + }, + ); + } + + return new Error(`Failed to spawn figdeck CLI (${command})`); +} + +/** + * Run a figdeck CLI command + */ +export async function runCli( + cliResult: CliDetectionResult, + options: RunCliOptions, +): Promise { + if (!cliResult.found) { + throw new Error("figdeck CLI not found"); + } + + const [cmd, ...baseArgs] = cliResult.command; + const args = [...baseArgs, ...options.args]; + + let proc: ChildProcess; + try { + proc = spawn(cmd, args, { + cwd: options.cwd, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw toSpawnError(error, cmd, args); + } + + const spawned = new Promise((resolve, reject) => { + const onSpawn = () => { + proc.off("error", onError); + resolve(); + }; + + const onError = (error: Error) => { + proc.off("spawn", onSpawn); + reject(toSpawnError(error, cmd, args)); + }; + + proc.once("spawn", onSpawn); + proc.once("error", onError); + }); + + // Handle spawn errors to avoid crashing the extension host. + proc.on("error", (error) => { + options.onError?.(error); + }); + + if (options.onStdout && proc.stdout) { + proc.stdout.on("data", (data: Buffer) => { + options.onStdout?.(data.toString()); + }); + } + + if (options.onStderr && proc.stderr) { + proc.stderr.on("data", (data: Buffer) => { + options.onStderr?.(data.toString()); + }); + } + + if (options.onExit) { + proc.on("exit", options.onExit); + } + + // Wait until the process is successfully spawned, or surface spawn errors to callers. + await spawned; + + return proc; +} + +/** + * Show CLI not found notification with guidance + */ +export async function showCliNotFoundNotification(): Promise { + const selection = await vscode.window.showWarningMessage( + "figdeck CLI not found. Install it to use figdeck commands.", + "Install with npm", + "Install with bun", + "Configure manually", + ); + + if (selection === "Install with npm") { + const terminal = vscode.window.createTerminal("figdeck"); + terminal.show(); + terminal.sendText("npm install -g figdeck"); + } else if (selection === "Install with bun") { + const terminal = vscode.window.createTerminal("figdeck"); + terminal.show(); + terminal.sendText("bun add -g figdeck"); + } else if (selection === "Configure manually") { + vscode.commands.executeCommand( + "workbench.action.openSettings", + "figdeck.cli.command", + ); + } +} diff --git a/packages/vscode/src/ops/serverManager.ts b/packages/vscode/src/ops/serverManager.ts new file mode 100644 index 0000000..180256c --- /dev/null +++ b/packages/vscode/src/ops/serverManager.ts @@ -0,0 +1,253 @@ +import type { ChildProcess } from "node:child_process"; +import * as vscode from "vscode"; +import { detectCli, runCli, showCliNotFoundNotification } from "./figdeckCli"; + +export type ServerState = "stopped" | "starting" | "running" | "error"; + +/** + * Manages the figdeck serve process + */ +export class ServerManager implements vscode.Disposable { + private process: ChildProcess | null = null; + private state: ServerState = "stopped"; + private statusBarItem: vscode.StatusBarItem; + private outputChannel: vscode.OutputChannel; + private currentFile: string | null = null; + private currentPort: number | null = null; + + private _onStateChange = new vscode.EventEmitter(); + readonly onStateChange = this._onStateChange.event; + + constructor(outputChannel: vscode.OutputChannel) { + this.outputChannel = outputChannel; + + // Create status bar item + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + this.statusBarItem.command = "figdeck.serve.quickPick"; + this.updateStatusBar(); + this.statusBarItem.show(); + } + + /** + * Get current server state + */ + getState(): ServerState { + return this.state; + } + + /** + * Get current port + */ + getPort(): number | null { + return this.currentPort; + } + + /** + * Start the serve process + */ + async start(filePath?: string): Promise { + if (this.state === "running" || this.state === "starting") { + vscode.window.showWarningMessage("figdeck serve is already running"); + return; + } + + // Get file to serve + const file = + filePath ?? vscode.window.activeTextEditor?.document.uri.fsPath; + + if (!file || !file.endsWith(".md")) { + vscode.window.showErrorMessage("Please open a Markdown file to serve"); + return; + } + + // Detect CLI + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const cliResult = await detectCli(workspaceFolder); + + if (!cliResult.found) { + await showCliNotFoundNotification(); + return; + } + + // Get config + const config = vscode.workspace.getConfiguration("figdeck.serve"); + const host = config.get("host", "127.0.0.1"); + const port = config.get("port", 4141); + + this.setState("starting"); + this.currentFile = file; + this.currentPort = port; + + this.outputChannel.appendLine(`\n[figdeck] Starting serve...`); + this.outputChannel.appendLine(`[figdeck] File: ${file}`); + this.outputChannel.appendLine(`[figdeck] Host: ${host}:${port}`); + + try { + const args = ["serve", file, "--host", host, "--port", String(port)]; + + this.process = await runCli(cliResult, { + args, + cwd: workspaceFolder?.uri.fsPath, + onStdout: (data) => { + this.outputChannel.append(data); + // Check for successful start + if (data.includes("WebSocket server") || data.includes("listening")) { + this.setState("running"); + } + }, + onStderr: (data) => { + this.outputChannel.append(`[stderr] ${data}`); + }, + onExit: (code) => { + this.outputChannel.appendLine( + `\n[figdeck] Process exited with code ${code}`, + ); + this.process = null; + this.setState(code === 0 ? "stopped" : "error"); + }, + }); + + // Set running state after a short delay if not already set + setTimeout(() => { + if (this.state === "starting") { + this.setState("running"); + } + }, 1000); + } catch (error) { + this.outputChannel.appendLine(`[figdeck] Error: ${error}`); + this.setState("error"); + vscode.window.showErrorMessage(`Failed to start figdeck serve: ${error}`); + } + } + + /** + * Stop the serve process + */ + stop(): void { + if (!this.process) { + this.setState("stopped"); + return; + } + + this.outputChannel.appendLine("\n[figdeck] Stopping serve..."); + + this.process.kill("SIGTERM"); + + // Force kill after timeout + setTimeout(() => { + if (this.process) { + this.process.kill("SIGKILL"); + this.process = null; + } + }, 3000); + + this.setState("stopped"); + this.currentFile = null; + this.currentPort = null; + } + + /** + * Restart the serve process + */ + async restart(): Promise { + const file = this.currentFile; + this.stop(); + + // Wait for process to fully stop + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (file) { + await this.start(file); + } else { + await this.start(); + } + } + + /** + * Show quick pick menu for serve actions + */ + async showQuickPick(): Promise { + const items: vscode.QuickPickItem[] = []; + + if (this.state === "running") { + items.push( + { + label: "$(debug-stop) Stop Serve", + description: "Stop the running server", + }, + { + label: "$(debug-restart) Restart Serve", + description: "Restart the server", + }, + ); + } else { + items.push({ + label: "$(debug-start) Start Serve", + description: "Start serving the current file", + }); + } + + items.push({ + label: "$(output) Show Output", + description: "Show figdeck output channel", + }); + + const selection = await vscode.window.showQuickPick(items, { + placeHolder: "figdeck serve actions", + }); + + if (!selection) return; + + if (selection.label.includes("Start")) { + await this.start(); + } else if (selection.label.includes("Stop")) { + this.stop(); + } else if (selection.label.includes("Restart")) { + await this.restart(); + } else if (selection.label.includes("Output")) { + this.outputChannel.show(); + } + } + + private setState(state: ServerState): void { + this.state = state; + this.updateStatusBar(); + this._onStateChange.fire(state); + } + + private updateStatusBar(): void { + switch (this.state) { + case "stopped": + this.statusBarItem.text = "$(debug-disconnect) figdeck"; + this.statusBarItem.tooltip = "figdeck serve: stopped"; + this.statusBarItem.backgroundColor = undefined; + break; + case "starting": + this.statusBarItem.text = "$(loading~spin) figdeck"; + this.statusBarItem.tooltip = "figdeck serve: starting..."; + this.statusBarItem.backgroundColor = undefined; + break; + case "running": + this.statusBarItem.text = `$(radio-tower) figdeck :${this.currentPort}`; + this.statusBarItem.tooltip = `figdeck serve: running on port ${this.currentPort}`; + this.statusBarItem.backgroundColor = undefined; + break; + case "error": + this.statusBarItem.text = "$(error) figdeck"; + this.statusBarItem.tooltip = "figdeck serve: error"; + this.statusBarItem.backgroundColor = new vscode.ThemeColor( + "statusBarItem.errorBackground", + ); + break; + } + } + + dispose(): void { + this.stop(); + this.statusBarItem.dispose(); + this._onStateChange.dispose(); + } +} diff --git a/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json b/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json new file mode 100644 index 0000000..a2b471a --- /dev/null +++ b/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "scopeName": "figdeck.markdown.injection", + "injectionSelector": "L:text.html.markdown -markup.fenced_code", + "patterns": [ + { "include": "#figdeck-frontmatter-block" }, + { "include": "#figdeck-columns-block" }, + { "include": "#figdeck-figma-block" }, + { "include": "#figdeck-directive-end" }, + { "include": "#figdeck-slide-separator" }, + { "include": "#figdeck-image-alt-tokens" } + ], + "repository": { + "figdeck-frontmatter-block": { + "name": "meta.embedded.block.frontmatter.figdeck", + "begin": "^(---)[\\t ]*$", + "beginCaptures": { + "1": { "name": "punctuation.definition.frontmatter.begin.figdeck" } + }, + "end": "^(---)[\\t ]*$", + "endCaptures": { + "1": { "name": "punctuation.definition.frontmatter.end.figdeck" } + }, + "patterns": [ + { "include": "#figdeck-yaml-content" } + ] + }, + "figdeck-yaml-content": { + "patterns": [ + { "include": "#figdeck-yaml-comment" }, + { "include": "#figdeck-yaml-key-nested" }, + { "include": "#figdeck-yaml-key-value" } + ] + }, + "figdeck-yaml-comment": { + "name": "comment.line.number-sign.yaml.figdeck", + "match": "^\\s*(#.*)$" + }, + "figdeck-yaml-key-nested": { + "name": "meta.key.yaml.figdeck", + "match": "^(\\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(:)\\s*$", + "captures": { + "2": { "name": "entity.name.tag.yaml.figdeck" }, + "3": { "name": "punctuation.separator.key-value.yaml.figdeck" } + } + }, + "figdeck-yaml-key-value": { + "name": "meta.key-value.yaml.figdeck", + "match": "^(\\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(:)\\s*(.+)?$", + "captures": { + "2": { "name": "entity.name.tag.yaml.figdeck" }, + "3": { "name": "punctuation.separator.key-value.yaml.figdeck" }, + "4": { + "patterns": [ + { "include": "#figdeck-yaml-value" } + ] + } + } + }, + "figdeck-yaml-value": { + "patterns": [ + { + "name": "string.quoted.double.yaml.figdeck", + "match": "\"[^\"]*\"" + }, + { + "name": "string.quoted.single.yaml.figdeck", + "match": "'[^']*'" + }, + { + "name": "constant.language.boolean.yaml.figdeck", + "match": "\\b(true|false|yes|no|on|off)\\b" + }, + { + "name": "constant.numeric.yaml.figdeck", + "match": "\\b[0-9]+(\\.[0-9]+)?\\b" + }, + { + "name": "string.unquoted.yaml.figdeck", + "match": "[^#\\s][^#]*" + } + ] + }, + "figdeck-columns-block": { + "name": "meta.directive.columns.figdeck", + "begin": "^(:::columns)(?:\\s+(.*))?$", + "beginCaptures": { + "1": { "name": "keyword.control.directive.columns.figdeck" }, + "2": { "patterns": [{ "include": "#figdeck-directive-attributes" }] } + }, + "end": "^(:::)\\s*$", + "endCaptures": { + "1": { "name": "keyword.control.directive.end.figdeck" } + }, + "patterns": [ + { "include": "#figdeck-column-directive" }, + { "include": "text.html.markdown" } + ] + }, + "figdeck-column-directive": { + "name": "keyword.control.directive.column.figdeck", + "match": "^(:::column)\\s*$" + }, + "figdeck-figma-block": { + "name": "meta.directive.figma.figdeck", + "begin": "^(:::figma)\\s*$", + "beginCaptures": { + "1": { "name": "keyword.control.directive.figma.figdeck" } + }, + "end": "^(:::)\\s*$", + "endCaptures": { + "1": { "name": "keyword.control.directive.end.figdeck" } + }, + "patterns": [ + { "include": "#figdeck-key-value" } + ] + }, + "figdeck-directive-end": { + "name": "keyword.control.directive.end.figdeck", + "match": "^:::$" + }, + "figdeck-directive-attributes": { + "patterns": [ + { + "name": "meta.attribute.figdeck", + "match": "(gap|width)\\s*(=)\\s*([^\\s]+)", + "captures": { + "1": { "name": "entity.other.attribute-name.figdeck" }, + "2": { "name": "keyword.operator.assignment.figdeck" }, + "3": { "name": "string.unquoted.attribute-value.figdeck" } + } + } + ] + }, + "figdeck-key-value": { + "patterns": [ + { + "name": "meta.key-value.link.figdeck", + "match": "^(link)\\s*(=)\\s*(.+)$", + "captures": { + "1": { "name": "entity.other.attribute-name.link.figdeck" }, + "2": { "name": "keyword.operator.assignment.figdeck" }, + "3": { "name": "string.unquoted.url.figdeck" } + } + }, + { + "name": "meta.key-value.text.figdeck", + "match": "^(text\\.[a-zA-Z0-9_]+)\\s*(=)\\s*(.*)$", + "captures": { + "1": { "name": "entity.other.attribute-name.text-override.figdeck" }, + "2": { "name": "keyword.operator.assignment.figdeck" }, + "3": { "name": "string.unquoted.text-value.figdeck" } + } + }, + { + "name": "meta.key-value.position.figdeck", + "match": "^(x|y)\\s*(=)\\s*([0-9]+%?)$", + "captures": { + "1": { "name": "entity.other.attribute-name.position.figdeck" }, + "2": { "name": "keyword.operator.assignment.figdeck" }, + "3": { "name": "constant.numeric.figdeck" } + } + }, + { + "name": "meta.key-value.hidelink.figdeck", + "match": "^(hideLink)\\s*(=)\\s*(true|false)$", + "captures": { + "1": { "name": "entity.other.attribute-name.figdeck" }, + "2": { "name": "keyword.operator.assignment.figdeck" }, + "3": { "name": "constant.language.boolean.figdeck" } + } + }, + { + "name": "meta.multiline-text.figdeck", + "begin": "^(text\\.[a-zA-Z0-9_]+)\\s*(=)\\s*$", + "beginCaptures": { + "1": { "name": "entity.other.attribute-name.text-override.figdeck" }, + "2": { "name": "keyword.operator.assignment.figdeck" } + }, + "end": "^(?=\\S)", + "patterns": [ + { + "name": "string.unquoted.multiline-text.figdeck", + "match": "^\\s+.+$" + } + ] + } + ] + }, + "figdeck-slide-separator": { + "name": "meta.separator.slide.figdeck", + "match": "^---$", + "captures": { + "0": { "name": "keyword.control.separator.slide.figdeck" } + } + }, + "figdeck-image-alt-tokens": { + "name": "meta.image.size-position.figdeck", + "match": "(w|h|x|y)(:)([0-9]+%?)", + "captures": { + "1": { "name": "entity.other.attribute-name.image-size.figdeck" }, + "2": { "name": "punctuation.separator.figdeck" }, + "3": { "name": "constant.numeric.figdeck" } + } + } + } +} diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json new file mode 100644 index 0000000..f9b0a67 --- /dev/null +++ b/packages/vscode/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From b8a563fde528c0c02cb8fb6d26d968ad6d76583c Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:28:00 +0900 Subject: [PATCH 02/11] fix(vscode): prevent CLI spawn crash and enforce image diagnostics size limit --- packages/vscode/src/authoring/slideParser.ts | 3 - .../vscode/src/diagnostics/analyzer.test.ts | 231 +++++++++++++++++- packages/vscode/src/diagnostics/analyzer.ts | 125 ++++++---- packages/vscode/src/extension.ts | 6 +- packages/vscode/src/ops/cli-runner.test.ts | 66 +++++ packages/vscode/src/ops/cli-runner.ts | 111 +++++++++ packages/vscode/src/ops/figdeckCli.ts | 113 +-------- 7 files changed, 498 insertions(+), 157 deletions(-) create mode 100644 packages/vscode/src/ops/cli-runner.test.ts create mode 100644 packages/vscode/src/ops/cli-runner.ts diff --git a/packages/vscode/src/authoring/slideParser.ts b/packages/vscode/src/authoring/slideParser.ts index a723902..f73142d 100644 --- a/packages/vscode/src/authoring/slideParser.ts +++ b/packages/vscode/src/authoring/slideParser.ts @@ -126,7 +126,6 @@ export function splitIntoSlidesWithRanges(content: string): SlideInfo[] { let currentStartLine = 0; let inFrontmatter = false; let codeFence: string | null = null; - let isFirstBlock = true; const flushSlide = (endLine: number) => { if (currentLines.length === 0) return; @@ -136,10 +135,8 @@ export function splitIntoSlidesWithRanges(content: string): SlideInfo[] { // Skip frontmatter-only blocks (global or per-slide) if (isOnlyFrontmatter(currentLines)) { currentLines = []; - isFirstBlock = false; return; } - isFirstBlock = false; const title = extractSlideTitle(currentLines); slides.push({ diff --git a/packages/vscode/src/diagnostics/analyzer.test.ts b/packages/vscode/src/diagnostics/analyzer.test.ts index 27a8b6a..189887d 100644 --- a/packages/vscode/src/diagnostics/analyzer.test.ts +++ b/packages/vscode/src/diagnostics/analyzer.test.ts @@ -1,13 +1,23 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type * as vscode from "vscode"; import { analyzeColumnsBlocks, + analyzeDocument, analyzeFigmaBlocks, analyzeFrontmatterStructure, + analyzeImages, + clearImageDiagnosticsCache, isValidFigmaUrl, validateImageAlt, } from "./analyzer"; import { validateFrontmatter } from "./frontmatterValidator"; +function makeFileUri(fsPath: string): { scheme: "file"; fsPath: string } { + return { scheme: "file", fsPath }; +} + function splitLines(text: string): string[] { return text.split(/\r?\n/); } @@ -714,3 +724,222 @@ describe("analyzeColumnsBlocks", () => { expect(issues[0].severity).toBe("error"); }); }); + +describe("analyzeImages", () => { + let statSpy: ReturnType | null = null; + + afterEach(() => { + statSpy?.mockRestore(); + statSpy = null; + clearImageDiagnosticsCache(); + }); + + it("skips remote image URLs", async () => { + const basePath = path.resolve("test-workspace"); + const documentUri = makeFileUri(path.join(basePath, "docs", "slides.md")); + + const issues = await analyzeImages( + ["![alt](https://example.com/image.png)"], + basePath, + documentUri as unknown as vscode.Uri, + ); + + expect(issues).toHaveLength(0); + }); + + it("warns for unsupported image formats", async () => { + const basePath = path.resolve("test-workspace"); + const documentUri = makeFileUri(path.join(basePath, "docs", "slides.md")); + + const issues = await analyzeImages( + ["![alt](images/image.webp)"], + basePath, + documentUri as unknown as vscode.Uri, + ); + + expect(issues.some((issue) => issue.code === "image-unsupported-format")).toBe( + true, + ); + }); + + it("warns when a local image exceeds the size limit", async () => { + const basePath = path.resolve("test-workspace"); + const documentPath = path.join(basePath, "docs", "slides.md"); + const documentUri = makeFileUri(documentPath); + + const expectedPath = path.resolve(path.dirname(documentPath), "images/big.png"); + + statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + if (String(filePath) === expectedPath) { + return { + size: 6 * 1024 * 1024, + isFile: () => true, + } as unknown as fs.Stats; + } + throw new Error("ENOENT"); + }) as unknown as typeof fs.promises.stat); + + const issues = await analyzeImages( + ["![alt](images/big.png)"], + basePath, + documentUri as unknown as vscode.Uri, + ); + + expect(statSpy).toHaveBeenCalledWith(expectedPath); + expect(issues.some((issue) => issue.code === "image-too-large")).toBe(true); + }); + + it("supports angle-bracket destinations with spaces", async () => { + const basePath = path.resolve("test-workspace"); + const documentPath = path.join(basePath, "docs", "slides.md"); + const documentUri = makeFileUri(documentPath); + + const expectedPath = path.resolve( + path.dirname(documentPath), + "images/big file.png", + ); + + statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + if (String(filePath) === expectedPath) { + return { + size: 6 * 1024 * 1024, + isFile: () => true, + } as unknown as fs.Stats; + } + throw new Error("ENOENT"); + }) as unknown as typeof fs.promises.stat); + + const issues = await analyzeImages( + ['![alt]( "title")'], + basePath, + documentUri as unknown as vscode.Uri, + ); + + expect(statSpy).toHaveBeenCalledWith(expectedPath); + expect(issues.some((issue) => issue.code === "image-too-large")).toBe(true); + }); + + it("respects an injected maxSizeMb override", async () => { + const basePath = path.resolve("test-workspace"); + const documentPath = path.join(basePath, "docs", "slides.md"); + const documentUri = makeFileUri(documentPath); + + const expectedPath = path.resolve(path.dirname(documentPath), "images/medium.png"); + + statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + if (String(filePath) === expectedPath) { + return { + size: 2 * 1024 * 1024, + isFile: () => true, + } as unknown as fs.Stats; + } + throw new Error("ENOENT"); + }) as unknown as typeof fs.promises.stat); + + const issues = await analyzeImages( + ["![alt](images/medium.png)"], + basePath, + documentUri as unknown as vscode.Uri, + { maxSizeMb: 1 }, + ); + + expect(statSpy).toHaveBeenCalledWith(expectedPath); + expect(issues.some((issue) => issue.code === "image-too-large")).toBe(true); + }); + + it("allows disabling the size check via injected maxSizeMb", async () => { + const basePath = path.resolve("test-workspace"); + const documentPath = path.join(basePath, "docs", "slides.md"); + const documentUri = makeFileUri(documentPath); + + statSpy = spyOn(fs.promises, "stat").mockImplementation((async () => { + throw new Error("Should not be called"); + }) as unknown as typeof fs.promises.stat); + + const issues = await analyzeImages( + ["![alt](images/huge.png)"], + basePath, + documentUri as unknown as vscode.Uri, + { maxSizeMb: null }, + ); + + expect(statSpy).not.toHaveBeenCalled(); + expect(issues.some((issue) => issue.code === "image-too-large")).toBe(false); + }); + + it("caches file stats across calls (within TTL)", async () => { + const basePath = path.resolve("test-workspace"); + const documentPath = path.join(basePath, "docs", "slides.md"); + const documentUri = makeFileUri(documentPath); + + const expectedPath = path.resolve(path.dirname(documentPath), "images/cached.png"); + + statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + if (String(filePath) === expectedPath) { + return { + size: 2 * 1024 * 1024, + isFile: () => true, + } as unknown as fs.Stats; + } + throw new Error("ENOENT"); + }) as unknown as typeof fs.promises.stat); + + await analyzeImages( + ["![alt](images/cached.png)"], + basePath, + documentUri as unknown as vscode.Uri, + { maxSizeMb: 1 }, + ); + + await analyzeImages( + ["![alt](images/cached.png)"], + basePath, + documentUri as unknown as vscode.Uri, + { maxSizeMb: 1 }, + ); + + expect(statSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("analyzeDocument", () => { + let statSpy: ReturnType | null = null; + + afterEach(() => { + statSpy?.mockRestore(); + statSpy = null; + clearImageDiagnosticsCache(); + }); + + it("forwards image options to analyzeImages", async () => { + const basePath = path.resolve("test-workspace"); + const documentPath = path.join(basePath, "docs", "slides.md"); + const documentUri = makeFileUri(documentPath); + + const expectedPath = path.resolve(path.dirname(documentPath), "images/medium.png"); + + statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + if (String(filePath) === expectedPath) { + return { + size: 2 * 1024 * 1024, + isFile: () => true, + } as unknown as fs.Stats; + } + throw new Error("ENOENT"); + }) as unknown as typeof fs.promises.stat); + + const document = { + getText: () => "![alt](images/medium.png)", + uri: documentUri, + } as unknown as vscode.TextDocument; + + const result = await analyzeDocument(document, basePath, { + images: { maxSizeMb: 1 }, + }); + + expect(statSpy).toHaveBeenCalledWith(expectedPath); + expect(result.issues.some((issue) => issue.code === "image-too-large")).toBe( + true, + ); + }); +}); diff --git a/packages/vscode/src/diagnostics/analyzer.ts b/packages/vscode/src/diagnostics/analyzer.ts index 7abd619..9cd71fd 100644 --- a/packages/vscode/src/diagnostics/analyzer.ts +++ b/packages/vscode/src/diagnostics/analyzer.ts @@ -4,19 +4,62 @@ import type * as vscode from "vscode"; import { validateFrontmatter } from "./frontmatterValidator"; import type { AnalysisResult, Issue } from "./types"; -function getMaxImageSizeMb(): number | null { - let maxSizeMb = 5; +type CachedFileStat = { + exists: boolean; + isFile: boolean; + size: number; +}; + +const DEFAULT_MAX_IMAGE_SIZE_MB = 5; + +const STAT_CACHE_TTL_MS = 2000; +const STAT_CACHE_MAX_ENTRIES = 2000; +const statCache = new Map(); + +export function clearImageDiagnosticsCache(): void { + statCache.clear(); +} + +async function statWithCache(filePath: string): Promise { + const now = Date.now(); + const cached = statCache.get(filePath); + if (cached && cached.expiresAt > now) { + return cached.stat; + } + + let stat: CachedFileStat; try { - const vscode = require("vscode") as typeof import("vscode"); - maxSizeMb = vscode.workspace - .getConfiguration("figdeck.images") - .get("maxSizeMb", 5); + const fsStat = await fs.promises.stat(filePath); + stat = { exists: true, isFile: fsStat.isFile(), size: fsStat.size }; } catch { - // Ignore when vscode module isn't available (e.g., unit tests) + stat = { exists: false, isFile: false, size: 0 }; } - if (!Number.isFinite(maxSizeMb) || maxSizeMb <= 0) return null; - return maxSizeMb; + if (statCache.size >= STAT_CACHE_MAX_ENTRIES) { + statCache.clear(); + } + + statCache.set(filePath, { expiresAt: now + STAT_CACHE_TTL_MS, stat }); + return stat; +} + +export interface AnalyzeImagesOptions { + maxSizeMb?: number | null; +} + +export interface AnalyzeDocumentOptions { + images?: AnalyzeImagesOptions; +} + +function resolveMaxSizeMb(options?: AnalyzeImagesOptions): number | null { + const hasOverride = options && Object.hasOwn(options, "maxSizeMb"); + if (!hasOverride) return DEFAULT_MAX_IMAGE_SIZE_MB; + + const value = options?.maxSizeMb; + if (value === undefined) return DEFAULT_MAX_IMAGE_SIZE_MB; + if (value === null) return null; + if (!Number.isFinite(value) || value <= 0) return null; + return value; } function parseImageDestination(raw: string): string { @@ -67,10 +110,11 @@ function toCandidatePaths( /** * Analyze a figdeck markdown document for issues */ -export function analyzeDocument( +export async function analyzeDocument( document: vscode.TextDocument, basePath: string, -): AnalysisResult { + options?: AnalyzeDocumentOptions, +): Promise { const issues: Issue[] = []; const text = document.getText(); const lines = text.split(/\r?\n/); @@ -78,7 +122,7 @@ export function analyzeDocument( // Run all analyzers issues.push(...analyzeFrontmatterStructure(lines)); issues.push(...validateFrontmatter(lines)); - issues.push(...analyzeImages(lines, basePath, document.uri)); + issues.push(...(await analyzeImages(lines, basePath, document.uri, options?.images))); issues.push(...analyzeFigmaBlocks(lines)); issues.push(...analyzeColumnsBlocks(lines)); @@ -151,14 +195,15 @@ export function analyzeFrontmatterStructure(lines: string[]): Issue[] { * Analyze image references for issues * @internal Exported for testing */ -export function analyzeImages( +export async function analyzeImages( lines: string[], basePath: string, documentUri: vscode.Uri, -): Issue[] { + options?: AnalyzeImagesOptions, +): Promise { const issues: Issue[] = []; const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)/g; - const maxSizeMb = getMaxImageSizeMb(); + const maxSizeMb = resolveMaxSizeMb(options); let inCodeFence = false; for (let i = 0; i < lines.length; i++) { @@ -212,34 +257,30 @@ export function analyzeImages( basePath, documentUri, )) { - try { - const stat = fs.statSync(candidatePath); - if (!stat.isFile()) continue; - - if (stat.size > maxSizeBytes) { - const sizeMb = stat.size / 1024 / 1024; - issues.push({ - severity: "warning", - message: `Image is ${sizeMb.toFixed(1)}MB (max ${maxSizeMb}MB). Consider compressing it or raising 'figdeck.images.maxSizeMb'.`, - range: { - startLine: i, - startColumn: startCol, - endLine: i, - endColumn: endCol, - }, - code: "image-too-large", - data: { - url, - filePath: candidatePath, - sizeBytes: stat.size, - maxSizeMb, - }, - }); - } - break; - } catch { - // Ignore missing/unreadable files. + const stat = await statWithCache(candidatePath); + if (!stat.exists || !stat.isFile) continue; + + if (stat.size > maxSizeBytes) { + const sizeMb = stat.size / 1024 / 1024; + issues.push({ + severity: "warning", + message: `Image is ${sizeMb.toFixed(1)}MB (max ${maxSizeMb}MB). Consider compressing it or raising 'figdeck.images.maxSizeMb'.`, + range: { + startLine: i, + startColumn: startCol, + endLine: i, + endColumn: endCol, + }, + code: "image-too-large", + data: { + url, + filePath: candidatePath, + sizeBytes: stat.size, + maxSizeMb, + }, + }); } + break; } } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index fad09dd..99632a0 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -30,7 +30,11 @@ export function activate(context: vscode.ExtensionContext) { // Create diagnostics manager const config = vscode.workspace.getConfiguration("figdeck.diagnostics"); - diagnosticsManager = new DiagnosticsManager(analyzeDocument, { + diagnosticsManager = new DiagnosticsManager((document, basePath) => { + const imagesConfig = vscode.workspace.getConfiguration("figdeck.images"); + const maxSizeMb = imagesConfig.get("maxSizeMb", 5); + return analyzeDocument(document, basePath, { images: { maxSizeMb } }); + }, { debounceMs: config.get("debounceMs", 300), enabled: config.get("enabled", true), }); diff --git a/packages/vscode/src/ops/cli-runner.test.ts b/packages/vscode/src/ops/cli-runner.test.ts new file mode 100644 index 0000000..a40b2c5 --- /dev/null +++ b/packages/vscode/src/ops/cli-runner.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { runCli, type CliDetectionResult } from "./cli-runner"; + +describe("runCli", () => { + it("streams stdout/stderr and reports exit code", async () => { + const cliResult: CliDetectionResult = { + found: true, + command: [process.execPath], + source: "config", + }; + + let stdout = ""; + let stderr = ""; + let exitCode: number | null | undefined; + + const proc = await runCli(cliResult, { + args: ["-e", "console.log('out'); console.error('err');"], + onStdout: (data) => { + stdout += data; + }, + onStderr: (data) => { + stderr += data; + }, + onExit: (code) => { + exitCode = code; + }, + }); + + await new Promise((resolve) => { + proc.on("close", () => resolve()); + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("out"); + expect(stderr).toContain("err"); + }); + + it("rejects on spawn error (ENOENT) without crashing", async () => { + if (process.platform === "win32") return; + + const cliResult: CliDetectionResult = { + found: true, + command: [`definitely-not-a-command-${Date.now()}`], + source: "config", + }; + + let errorFromCallback: Error | null = null; + let caught: Error | null = null; + + try { + await runCli(cliResult, { + args: [], + onError: (error) => { + errorFromCallback = error; + }, + }); + } catch (error) { + caught = error as Error; + } + + expect(errorFromCallback).not.toBe(null); + expect(caught).not.toBe(null); + expect(caught?.message).toContain("Failed to spawn figdeck CLI"); + }); +}); + diff --git a/packages/vscode/src/ops/cli-runner.ts b/packages/vscode/src/ops/cli-runner.ts new file mode 100644 index 0000000..fbb68c1 --- /dev/null +++ b/packages/vscode/src/ops/cli-runner.ts @@ -0,0 +1,111 @@ +import { type ChildProcess, spawn } from "node:child_process"; + +/** + * Result of CLI detection + */ +export interface CliDetectionResult { + found: boolean; + command: string[]; + source: "workspace" | "path" | "config" | "none"; +} + +/** + * Options for running CLI commands + */ +export interface RunCliOptions { + args: string[]; + cwd?: string; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + onExit?: (code: number | null) => void; + onError?: (error: Error) => void; +} + +function formatCliCommand(cmd: string, args: string[]): string { + return [cmd, ...args] + .map((part) => (/[^\w@%+=:,./-]/u.test(part) ? JSON.stringify(part) : part)) + .join(" "); +} + +function toSpawnError(error: unknown, cmd: string, args: string[]): Error { + const command = formatCliCommand(cmd, args); + + if (error instanceof Error) { + return new Error( + `Failed to spawn figdeck CLI (${command}): ${error.message}`, + { + cause: error, + }, + ); + } + + return new Error(`Failed to spawn figdeck CLI (${command})`); +} + +/** + * Run a figdeck CLI command + */ +export async function runCli( + cliResult: CliDetectionResult, + options: RunCliOptions, +): Promise { + if (!cliResult.found) { + throw new Error("figdeck CLI not found"); + } + + const [cmd, ...baseArgs] = cliResult.command; + const args = [...baseArgs, ...options.args]; + + let proc: ChildProcess; + try { + proc = spawn(cmd, args, { + cwd: options.cwd, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw toSpawnError(error, cmd, args); + } + + const spawned = new Promise((resolve, reject) => { + const onSpawn = () => { + proc.off("error", onError); + resolve(); + }; + + const onError = (error: Error) => { + proc.off("spawn", onSpawn); + reject(toSpawnError(error, cmd, args)); + }; + + proc.once("spawn", onSpawn); + proc.once("error", onError); + }); + + // Handle spawn errors to avoid crashing the extension host. + proc.on("error", (error) => { + options.onError?.(error); + }); + + if (options.onStdout && proc.stdout) { + proc.stdout.on("data", (data: Buffer) => { + options.onStdout?.(data.toString()); + }); + } + + if (options.onStderr && proc.stderr) { + proc.stderr.on("data", (data: Buffer) => { + options.onStderr?.(data.toString()); + }); + } + + if (options.onExit) { + proc.on("exit", options.onExit); + } + + // Wait until the process is successfully spawned, or surface spawn errors to callers. + await spawned; + + return proc; +} + diff --git a/packages/vscode/src/ops/figdeckCli.ts b/packages/vscode/src/ops/figdeckCli.ts index d7895df..f7a9448 100644 --- a/packages/vscode/src/ops/figdeckCli.ts +++ b/packages/vscode/src/ops/figdeckCli.ts @@ -1,16 +1,9 @@ -import { type ChildProcess, spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; - -/** - * Result of CLI detection - */ -export interface CliDetectionResult { - found: boolean; - command: string[]; - source: "workspace" | "path" | "config" | "none"; -} +import type { CliDetectionResult } from "./cli-runner"; +export type { CliDetectionResult, RunCliOptions } from "./cli-runner"; +export { runCli } from "./cli-runner"; /** * Detect figdeck CLI @@ -74,106 +67,6 @@ export async function detectCli( }; } -/** - * Options for running CLI commands - */ -export interface RunCliOptions { - args: string[]; - cwd?: string; - onStdout?: (data: string) => void; - onStderr?: (data: string) => void; - onExit?: (code: number | null) => void; - onError?: (error: Error) => void; -} - -function formatCliCommand(cmd: string, args: string[]): string { - return [cmd, ...args] - .map((part) => (/[^\w@%+=:,./-]/u.test(part) ? JSON.stringify(part) : part)) - .join(" "); -} - -function toSpawnError(error: unknown, cmd: string, args: string[]): Error { - const command = formatCliCommand(cmd, args); - - if (error instanceof Error) { - return new Error( - `Failed to spawn figdeck CLI (${command}): ${error.message}`, - { - cause: error, - }, - ); - } - - return new Error(`Failed to spawn figdeck CLI (${command})`); -} - -/** - * Run a figdeck CLI command - */ -export async function runCli( - cliResult: CliDetectionResult, - options: RunCliOptions, -): Promise { - if (!cliResult.found) { - throw new Error("figdeck CLI not found"); - } - - const [cmd, ...baseArgs] = cliResult.command; - const args = [...baseArgs, ...options.args]; - - let proc: ChildProcess; - try { - proc = spawn(cmd, args, { - cwd: options.cwd, - shell: process.platform === "win32", - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - throw toSpawnError(error, cmd, args); - } - - const spawned = new Promise((resolve, reject) => { - const onSpawn = () => { - proc.off("error", onError); - resolve(); - }; - - const onError = (error: Error) => { - proc.off("spawn", onSpawn); - reject(toSpawnError(error, cmd, args)); - }; - - proc.once("spawn", onSpawn); - proc.once("error", onError); - }); - - // Handle spawn errors to avoid crashing the extension host. - proc.on("error", (error) => { - options.onError?.(error); - }); - - if (options.onStdout && proc.stdout) { - proc.stdout.on("data", (data: Buffer) => { - options.onStdout?.(data.toString()); - }); - } - - if (options.onStderr && proc.stderr) { - proc.stderr.on("data", (data: Buffer) => { - options.onStderr?.(data.toString()); - }); - } - - if (options.onExit) { - proc.on("exit", options.onExit); - } - - // Wait until the process is successfully spawned, or surface spawn errors to callers. - await spawned; - - return proc; -} - /** * Show CLI not found notification with guidance */ From 0b0c253e44a178e88ce95b72fb663c3f1cce1e9d Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 13:48:54 +0900 Subject: [PATCH 03/11] feat(vscode): enhance server configuration with new options for remote access, authentication, and file watching --- packages/vscode/package.json | 20 ++ .../vscode/src/diagnostics/analyzer.test.ts | 56 +++- packages/vscode/src/diagnostics/analyzer.ts | 161 +++++++++- .../vscode/src/diagnostics/codeActions.ts | 285 ++++++++++++++++++ .../src/diagnostics/frontmatterValidator.ts | 3 +- packages/vscode/src/extension.ts | 19 +- packages/vscode/src/ops/cli-runner.test.ts | 3 +- packages/vscode/src/ops/cli-runner.ts | 1 - packages/vscode/src/ops/figdeckCli.ts | 1 + packages/vscode/src/ops/serverManager.ts | 42 +++ 10 files changed, 560 insertions(+), 31 deletions(-) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 3f1cbbc..39e0d1d 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -116,6 +116,26 @@ "default": 4141, "description": "Port for figdeck serve command" }, + "figdeck.serve.allowRemote": { + "type": "boolean", + "default": false, + "description": "Allow remote connections to the server (required for non-loopback hosts)" + }, + "figdeck.serve.secret": { + "type": "string", + "default": "", + "description": "Secret for authentication handshake (auto-generated if not set for remote connections)" + }, + "figdeck.serve.noAuth": { + "type": "boolean", + "default": false, + "description": "Disable authentication (not recommended for remote connections)" + }, + "figdeck.serve.noWatch": { + "type": "boolean", + "default": false, + "description": "Disable file watching (auto-reload on file changes)" + }, "figdeck.diagnostics.enabled": { "type": "boolean", "default": true, diff --git a/packages/vscode/src/diagnostics/analyzer.test.ts b/packages/vscode/src/diagnostics/analyzer.test.ts index 189887d..ef76461 100644 --- a/packages/vscode/src/diagnostics/analyzer.test.ts +++ b/packages/vscode/src/diagnostics/analyzer.test.ts @@ -757,9 +757,9 @@ describe("analyzeImages", () => { documentUri as unknown as vscode.Uri, ); - expect(issues.some((issue) => issue.code === "image-unsupported-format")).toBe( - true, - ); + expect( + issues.some((issue) => issue.code === "image-unsupported-format"), + ).toBe(true); }); it("warns when a local image exceeds the size limit", async () => { @@ -767,9 +767,14 @@ describe("analyzeImages", () => { const documentPath = path.join(basePath, "docs", "slides.md"); const documentUri = makeFileUri(documentPath); - const expectedPath = path.resolve(path.dirname(documentPath), "images/big.png"); + const expectedPath = path.resolve( + path.dirname(documentPath), + "images/big.png", + ); - statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + statSpy = spyOn(fs.promises, "stat").mockImplementation((async ( + filePath: fs.PathLike, + ) => { if (String(filePath) === expectedPath) { return { size: 6 * 1024 * 1024, @@ -799,7 +804,9 @@ describe("analyzeImages", () => { "images/big file.png", ); - statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + statSpy = spyOn(fs.promises, "stat").mockImplementation((async ( + filePath: fs.PathLike, + ) => { if (String(filePath) === expectedPath) { return { size: 6 * 1024 * 1024, @@ -824,9 +831,14 @@ describe("analyzeImages", () => { const documentPath = path.join(basePath, "docs", "slides.md"); const documentUri = makeFileUri(documentPath); - const expectedPath = path.resolve(path.dirname(documentPath), "images/medium.png"); + const expectedPath = path.resolve( + path.dirname(documentPath), + "images/medium.png", + ); - statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + statSpy = spyOn(fs.promises, "stat").mockImplementation((async ( + filePath: fs.PathLike, + ) => { if (String(filePath) === expectedPath) { return { size: 2 * 1024 * 1024, @@ -864,7 +876,9 @@ describe("analyzeImages", () => { ); expect(statSpy).not.toHaveBeenCalled(); - expect(issues.some((issue) => issue.code === "image-too-large")).toBe(false); + expect(issues.some((issue) => issue.code === "image-too-large")).toBe( + false, + ); }); it("caches file stats across calls (within TTL)", async () => { @@ -872,9 +886,14 @@ describe("analyzeImages", () => { const documentPath = path.join(basePath, "docs", "slides.md"); const documentUri = makeFileUri(documentPath); - const expectedPath = path.resolve(path.dirname(documentPath), "images/cached.png"); + const expectedPath = path.resolve( + path.dirname(documentPath), + "images/cached.png", + ); - statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + statSpy = spyOn(fs.promises, "stat").mockImplementation((async ( + filePath: fs.PathLike, + ) => { if (String(filePath) === expectedPath) { return { size: 2 * 1024 * 1024, @@ -916,9 +935,14 @@ describe("analyzeDocument", () => { const documentPath = path.join(basePath, "docs", "slides.md"); const documentUri = makeFileUri(documentPath); - const expectedPath = path.resolve(path.dirname(documentPath), "images/medium.png"); + const expectedPath = path.resolve( + path.dirname(documentPath), + "images/medium.png", + ); - statSpy = spyOn(fs.promises, "stat").mockImplementation((async (filePath: fs.PathLike) => { + statSpy = spyOn(fs.promises, "stat").mockImplementation((async ( + filePath: fs.PathLike, + ) => { if (String(filePath) === expectedPath) { return { size: 2 * 1024 * 1024, @@ -938,8 +962,8 @@ describe("analyzeDocument", () => { }); expect(statSpy).toHaveBeenCalledWith(expectedPath); - expect(result.issues.some((issue) => issue.code === "image-too-large")).toBe( - true, - ); + expect( + result.issues.some((issue) => issue.code === "image-too-large"), + ).toBe(true); }); }); diff --git a/packages/vscode/src/diagnostics/analyzer.ts b/packages/vscode/src/diagnostics/analyzer.ts index 9cd71fd..fa090ff 100644 --- a/packages/vscode/src/diagnostics/analyzer.ts +++ b/packages/vscode/src/diagnostics/analyzer.ts @@ -1,7 +1,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type * as vscode from "vscode"; -import { validateFrontmatter } from "./frontmatterValidator"; +import { parse as parseYaml } from "yaml"; +import { + extractFrontmatterBlocks, + validateFrontmatter, +} from "./frontmatterValidator"; import type { AnalysisResult, Issue } from "./types"; type CachedFileStat = { @@ -14,7 +18,10 @@ const DEFAULT_MAX_IMAGE_SIZE_MB = 5; const STAT_CACHE_TTL_MS = 2000; const STAT_CACHE_MAX_ENTRIES = 2000; -const statCache = new Map(); +const statCache = new Map< + string, + { expiresAt: number; stat: CachedFileStat } +>(); export function clearImageDiagnosticsCache(): void { statCache.clear(); @@ -122,7 +129,17 @@ export async function analyzeDocument( // Run all analyzers issues.push(...analyzeFrontmatterStructure(lines)); issues.push(...validateFrontmatter(lines)); - issues.push(...(await analyzeImages(lines, basePath, document.uri, options?.images))); + issues.push( + ...(await analyzeBackgroundImages( + lines, + basePath, + document.uri, + options?.images, + )), + ); + issues.push( + ...(await analyzeImages(lines, basePath, document.uri, options?.images)), + ); issues.push(...analyzeFigmaBlocks(lines)); issues.push(...analyzeColumnsBlocks(lines)); @@ -191,6 +208,144 @@ export function analyzeFrontmatterStructure(lines: string[]): Issue[] { return issues; } +const SUPPORTED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif"]; + +/** + * Find the line number where a key appears in frontmatter content + */ +function findKeyLineInFrontmatter( + lines: string[], + startLine: number, + endLine: number, + key: string, +): number { + const keyPattern = new RegExp(`^\\s*${key}\\s*:`); + for (let i = startLine; i <= endLine && i < lines.length; i++) { + if (keyPattern.test(lines[i])) { + return i; + } + } + return startLine; +} + +/** + * Analyze backgroundImage references in frontmatter for issues + * @internal Exported for testing + */ +export async function analyzeBackgroundImages( + lines: string[], + basePath: string, + documentUri: vscode.Uri, + options?: AnalyzeImagesOptions, +): Promise { + const issues: Issue[] = []; + const maxSizeMb = resolveMaxSizeMb(options); + const blocks = extractFrontmatterBlocks(lines); + + for (const block of blocks) { + if (!block.content.trim()) continue; + + let parsed: unknown; + try { + parsed = parseYaml(block.content); + } catch { + continue; // YAML parse errors are handled by validateFrontmatter + } + + if (typeof parsed !== "object" || parsed === null) continue; + + const backgroundImage = (parsed as Record).backgroundImage; + if (typeof backgroundImage !== "string" || !backgroundImage) continue; + + const url = backgroundImage.trim(); + + // Skip remote URLs + if (url.startsWith("http://") || url.startsWith("https://")) { + continue; + } + + const keyLine = findKeyLineInFrontmatter( + lines, + block.startLine, + block.endLine, + "backgroundImage", + ); + const lineLength = lines[keyLine]?.length ?? 100; + + // Check extension + const ext = url.toLowerCase().split(".").pop(); + if (ext && !SUPPORTED_IMAGE_EXTENSIONS.includes(ext)) { + issues.push({ + severity: "warning", + message: `Image format '${ext}' is not supported. Use PNG, JPEG, or GIF.`, + range: { + startLine: keyLine, + startColumn: 0, + endLine: keyLine, + endColumn: lineLength, + }, + code: "background-image-unsupported-format", + data: { url, ext }, + }); + } + + // Check file existence and size + const candidatePaths = toCandidatePaths(url, basePath, documentUri); + let fileFound = false; + + for (const candidatePath of candidatePaths) { + const stat = await statWithCache(candidatePath); + if (!stat.exists || !stat.isFile) continue; + + fileFound = true; + + // Check file size + if (maxSizeMb) { + const maxSizeBytes = maxSizeMb * 1024 * 1024; + if (stat.size > maxSizeBytes) { + const sizeMb = stat.size / 1024 / 1024; + issues.push({ + severity: "warning", + message: `Background image is ${sizeMb.toFixed(1)}MB (max ${maxSizeMb}MB). Consider compressing it.`, + range: { + startLine: keyLine, + startColumn: 0, + endLine: keyLine, + endColumn: lineLength, + }, + code: "background-image-too-large", + data: { + url, + filePath: candidatePath, + sizeBytes: stat.size, + maxSizeMb, + }, + }); + } + } + break; + } + + // Check if file exists + if (!fileFound && candidatePaths.length > 0) { + issues.push({ + severity: "error", + message: `Background image not found: ${url}`, + range: { + startLine: keyLine, + startColumn: 0, + endLine: keyLine, + endColumn: lineLength, + }, + code: "background-image-not-found", + data: { url }, + }); + } + } + + return issues; +} + /** * Analyze image references for issues * @internal Exported for testing diff --git a/packages/vscode/src/diagnostics/codeActions.ts b/packages/vscode/src/diagnostics/codeActions.ts index b6a0067..6868f21 100644 --- a/packages/vscode/src/diagnostics/codeActions.ts +++ b/packages/vscode/src/diagnostics/codeActions.ts @@ -42,6 +42,10 @@ export class FigdeckCodeActionProvider implements vscode.CodeActionProvider { return this.createAddLinkPropertyFix(document, diagnostic); case "columns-gap-exceeded": return this.createClampGapFix(document, diagnostic); + case "frontmatter-invalid-format": + return this.createColorNormalizationFix(document, diagnostic); + case "frontmatter-invalid-value": + return this.createTransitionNormalizationFix(document, diagnostic); default: return null; } @@ -128,6 +132,287 @@ export class FigdeckCodeActionProvider implements vscode.CodeActionProvider { action.isPreferred = true; return action; } + + /** + * Fix for invalid transition style/curve - convert underscores to hyphens + */ + private createTransitionNormalizationFix( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + ): vscode.CodeAction | null { + // Check if it's about transition style or curve + const msg = diagnostic.message; + if ( + !msg.includes("style") && + !msg.includes("curve") && + !msg.includes("type") + ) { + return null; + } + + const line = document.lineAt(diagnostic.range.start.line); + const text = line.text; + + // Match YAML key: value pattern + const match = text.match(/^(\s*)(style|curve|type):\s*(.+?)\s*$/); + if (!match) return null; + + const [, indent, key, value] = match; + const trimmedValue = value.replace(/^["']|["']$/g, "").trim(); + + // Try to normalize the value + const normalizedValue = normalizeTransitionValue(key, trimmedValue); + if (!normalizedValue || normalizedValue === trimmedValue) { + return null; + } + + const action = new vscode.CodeAction( + `Change to "${normalizedValue}"`, + vscode.CodeActionKind.QuickFix, + ); + action.edit = new vscode.WorkspaceEdit(); + action.edit.replace( + document.uri, + line.range, + `${indent}${key}: ${normalizedValue}`, + ); + action.diagnostics = [diagnostic]; + action.isPreferred = true; + return action; + } + + /** + * Fix for invalid color format - convert rgb() or #rgb to #rrggbb + */ + private createColorNormalizationFix( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + ): vscode.CodeAction | null { + // Only apply to color-related fields + if (!diagnostic.message.includes("color format")) { + return null; + } + + const line = document.lineAt(diagnostic.range.start.line); + const text = line.text; + + // Match YAML key: value pattern for color properties + const colorMatch = text.match(/^(\s*)(background|color):\s*(.+?)\s*$/); + if (!colorMatch) return null; + + const [, indent, key, value] = colorMatch; + const normalizedColor = normalizeColor(value); + + if (!normalizedColor) return null; + + const action = new vscode.CodeAction( + `Convert to ${normalizedColor}`, + vscode.CodeActionKind.QuickFix, + ); + action.edit = new vscode.WorkspaceEdit(); + action.edit.replace( + document.uri, + line.range, + `${indent}${key}: "${normalizedColor}"`, + ); + action.diagnostics = [diagnostic]; + action.isPreferred = true; + return action; + } +} + +/** + * Normalize various color formats to #rrggbb + */ +function normalizeColor(value: string): string | null { + const trimmed = value.replace(/^["']|["']$/g, "").trim(); + + // Handle #rgb shorthand -> #rrggbb + const shorthandMatch = trimmed.match( + /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/, + ); + if (shorthandMatch) { + const [, r, g, b] = shorthandMatch; + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); + } + + // Handle rgb(r, g, b) -> #rrggbb + const rgbMatch = trimmed.match( + /^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i, + ); + if (rgbMatch) { + const r = Math.min(255, Math.max(0, Number.parseInt(rgbMatch[1], 10))); + const g = Math.min(255, Math.max(0, Number.parseInt(rgbMatch[2], 10))); + const b = Math.min(255, Math.max(0, Number.parseInt(rgbMatch[3], 10))); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; + } + + // Handle rgba(r, g, b, a) -> #rrggbb (ignore alpha) + const rgbaMatch = trimmed.match( + /^rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*[\d.]+\s*\)$/i, + ); + if (rgbaMatch) { + const r = Math.min(255, Math.max(0, Number.parseInt(rgbaMatch[1], 10))); + const g = Math.min(255, Math.max(0, Number.parseInt(rgbaMatch[2], 10))); + const b = Math.min(255, Math.max(0, Number.parseInt(rgbaMatch[3], 10))); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; + } + + // Handle common color names + const colorNames: Record = { + white: "#ffffff", + black: "#000000", + red: "#ff0000", + green: "#00ff00", + blue: "#0000ff", + yellow: "#ffff00", + cyan: "#00ffff", + magenta: "#ff00ff", + gray: "#808080", + grey: "#808080", + }; + + const lowerTrimmed = trimmed.toLowerCase(); + if (colorNames[lowerTrimmed]) { + return colorNames[lowerTrimmed]; + } + + return null; +} + +const TRANSITION_STYLES = [ + "none", + "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", + "move-from-left", + "move-from-right", + "move-from-top", + "move-from-bottom", + "slide-out-to-left", + "slide-out-to-right", + "slide-out-to-top", + "slide-out-to-bottom", + "move-out-to-left", + "move-out-to-right", + "move-out-to-top", + "move-out-to-bottom", +]; + +const TRANSITION_CURVES = [ + "ease-in", + "ease-out", + "ease-in-and-out", + "linear", + "gentle", + "quick", + "bouncy", + "slow", +]; + +const TIMING_TYPES = ["on-click", "after-delay"]; + +/** + * Normalize transition style/curve values + */ +function normalizeTransitionValue(key: string, value: string): string | null { + // Convert underscores to hyphens + const normalized = value.toLowerCase().replace(/_/g, "-"); + + // Check against valid values + if (key === "style") { + if (TRANSITION_STYLES.includes(normalized)) { + return normalized; + } + // Try fuzzy matching for common mistakes + const closest = findClosestMatch(normalized, TRANSITION_STYLES); + return closest; + } + + if (key === "curve") { + if (TRANSITION_CURVES.includes(normalized)) { + return normalized; + } + // Handle common variations + if (normalized === "ease" || normalized === "easein") return "ease-in"; + if (normalized === "easeout") return "ease-out"; + if (normalized === "easeinout" || normalized === "ease-in-out") + return "ease-in-and-out"; + + const closest = findClosestMatch(normalized, TRANSITION_CURVES); + return closest; + } + + if (key === "type") { + if (TIMING_TYPES.includes(normalized)) { + return normalized; + } + // Handle common variations + if (normalized === "onclick" || normalized === "click") return "on-click"; + if ( + normalized === "afterdelay" || + normalized === "delay" || + normalized === "auto" + ) { + return "after-delay"; + } + const closest = findClosestMatch(normalized, TIMING_TYPES); + return closest; + } + + return null; +} + +/** + * Find closest matching string using Levenshtein distance + */ +function findClosestMatch(value: string, candidates: string[]): string | null { + let bestMatch: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const candidate of candidates) { + const distance = levenshteinDistance(value, candidate); + if (distance < bestDistance && distance <= 3) { + bestDistance = distance; + bestMatch = candidate; + } + } + + return bestMatch; +} + +/** + * Calculate Levenshtein distance between two strings + */ +function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = []; + + for (let i = 0; i <= a.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= b.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + + return matrix[a.length][b.length]; } /** diff --git a/packages/vscode/src/diagnostics/frontmatterValidator.ts b/packages/vscode/src/diagnostics/frontmatterValidator.ts index 6936741..7b8ce44 100644 --- a/packages/vscode/src/diagnostics/frontmatterValidator.ts +++ b/packages/vscode/src/diagnostics/frontmatterValidator.ts @@ -468,8 +468,9 @@ function isContentLine(line: string): boolean { /** * Extract frontmatter blocks from lines (both explicit and implicit) + * @internal Exported for use by analyzer */ -function extractFrontmatterBlocks( +export function extractFrontmatterBlocks( lines: string[], ): Array<{ startLine: number; endLine: number; content: string }> { const blocks: Array<{ diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 99632a0..8c4e20b 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -30,14 +30,17 @@ export function activate(context: vscode.ExtensionContext) { // Create diagnostics manager const config = vscode.workspace.getConfiguration("figdeck.diagnostics"); - diagnosticsManager = new DiagnosticsManager((document, basePath) => { - const imagesConfig = vscode.workspace.getConfiguration("figdeck.images"); - const maxSizeMb = imagesConfig.get("maxSizeMb", 5); - return analyzeDocument(document, basePath, { images: { maxSizeMb } }); - }, { - debounceMs: config.get("debounceMs", 300), - enabled: config.get("enabled", true), - }); + diagnosticsManager = new DiagnosticsManager( + (document, basePath) => { + const imagesConfig = vscode.workspace.getConfiguration("figdeck.images"); + const maxSizeMb = imagesConfig.get("maxSizeMb", 5); + return analyzeDocument(document, basePath, { images: { maxSizeMb } }); + }, + { + debounceMs: config.get("debounceMs", 300), + enabled: config.get("enabled", true), + }, + ); // Create server manager serverManager = new ServerManager(outputChannel); diff --git a/packages/vscode/src/ops/cli-runner.test.ts b/packages/vscode/src/ops/cli-runner.test.ts index a40b2c5..297a3a5 100644 --- a/packages/vscode/src/ops/cli-runner.test.ts +++ b/packages/vscode/src/ops/cli-runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { runCli, type CliDetectionResult } from "./cli-runner"; +import { type CliDetectionResult, runCli } from "./cli-runner"; describe("runCli", () => { it("streams stdout/stderr and reports exit code", async () => { @@ -63,4 +63,3 @@ describe("runCli", () => { expect(caught?.message).toContain("Failed to spawn figdeck CLI"); }); }); - diff --git a/packages/vscode/src/ops/cli-runner.ts b/packages/vscode/src/ops/cli-runner.ts index fbb68c1..f081342 100644 --- a/packages/vscode/src/ops/cli-runner.ts +++ b/packages/vscode/src/ops/cli-runner.ts @@ -108,4 +108,3 @@ export async function runCli( return proc; } - diff --git a/packages/vscode/src/ops/figdeckCli.ts b/packages/vscode/src/ops/figdeckCli.ts index f7a9448..91fbad8 100644 --- a/packages/vscode/src/ops/figdeckCli.ts +++ b/packages/vscode/src/ops/figdeckCli.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; import type { CliDetectionResult } from "./cli-runner"; + export type { CliDetectionResult, RunCliOptions } from "./cli-runner"; export { runCli } from "./cli-runner"; diff --git a/packages/vscode/src/ops/serverManager.ts b/packages/vscode/src/ops/serverManager.ts index 180256c..a885fe3 100644 --- a/packages/vscode/src/ops/serverManager.ts +++ b/packages/vscode/src/ops/serverManager.ts @@ -76,6 +76,10 @@ export class ServerManager implements vscode.Disposable { const config = vscode.workspace.getConfiguration("figdeck.serve"); const host = config.get("host", "127.0.0.1"); const port = config.get("port", 4141); + const allowRemote = config.get("allowRemote", false); + const secret = config.get("secret", ""); + const noAuth = config.get("noAuth", false); + const noWatch = config.get("noWatch", false); this.setState("starting"); this.currentFile = file; @@ -88,6 +92,24 @@ export class ServerManager implements vscode.Disposable { try { const args = ["serve", file, "--host", host, "--port", String(port)]; + // Add optional flags based on config + if (allowRemote) { + args.push("--allow-remote"); + this.outputChannel.appendLine(`[figdeck] Remote access enabled`); + } + if (secret) { + args.push("--secret", secret); + this.outputChannel.appendLine(`[figdeck] Authentication enabled`); + } + if (noAuth) { + args.push("--no-auth"); + this.outputChannel.appendLine(`[figdeck] Authentication disabled`); + } + if (noWatch) { + args.push("--no-watch"); + this.outputChannel.appendLine(`[figdeck] File watching disabled`); + } + this.process = await runCli(cliResult, { args, cwd: workspaceFolder?.uri.fsPath, @@ -100,6 +122,26 @@ export class ServerManager implements vscode.Disposable { }, onStderr: (data) => { this.outputChannel.append(`[stderr] ${data}`); + // Check for common errors + if ( + data.includes("EADDRINUSE") || + data.includes("address already in use") + ) { + this.setState("error"); + vscode.window + .showErrorMessage( + `Port ${port} is already in use. Change the port in settings or stop the other process.`, + "Open Settings", + ) + .then((selection) => { + if (selection === "Open Settings") { + vscode.commands.executeCommand( + "workbench.action.openSettings", + "figdeck.serve.port", + ); + } + }); + } }, onExit: (code) => { this.outputChannel.appendLine( From 427754a4f128f7d6d6c72ef33d13ca1451086120 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 14:51:01 +0900 Subject: [PATCH 04/11] fix(vscode): prevent unnecessary state change in ServerManager when process exits with code --- packages/vscode/src/ops/serverManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vscode/src/ops/serverManager.ts b/packages/vscode/src/ops/serverManager.ts index a885fe3..6c27ad9 100644 --- a/packages/vscode/src/ops/serverManager.ts +++ b/packages/vscode/src/ops/serverManager.ts @@ -148,6 +148,7 @@ export class ServerManager implements vscode.Disposable { `\n[figdeck] Process exited with code ${code}`, ); this.process = null; + if (this.state === "stopped") return; this.setState(code === 0 ? "stopped" : "error"); }, }); From 57defd829010b89378844ec61e0821c7d2e735dd Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:31:43 +0900 Subject: [PATCH 05/11] feat(vscode): add metadata and homepage information to package.json for improved visibility and user support --- packages/vscode/images/icon.png | Bin 0 -> 21180 bytes packages/vscode/package.json | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 packages/vscode/images/icon.png diff --git a/packages/vscode/images/icon.png b/packages/vscode/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1d4ccc8a0ed7c9caf935162089030019cb1ea939 GIT binary patch literal 21180 zcmeIac{tST|37|@u~np`2?ECh8zUDRO{ds@>{awF*zSsB6b)Bozy*`)6bGe`Q{p$DMbWZTE6kdrC z;@3KPTpuAGDngu!yxh>zTl?-9{ISC6q=_p+tJY)x5l~c&5Hu28^-ml{8C9Zv@E1<& zBWI2v^fGkS!UZmb^!2okA31k}FxaB!a;_)0cXVO5A?+VS9`NJ)8I$4)?b9^nD zwpg4u*sH(pkpO%D`}k#$u zd2&))arSlZ8^Zx&JcyTpp2-UoIUFuBZMrU+(Cv}C&&b-qL%N>>A+mVSOT`fD_-Wk) z2fDOU6?qGo268t~ko@bM_(rmXS3UtCka9c3rq?TKJRf3B1G%R5d#pH@WRcO=wvijph+2gQH(ZTqs^x9WO zJLHMbO*@ty&^vEMdMm|Al-+=JkCfy%Cco^+kbz#Ng8FGLb)T%)Vu*POI%8jTZ-!=} zNEiKt){VCby;1}rstzw5+89|Y-l2(yh>xMa0{c68|4~`AD2|9(q;DQcV&6=MS;NR` zT@t4jJIk%@0|;o#Ix4|9f5^-_+TC=@?)?>*h9u;@dW$VJ6AE~yt&a#HY%|NOuS0@8+^oswTW);Y)O zh?z|DLH9_Jenru+1<6BT`jahU+y;G`3!lYkMfA&R{ND?E3l(yKZ2as8O5J4o-PBKA<%DjuN-mU9o7rvFz=2vH08@0gt$x}?EU8MD zluATpb~j^PR1pSR5FZtU8MI?oIpdw#V~VK#5E#uunn4hKXGQs7=JbuJO55~F$Herc zL{d`E@`d!>7z@%yk{z>;UVn8?e_0bg7X4y;{G65frMtg61A*XO7)gJsELHt|O*FSB zEKl^%KthNAeD(|`YrcvFonOx!N^_H=&xE(t-Jfmw9{lknCpW(D$-ig3OrsQ~9lSRu z)Pyx)6K+-}jO3JF3GZGsS=0K*>Ejf%|0LpWAAa?|ZC1I*w7Kle`4XC8XjWV2R?x@rxTp!jIpQk-Ubd?3rDs%N)zpRiT?|HW zXod@*|I$6%SIGUX7?Cs|Jow_Pr_lsv%KI_skXZsDO>D!;Q4%)S@zeSi);*e#mjHZv8 zCJl|7oZeC<8``?kjAYTatzqU$GSFw!k zxUHslhg2|(qgpl2sXw&0I=4i+Bl0dWC*FIsF~W<0WWYY8Y%uVgDq8PjSfN$>T5OAo zxjL4JQCFMOM}J=5SJ^t)9Nhcz%DJ|~`NNx6qBd|rhM&beEX`w&j~h%pcCUuPWJ|tF z``nanI@cF^dmqb9NH&wbjY_i&= zl103s`lBY_vCiyCmM3}e`C)+3^{4d(HR}&u9G|NcwD_CH_=q8yC^1>qB51U`#(Sgc zfqCX=O)Zc8JO-u*4fVm@2e@n^&|; zQQ2^Ze4b;h12>X7g1Ap!F0M)nZ}>eV;{GvwoI4Pl1Acc;V$AWf{+!(+9mrUbAha#) z`WN4Z*@@vnVxjqKnNsGqVoQx9JKgoE3dpF@_s z?!4}D)z9%HU2Cz+nCGR&yUU+1IOrAVD!N6Ei|;`^tiqW3bvdcFuT5X43Jh~Mw$h9S zoBsQ&hY8Uf+#hv;B${TPc0Qa@|(#9)!we-o1 z0#>{doc>TEHGS&6p4+qIvm;G9Exbq{8<*iwQC1V7uVb-bH)MfrHe#w-5uhI%^!PW}QiW{>d6MD(NkNwDveY+MtgSgFi zZeL4_?SVy>G;W^q$fGLMe@g>l&PadDz_cn?nW35s8E$)vRz&I_VUOjpVb2i3*%x)MN?EB;_-BubQRa=R+@dJ$y zluehf&S7Y!TsRf07P2~g-7pWbg^))afA=dW^?aINT-#qbSy_s0XX>Y|Ep;xH+lhSR zpprN#)LXW8Kw8s%omMhylZ}{R*)5VH9WUYHx((!!5>1v)#p&zBsTns5z*cBeN~EXI zmKP31!sr>~I((;tjpxKlwbLPo0dMiQs*h9G4c|bi$aMK%U@>4dd>^NR#|>0OUS3X^ zd>vGW9e+`3^6To0ZBgg1;W0E`519u|9#dK;PjdYg&*4N`#40wV0ZdN4az@7Gwmibn)k}5vUEoJK>UevSj?PlZ4 zb7QKD6lt@Gzm&kBVd!=OLOvH?eZQ`5-E++6B4xviZ>nwVc&tC7d~=9Us4!c;uYKwi zKo0Az*IcYN&TvMuHdtRyuC)7{$2Es^RThwy5B0b2TpTj{q^iNq=mJB$M>+RMjes2l z9wSznqJh+usGeXOm7OTC13BHqboAm-5fvf8L|FWlcKP!ocIePdu>R{9W92;RuVM*IA*@2urZxhoS4=|NB=4 z|Ki|ZTlm)>{Obw-^@RU=!hb#Czn<`4PxybeCwy;;DKN_=qa{Ox?&&OltvV{zL6R<& zc%ZarNiydyVvs&ic35(=n@=7Aq1Hp&VV4|^Ye z)AYJ>KKoU{=C;$BI=;rMjRqFPWa?gIE)>%J+Nze5WBd*)*R%t-WYBsse| z`7Q2&5=#}FW8n9@Lz{AQ8eC#5+bhRUwA>f#YRUWU;-#s!oXxpcU)MG?H^s(h)-LsfpD&I$2ha7f z%Vg3KER8*Q-&Qj0cgC@Lx0|4tkDpeN@_f86Y1KQg>^0s-a0N~6!Viet8R|c#llo`s z)~E;vhhXPj*l?33>+~m-H7-JL%L%=5%l&)&$o$#1fcEQ6aaUVnekq(8H^vtE0Ci{> z&)wVgb=cx?dY<*IL-#IhO1A(05+X~9fmb1@&wz_Zi3$WRGdq9O# zJ^pTc(X6E|%zp$$Uid@k!E8m(b-6{mO?sK7?Tc$}Cs|sy#j32kO+f8KxB!XHJ|1(< zhxb({yw{XCyW{#D3PLx$pv(<52XwXN@vpiU)8b0l&aF`7fmWvL_>4$m>+c)IIf-z2 zfivxSbz{eHDr>GPYc5&Wp(RZ$!)3uIVhzj^gqkJHonYSVyqeVKxKz9!TXE?SOB*!n z?hDyE%{_V-cg&c;T@hMPVYm0Yy)`=awU8GZ?0XtNK!**rlvkS7pc-Vt2nF_Z7KtS(`)OZ=);D!w#0b>oEQUP!E zMf=HdE3ZSGep!bbLA)n*@WHocCmi9*X^pw~R)--S`X-q4kxj+pzBa3rOF$R(GY;sG zCrd-OfmN~&zG}efa!6>OS_VU5FQo|mkge@EuU#+0zkk@>sug~jpq1y#;rjo%la z+in%ZYT5{?Uf=-1AsSI^u=sIVTQG&zuV`0P3!*ktNb+iHa|OWtV(%@aacNG03uDNa zUFeVv-iwmP6U(l<8AL#SHIqmy{^1y3T)C=;cWAqa$iR~uNdhnIbdcIN zV)A__HK6D1Z_k%E zI}x(i6iNbco%C>ecUNdJ?AHJ_`MfM@Xv8xcsW%U*-)T>8>H8)&sB;|(D;7{}gB9E^ zkd#;6ka7n&UZ9{y_TA{3)USGz}Gc8CCX(k4BI1o*@KA=biRBypr5f9 zs#dU?@>t7dA9czFFdC`>o5fBidQ25Ziyq@Bwm~Y~9l^lf-_q-ZZ6c3@l#H?aCn)Cx z*d+%Lw0FXOm!W`$k=EPiFIBb^nEA4}gVPZlibGpU2V#}rpqm;}*QB<(_p|guy=PMF z>*~9(=MOd+@?K=u4NT0!w-Iq#FYyR`3%z2gL-hP1wtZ&qS7#XLL{=K#THx!8dUEYS zck>@Fyu^Ce%z#{FbdHtxbdp+|Q4U)TVx{U6y1h{Ie#sqGhbga65*K)qUAiTA8VTgm?vB%>TK5>;dxR~;Mz3do`;R%j z-(A)*w6WFbY=3GjGL(&ckAethc5<+w5gKeK%003Qn;OsF@@g@+Clo!Yek^q^$NCof zq=pa?MT5PUQG(j)c8-3yyRkUz5RZWe(8t)zo?RtubK`vWh}i%Ze*W>9)h0xK$Nv98 zM;Zr<6AJICd1fef%LiCa=)PmMW&lAt9DvCe%QAe~H8S7GVq95H zARgIsELkufe4R2u+B-nf$8dS^ee`%ZxriclKw$USORce|{biPUr@aRcBs6QBlD}SZ z3o9$EdKW#Eqap|d7PF)K^R#p^Zssc3g{gq=!Vm>-TTeblUs!WH?|PD@4=adJL)gTh zvtN{>ZcQ=LexG2%yM+15w@}>&Cis54#3AIF5dx@Vdrt2UO7_Gr8x7b>mE zCZ0=n4Q!buif>8Alq@TYwLlpb)?$z=zmcnpW4L9>Sfdc$NUeJjN)(cXrLaOaHjD~- zGXZbR=wjy?pM`Bcc9(6x0oeo(0#C?tP4_Z#syR+}&#(E;4i&H_+hH;oha4IOwk4a) zq)&!`^v&_GE9=yzQy&7BcM=ores;E#U$W?8Djq^KCVc5N4c_ zRHEh}MvyKJ#ueUP^2&UaofraB+2brSZmF%LSYb_Y9RO@R&~S+7${K^Uj2&2fk=J3D zFvRwqz^Wl0XCB-+#AH+hv?#f_BAyr%`4>2_0eGZ%a1t|4s^1Y_Zt8b@r{j>Vt+%J$ z3c9hAQ@stIcx*=GmG}m7x%D!{_S;vs-^hlC9UzZRT!|93P}gt8f)+chmy5Dh*v?-l zd|SOCc91hx%0ZPM=8YB9>fzDNY|Md4Bn2Z_1xzWXG* ze8^{NdUnsIu2|X-5Aw%7qQn&L-MN)VJJxu^6Dr=;ChW$%)T*s7eW)Wz zpx%)ON#Y9o9lMJ%p(?GWR(Xrv&h)PJ&2@Kun+LwjKX0t_$6Ty_v+mDzpLhGks}#Ok zkDlQ{T0&u!Oem4&ig~Qq>rHBcx7%6;1v0cgtn@!6MpB!L-vokE+rKed~)t699LcPOZgbLO-I(pPmf&;4ZFXEGX$%!9S)!Q;$m^QJ7<2XGUi#9vovS=7i;aqxXO2`43!r`B9MoVBSHH+h!GUs? z>T12Akb>9*o6Toi2gz(IAY|RzB#^?BEInL*z7 z;s(4W%yaPqB0T_$u*P-n_I~t8l(gK4A`(6Yw&sE79dFx%G zvjQyCR~t_k9&`jO>IHhP6sD>AZoaMSK}5>`q~Ci#lZitUKD;{Bo3_t9S;I#*VsC%8 zTZo$+wD0Ih#(y_flV#MN6PPaQ0`C;mpTH$~FFP;ucj-((z4w(lWtJu?D;H<;{959N zxKIwf@&chTk6fYMx>j*m@~6~xA*wt(W9q$JFA}iXhL(MYf|}T*(td0^v%^4_NUOyf zPvG>Y+`Pao+`bW}PT`JKxxcGW^zLRGYCJe58#R_gpe6Kg?$ziO;z4u}BNKPjU;rHh$S)pU62m%o>#d89Yt>5nM3z-|V9DjHJ>g2B$B z{EhNTWC$4wjJXm@r4by9sQ!x(`1P#RW#AWMvnOm5q;Zp26@UT%qVQTC; zVw(O}&v};+5wpd9zwfK~m=^5z2v^^&DCeZ=it}x&q&bK=VYsZ#HWsyRLpGb&3enPW zCR+Pvww=*`riGYt;<98B+yV>FR@Lbt=Fv>?5%zv~2t~s=H|~>GBGbK=^N-T|gAi>a zdzHSK3w}8>kGWBLxa-nj{caicRY({=F|?m{xeVIe7wI~Bcb-a!`lq!*v;@&AUypk) z86YNH#A8siz6hKyy1f6z(iQm2 ziT&)^Axq?E`YJ89NK&X*a6Bnyvu&ID+I6K6?Ju0_s>F{U_JCK7S=x$7`0<1BCMG6w zQk4OkXmXsNN=hmqkP~q8iqTdj#)_8`$OBz5YAca28{!MEyr_h?+l-t$N-bJfBVjfs zzgv8s^M|DH_3}tSV=kh>OAd^|s-a1~3D~n}Rt26;1mp3}=>Tk}yj^21^grYFgKH&Q z59ad-(UkC+tQL=}4wW86swQp>`B^dJJ+g^cd#yo1X4XnV2ub>SMlvY>RNo211?*@1^aSvz1 zIPjr~qbLV~ER}d4aLC?R`+(>(+h_f@0K?h%|F9>0CM*b$sjT!t5!rC!HkGKPv~R$R zT1h76=zzL~@k0T{7JTvso!<#LXEKe>{6`kX8H3<}BcJ##TAEq>BG}+118Sn@fdK&y zYJJuqV&bP{q>YT(&>Mkv-z6lw!Kd!2@b5AY_A=0r551avM_E0%sV2$LNt?IpPp!GBO z%9Hw&B-CJ|p=S=n;ATwe3fv2D3yyG^eA8!5z0TI?8%(1h82Pgm9%8EB-wL7}crOLi zF)H|ZY=tGk^Ij>h3-j_LG~s4M*%v7Ki@@C=AsH;D{=I1xGX1ArN9WT8%(wGOUYr;U z{V<7W?l|b`pT^y?;0$m^PPs1b6TMj;J~w1;O7hU|vb%R10Rb8M6&{sp#GGh+_ol+f zlRN~5!DG!p0WlVDrfl9E@GzX4?ANldV$8oxz!SXFB=$J~LIdZj$w?O#ka4bUMP17Y zfxKT_DE0>@%VXI`c>3&SBEx+>1u|_?ZGwJ;w`}F4hX{NDD`uQJ?bv^=I;erUescn+CMtG_4!FlP9$g$Kc%|Y zl`m9m3Z9W=vh~*Tuq1sneR1~kTAAyRJtX&x4{+VRI4U{EF|rF^!02gUzir^eG-~(Q z=NlYTIG6kCRKg>_{U^F(35u(5Jxl3TM;c0kDaM~Z`(ANOd~Ry41boE`q`3P(10J=0 zkFQH=d`+Y!d{!oAU1hJn>x3GB#O*U_3viRuBdHurMX_?}eP>Hy1W+9`3G`_)!byX6M>7^Q$E;cXgG z-5*=;@gQWH{NfAk6gbQ?W3_3n&zy^{tU+1ZVLRW1lMxMot+dMWG=r9%sqb+#AT75`z@$Jv^MAEqy<)Js{yx>R@YWHDO8Jul)ODc#%c*B=}^#zl>XP{aQl zoy*VF&(JTdG31b>_~K6Bzg`-IsGT^-kKG4%Xo?@V0!~t9%Gx{=11kvy7B8k@>5T{ z`psxRSeVUy@g6N5$$Ckn%wL49T)>d<#-QQt2JZz4IW2CZFD`*j3*-DU1rZ0z#~9n* z|0Y4yJ~C+8cwrWMO`E2tI_YWer&b>3!C@1z;XsP+>^B+D$L`$JIt+L};eg;c+4E8d zk^xA$rkp4)pbg_pPi}dQIWH7ERg&hK@};*c{u8DbV^!7;yk4$;Y8ICp?O&=jo0*Nb z%&DtkVtG+a%mL&c8)q9C7z$MikWA8wTHfLEvI>bxYN!(TVR@^)fo=(AZ@anjhz&M< zbww?+Vv|84S7Hh6{b2e0`kfa?@9tF#JXB%uJ^$q+br3eNViw{JDWd$B|D}0qC1koJ zEt8Oo#jRdcK`aq5@#(*TJ@K^1MwfchF*8|n>zpa);*~QrAjG1lR-lNprG4{Lx^_4B}zTieDJ++*Zmd|uba($Bmm>} zWY4!Vt8?m8omeh|tc~Htb>lSp_WJde_;M)~Ymx1s(a6QJiJV#UG+;zfA%LL{V zz|JAXuzCEj)+N6#-f!bTuI00fx;mqoD@yAI-{zo~yMGyC;B!OUPE#C=z zOfnpLs{2j4jkBMA{uU}wxEAvW-wb?sZf|=0d_%&El%C{oVq4*KgHU=8r!0AWSHJDE ziwgD9)wP0p)yDv9X?lT*>!?4j_M7xTtiCvE3rKrgz5=Z0?V@dKa`(7n$4W&mY}sb8 z&LW^rkm0x>I}npX@|^8)xeUWG;Pj{LV)iTiF1BSV1H2YP_{pt%VrK-~D$6F79|Ka_ zryo^gXKU8%9 zeGr3UAAFH87@wC}`YNW_w+flxSq}<{OwAYjuu*m4W8Wvx97%LW4!(X!`+c)b)77=U zu2mlKDtmYc@UaS>gu!@K6ocx=~UNTP5@$(yLeY)O2z&@gR>C{Ms8*yKO;$aUoD znO#W5bo>w`Pu+C#&D=uGh2gjGb%-3ANk^w(bIWqH*HsfQ#KCzxstxj z(0@ki)0ljh5MVaVfhb(yNj9BSY73SMcOCuU_lsV*309T54u0m;kM|P%5nuB61P)k= zT?@ua=h)zxCBnyuVlKLB&{#A`;V=w0OoO;dF8SKlY`OGOc9svNLB*1}&d6t_qWJK_ zC&Nh%7?frVc@tCOjdIHH{FYYjS|I4<29#5p4lm&6bXxO|N*ctgd$w=g0ea27_N%(s z%-eYT#g2Vj8LI_Hr%Pb?`rTB5X=lGKp+Ep?ipHU$vwJADi6q}qi&^*{Ee#HAOmPt> zL8qZQA4Z;fCH8elmiQG~;|<9(flp_cg9lO8HTZ7f)78Hv+vn8U?JQIaUOuXP3B#oH z=%`?3O?`uL(2Un2I9!lSB`_P=_~0xc%}OB;E#D$%`{HN0uQxo38~Qcenp6<4wi%C1U6OBB*9%V&SwLKYm}y zK7G~%PMydI?rCk3Yi|zgT>a?3W-Z3uT{wP0fdXs zfcDJXp;N0_^riJW0C4_>gG}?VbEdf*NFFL7%x%t28eDLT0Hu}##7gBv(oL5J5l+0|qzvW8 z^wH<7JPWREZTu?BQ{B7AWj-GzA~gK!2J_HLqn@NPpMzBFX&P5NL!NLb-ge75@N zvb*1F>{|;*Czh$H8P@NJ=qjZf^CLGX_m=^ny}Rmj>h{WE7lHgT7a?IK9 zu~t$z_qS}PYDJ%U1qs0Jr($7YVq`hvjrfU({8e7$$$hsh5D zNi}4w5|(5=ncR+H_?n;RTM;O^TX%ibv3?~d*`o(DWMRfk4dz1RgMOOi{5&(O@ij!n zMGnyXwr8qM2MYdiTxn#z$l-!zLDX;=(p)+-gLmQK2ge%;F=OLT@7I)zd3O9L`uef9 z3cy;>0<{Zp;dvLhqjsj!&D4|!&tm|Bs{4qDLokCLUMkt86|xXISEdn`^f!3!oU%$? zi`C+SVqDUCAu}`O7aZ4sI-z*l?%}?JW4U<4WVlgW;ZpfTofuR(w<~><(KK96vyAkf zEpz$$UT~-5(r{z&o}bZFE3N;?0luuFD)hT>0ziHBt6L4%o;agCYr`F7Wwx+P8R^ZM z2G?^H8$jHwfp`cYXOsQAQ>Z}(Z zfs~og__ipfCaea*c=C1a%!f}u{lotz0({#IIEyFCKUH7!hjXK8o296eQterp^$6 z=(oHJXa165fXtlh3TNcx8z7dhaav(Tms{pTB&dTj?uv>{ach|Pwp##3d*6BwWzJ_8 zHT-sk_9lk3G)I43NK_23NS15+E8Y_ZkO!u2TkpeuUB+Lb0{O z)sFA)rF;w0zi}aATx+fWTs)*fHbS7B#ALdVx^I4N4+&u6rdPa4MB2+F!u6EZR5c%B zo&?IY@h)AxXMVx_uW#_muT)G8-&Mx52uqW&4~0)D@`Hcea`uDH)vS(y%!{q0c0QLX zsLr!$V}>be{1{_PKbyzTZwJVmrrp=NnlHM`M=AQ1G*}t)H`v5}6i&1uCQ$0J$xNwO z{)68L>h^`l0)@V&8vZk6yhGZ}B%T+0c0)V=MNvLxvrUzFgiat4RRZ#}t{=sKu6uTu zYbANyQ1VQOfJG5_7$$ZjYNQF*kkr>FJD#8Jj=I(DfRpzFAe8aF`j}J3*kbtiT!;f_ z{@l?PdQb7mWPh^VLFPin^O(y#=tlGgly&RcaP^eS`x8Ox*8y)0=2$&rPg^MWo7wVD zM>>BdP~)MFy+uWN&_@&i1z8~y>6^(i@*6E)>ObeCxDg~2odO9%jVx9xrvG_r`WO)n zwZphz=XuAZ0)x(?bK%QzuB~t~XtzuBo*wmIinVzRd4yN0`ttSBk|C-oQTG)*68IhQ zk$2P`xDoeLGw^5Ij$k`*5Si)UD=5e`Y4zWAeG8`h$F8mI zlTmuAw*6`o*u{R^(QUTjF2(b6cRy=-sS+2@GjB?c)t(4g6ahW016jw8#LvrQ8YnsR zcOZy!kXh@2 zh|HUi8@`%(3|eGL;wRYs$ivhf41eEQXasaK;l^sR(4A?bQ3GL${0WW{ShHc(>`kT? zF!3$uH^}Jmnm!DwUct;r5$6XS--Ul$276}H(iHC(ys3qF)xXL<(lJWV(eP&5E?(}$`l zbTV(k9Y=4-3-N^_Zp5x8(Z->;O?(PBqzfEb$)E6A&YCN< zf^899EkK15tLlRO=5uRKyk7%%ZWzn>s4OSBf#4E8H+zG%IyZ>_+!-+b2Kj`gBpsQ5 z#*XA<9$Z8trcfzmDzl=Q8~a19%vg4D+iVwWzh1ohi7ZmBX^@`T1b=9&X774}y^3q2 zD#JlvLlnpjU^gAa?l+j5YIg?|9RXF`XO-5F`Z#v8L2CUu+yOSNj6WZsS{@{}thA!P zg%Gs`zXM}rKE8F$|37~80H6_ zh3|g;Xxw8H9y1O?@0G=c-r7F%BlY34Uw=A0zX%U++!Gp}`OzBVH zkMUwWqaX%uEi7*vJ0DgKKd@TbYfnZ)wZBYt3`3wTWz^<3cgJa$e8KI2`$FEvpW@Ox!X zAlx;f8Tc9((=gUlryUlLpTS2&*!!OaLw2%qIpq&DdElaT($T117>QtIEsijE0e?e- zs*>ZxbS~VU+1m-h9sB205U_`+tBmh_OrQ)^yz-ENXRlCn!gkwF8KpRENHZ{9IFzYt z$pe`w{ydMt#lEMcIHd3 zW|n}g{~(+3WT=Y2uW-&fXYd}t<+LA{v-Giy&t#diryk-)`=91_Sn?x%rs5{3GX^h` zBkd8)lBG3p7T@43W=X?t68JR-QdGbh{gFN~GaTML74XKUHIc#rgdCBYk2Hey!7^9U_YH7HGh zv!GxpEc}eaB(4^loqAXcY5;og;-)G!*1usy+=<&!Zwq$K@n0RC8%E5ef8NlW;08&3 zVN-FmETHJtS%#zjmMvm4_C%C*8kb3$aY{<4zoqH%(K3NKNWiIyY#Dwirms@G_b&96 z?NvmL!0%cp{Q7#n#c=f+CeE$9x&=O#&thNJroBroxRxr*75haZA`y=j3||* z;Z|9rSzk9}nQC?KjV17q7a}^wSADNrYr*=k(i#oPw#7N8)ad1A0;-n)CjjLKLo640 zlM>ez;WzG#jlJhMtLkbx0?fRCN%mc!lZNuzTaf!xbJ^CjGFO=+ z2#B#aVg-07D-6Go_J)6vXp-nE_$h-Jb8M^n(9gT@(`xhxq-QPK|1MY&p0gr8R!2E4=ul(lF%P$J{s9_52M)Tw#m1#Ni@uMu}s^>@Ud3Akk{T zALb#_`(1Y^KLima8>f~jo40)Zt(it^(7A=OpGnOxJ!?PC`pR_ul?=GXl3gH%?El|6 zpm9jw&v!o*dlm}s3n>k)LQMQ+Aj)|kTs<}=wlq9wYH_E1Z(^+Y7fY9CLm!SYhCa|= zkEi<0i;q3G%}_rZcTQT0~w US#JtdCb9LjG<1$<9Hrd;Ka5-8CjbBd literal 0 HcmV?d00001 diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 39e0d1d..22e289f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -4,6 +4,15 @@ "description": "VS Code extension for figdeck - Markdown to Figma Slides", "version": "0.1.0", "publisher": "figdeck", + "icon": "images/icon.png", + "galleryBanner": { + "color": "#1a1a2e", + "theme": "dark" + }, + "homepage": "https://github.com/7nohe/figdeck", + "bugs": { + "url": "https://github.com/7nohe/figdeck/issues" + }, "license": "MIT", "repository": { "type": "git", From 70a20795aacffa0548bc97eb927b4fbcf376afca Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:50:23 +0900 Subject: [PATCH 06/11] chore(vscode): update build scripts --- .gitignore | 3 ++- packages/vscode/.vscodeignore | 10 ++++++++++ packages/vscode/LICENSE | 21 +++++++++++++++++++++ packages/vscode/package.json | 7 ++++--- 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 packages/vscode/.vscodeignore create mode 100644 packages/vscode/LICENSE diff --git a/.gitignore b/.gitignore index d505e5e..2e5f76e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist .agents -.vscode \ No newline at end of file +.vscode +figdeck-vscode-*.vsix \ No newline at end of file diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore new file mode 100644 index 0000000..52c3dfb --- /dev/null +++ b/packages/vscode/.vscodeignore @@ -0,0 +1,10 @@ +.vscode/** +src/** +node_modules/** +*.ts +!*.d.ts +tsconfig.json +biome.json +.gitignore +bun.lockb +*.map diff --git a/packages/vscode/LICENSE b/packages/vscode/LICENSE new file mode 100644 index 0000000..ef687ca --- /dev/null +++ b/packages/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Daiki Urata + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 22e289f..0b95018 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -173,14 +173,15 @@ } }, "scripts": { - "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap", - "dev": "bun run build --watch", + "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify --keep-names", + "build:dev": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap", + "package": "bun run build && vsce package --no-dependencies", + "dev": "bun run build:dev --watch", "typecheck": "tsc --noEmit", "lint": "biome lint src", "test": "bun test src" }, "devDependencies": { - "@figdeck/shared": "workspace:*", "@types/bun": "^1.3.3", "@types/node": "^22.10.0", "@types/vscode": "^1.85.0", From 38b0f9e3df50f185647dc9feb4838c58eb541a12 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:07:20 +0900 Subject: [PATCH 07/11] feat(docs): add 'VS Code Extension' link to navigation for improved user access --- packages/docs/astro.config.mjs | 1 + .../src/content/docs/en/vscode-extension.md | 130 ++++++++++++++++++ .../src/content/docs/ja/vscode-extension.md | 130 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 packages/docs/src/content/docs/en/vscode-extension.md create mode 100644 packages/docs/src/content/docs/ja/vscode-extension.md diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs index e934d32..70c5914 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -77,6 +77,7 @@ export default defineConfig({ items: [ { label: 'Installation', link: '/getting-started/installation/' }, { label: 'Plugin Setup', link: '/plugin-setup/' }, + { label: 'VS Code Extension', link: '/vscode-extension/' }, ], }, { diff --git a/packages/docs/src/content/docs/en/vscode-extension.md b/packages/docs/src/content/docs/en/vscode-extension.md new file mode 100644 index 0000000..f34d5e7 --- /dev/null +++ b/packages/docs/src/content/docs/en/vscode-extension.md @@ -0,0 +1,130 @@ +--- +title: VS Code Extension +--- + +## Overview + +The figdeck VS Code extension enhances your Markdown editing experience with syntax highlighting, snippets, diagnostics, and integrated CLI commands. + +**[Install from VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=figdeck.figdeck-vscode)** + +## Installation + +1. Open VS Code +2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X) +3. Search for "figdeck" +4. Click Install + +Or use Quick Open (Ctrl+P / Cmd+P): + +``` +ext install figdeck.figdeck-vscode +``` + +## Features + +### Snippets + +Quick insertion of figdeck-specific syntax. Type the prefix and press Tab: + +| Prefix | Description | +|--------|-------------| +| `figdeck-global` | Global frontmatter with background, color, align, valign | +| `figdeck-slide` | New slide with separator | +| `figdeck-transition` | Slide with transition animation | +| `:::columns2` | 2-column layout | +| `:::columns3` | 3-column layout | +| `:::columns4` | 4-column layout | +| `:::figma` | Figma link block | +| `figdeck-gradient` | Gradient background | + +### Syntax Highlighting + +Enhanced highlighting for figdeck-specific syntax: + +- `:::columns` / `:::column` / `:::figma` directives +- `key=value` attributes (link, gap, width, x, y, hideLink, text.*) +- Image size/position specs (`w:`, `h:`, `x:`, `y:`) + +### Slide Outline + +Tree view in the Explorer sidebar showing all slides: + +- Click to jump to slide +- Shows slide number and title +- Updates automatically on document changes + +Navigate between slides with commands: +- `figdeck: Go to Next Slide` +- `figdeck: Go to Previous Slide` + +### Diagnostics + +Real-time validation of figdeck Markdown: + +- Unclosed frontmatter blocks +- Unsupported image formats +- Invalid image size/position values +- Missing `link=` in `:::figma` blocks +- Invalid Figma URLs +- Column count validation +- Gap/width parameter validation + +### Quick Fixes + +CodeActions to fix common issues: + +- Add `link=` property to figma blocks +- Clamp gap to maximum value + +### CLI Integration + +Run figdeck commands directly from VS Code: + +| Command | Description | +|---------|-------------| +| `figdeck: Init slides.md` | Create new slides file | +| `figdeck: Build JSON (current file)` | Build to JSON | +| `figdeck: Start Serve` | Start WebSocket server | +| `figdeck: Stop Serve` | Stop WebSocket server | +| `figdeck: Restart Serve` | Restart WebSocket server | +| `figdeck: Show Output` | Show output channel | + +Status bar shows serve status and port. + +## Settings + +Configure the extension in VS Code settings: + +| Setting | Default | Description | +|---------|---------|-------------| +| `figdeck.cli.command` | `null` | Custom CLI command (e.g., `["bunx", "figdeck"]`) | +| `figdeck.serve.host` | `"127.0.0.1"` | Host for serve command | +| `figdeck.serve.port` | `4141` | Port for serve command | +| `figdeck.serve.allowRemote` | `false` | Allow remote connections | +| `figdeck.serve.secret` | `""` | Secret for authentication | +| `figdeck.serve.noAuth` | `false` | Disable authentication | +| `figdeck.serve.noWatch` | `false` | Disable file watching | +| `figdeck.diagnostics.enabled` | `true` | Enable diagnostics | +| `figdeck.diagnostics.debounceMs` | `300` | Debounce time for diagnostics | +| `figdeck.images.maxSizeMb` | `5` | Maximum image file size in MB | + +## CLI Detection + +The extension looks for figdeck CLI in this order: + +1. `node_modules/.bin/figdeck` in workspace +2. `figdeck` in PATH +3. `figdeck.cli.command` setting + +If not found, you'll be prompted to install or configure it. + +## Workflow + +1. **Create a new slides file**: Use `figdeck: Init slides.md` command +2. **Edit your Markdown**: Use snippets and syntax highlighting +3. **Start the server**: Use `figdeck: Start Serve` command +4. **Connect Figma Plugin**: Open the figdeck plugin in Figma Slides +5. **Live preview**: Your slides are generated automatically + +The extension watches for file changes and updates the Figma plugin in real-time. diff --git a/packages/docs/src/content/docs/ja/vscode-extension.md b/packages/docs/src/content/docs/ja/vscode-extension.md new file mode 100644 index 0000000..8d1a2bb --- /dev/null +++ b/packages/docs/src/content/docs/ja/vscode-extension.md @@ -0,0 +1,130 @@ +--- +title: VS Code 拡張機能 +--- + +## 概要 + +figdeck VS Code 拡張機能は、シンタックスハイライト、スニペット、診断機能、CLI 統合により、Markdown 編集体験を向上させます。 + +**[VS Code Marketplace からインストール](https://marketplace.visualstudio.com/items?itemName=figdeck.figdeck-vscode)** + +## インストール + +1. VS Code を開く +2. 拡張機能へ移動 (Ctrl+Shift+X / Cmd+Shift+X) +3. "figdeck" を検索 +4. インストールをクリック + +または Quick Open (Ctrl+P / Cmd+P) を使用: + +``` +ext install figdeck.figdeck-vscode +``` + +## 機能 + +### スニペット + +figdeck 固有の構文を素早く挿入できます。プレフィックスを入力して Tab を押してください: + +| プレフィックス | 説明 | +|---------------|------| +| `figdeck-global` | グローバル frontmatter(background, color, align, valign) | +| `figdeck-slide` | スライド区切り付きの新しいスライド | +| `figdeck-transition` | トランジションアニメーション付きスライド | +| `:::columns2` | 2カラムレイアウト | +| `:::columns3` | 3カラムレイアウト | +| `:::columns4` | 4カラムレイアウト | +| `:::figma` | Figma リンクブロック | +| `figdeck-gradient` | グラデーション背景 | + +### シンタックスハイライト + +figdeck 固有の構文が強調表示されます: + +- `:::columns` / `:::column` / `:::figma` ディレクティブ +- `key=value` 属性(link, gap, width, x, y, hideLink, text.* など) +- 画像サイズ・位置指定(`w:`, `h:`, `x:`, `y:`) + +### スライドアウトライン + +エクスプローラーサイドバーに全スライドのツリービューが表示されます: + +- クリックでスライドにジャンプ +- スライド番号とタイトルを表示 +- ドキュメント変更時に自動更新 + +コマンドでスライド間を移動: +- `figdeck: Go to Next Slide` +- `figdeck: Go to Previous Slide` + +### 診断機能 + +figdeck Markdown のリアルタイム検証: + +- 閉じられていない frontmatter ブロック +- サポートされていない画像形式 +- 無効な画像サイズ・位置の値 +- `:::figma` ブロックの `link=` の欠落 +- 無効な Figma URL +- カラム数の検証 +- gap/width パラメータの検証 + +### クイックフィックス + +よくある問題を修正する CodeAction: + +- figma ブロックに `link=` プロパティを追加 +- gap を最大値に調整 + +### CLI 統合 + +VS Code から直接 figdeck コマンドを実行: + +| コマンド | 説明 | +|---------|------| +| `figdeck: Init slides.md` | 新しいスライドファイルを作成 | +| `figdeck: Build JSON (current file)` | JSON にビルド | +| `figdeck: Start Serve` | WebSocket サーバーを起動 | +| `figdeck: Stop Serve` | WebSocket サーバーを停止 | +| `figdeck: Restart Serve` | WebSocket サーバーを再起動 | +| `figdeck: Show Output` | 出力チャンネルを表示 | + +ステータスバーにサーバーの状態とポートが表示されます。 + +## 設定 + +VS Code の設定で拡張機能を設定できます: + +| 設定 | デフォルト | 説明 | +|-----|----------|------| +| `figdeck.cli.command` | `null` | カスタム CLI コマンド(例: `["bunx", "figdeck"]`) | +| `figdeck.serve.host` | `"127.0.0.1"` | serve コマンドのホスト | +| `figdeck.serve.port` | `4141` | serve コマンドのポート | +| `figdeck.serve.allowRemote` | `false` | リモート接続を許可 | +| `figdeck.serve.secret` | `""` | 認証用シークレット | +| `figdeck.serve.noAuth` | `false` | 認証を無効化 | +| `figdeck.serve.noWatch` | `false` | ファイル監視を無効化 | +| `figdeck.diagnostics.enabled` | `true` | 診断機能を有効化 | +| `figdeck.diagnostics.debounceMs` | `300` | 診断のデバウンス時間 | +| `figdeck.images.maxSizeMb` | `5` | 最大画像ファイルサイズ(MB) | + +## CLI の検出 + +拡張機能は以下の順序で figdeck CLI を検索します: + +1. ワークスペースの `node_modules/.bin/figdeck` +2. PATH 上の `figdeck` +3. `figdeck.cli.command` 設定 + +見つからない場合は、インストールまたは設定を促すメッセージが表示されます。 + +## ワークフロー + +1. **新しいスライドファイルを作成**: `figdeck: Init slides.md` コマンドを使用 +2. **Markdown を編集**: スニペットとシンタックスハイライトを活用 +3. **サーバーを起動**: `figdeck: Start Serve` コマンドを使用 +4. **Figma プラグインに接続**: Figma Slides で figdeck プラグインを開く +5. **ライブプレビュー**: スライドが自動的に生成されます + +拡張機能はファイルの変更を監視し、Figma プラグインをリアルタイムで更新します。 From 5e9a47e0c689379c98add41734ca6a364ead0c2c Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:36:51 +0900 Subject: [PATCH 08/11] fix(vscode): restore snippets after slide separators --- packages/vscode/package.json | 4 + .../src/authoring/frontmatterCompletion.ts | 108 ++++++++++++------ packages/vscode/src/extensionManifest.test.ts | 87 ++++++++++++++ ...figdeck-markdown.injection.tmLanguage.json | 4 +- 4 files changed, 168 insertions(+), 35 deletions(-) create mode 100644 packages/vscode/src/extensionManifest.test.ts diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 0b95018..d7495d7 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -93,6 +93,10 @@ { "language": "markdown", "path": "./snippets/figdeck.json" + }, + { + "language": "yaml", + "path": "./snippets/figdeck.json" } ], "grammars": [ diff --git a/packages/vscode/src/authoring/frontmatterCompletion.ts b/packages/vscode/src/authoring/frontmatterCompletion.ts index 9a6412a..93078bc 100644 --- a/packages/vscode/src/authoring/frontmatterCompletion.ts +++ b/packages/vscode/src/authoring/frontmatterCompletion.ts @@ -196,48 +196,90 @@ function isInFrontmatter( document: vscode.TextDocument, position: vscode.Position, ): boolean { - const text = document.getText(); - const offset = document.offsetAt(position); + // Keep frontmatter detection aligned with figdeck's Markdown parser: + // - `---` is either a slide separator or a fenced frontmatter fence + // - fenced frontmatter can only start at the beginning of a slide (no meaningful content yet) + // - implicit frontmatter is a YAML-looking block at the beginning of a slide terminated by `---` + + const targetLine = position.line; + let currentLines: string[] = []; + let inFencedFrontmatter = false; + let codeFence: string | null = null; + + const hasMeaningfulContent = (lines: string[]): boolean => + lines.some((l) => l.trim() !== ""); + + const looksLikeInlineFrontmatter = (lines: string[]): boolean => { + let sawKey = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) { + sawKey = true; + continue; + } + if (/^\s+/.test(line) && sawKey) { + continue; + } + return false; + } + return sawKey; + }; + + for (let lineIndex = 0; lineIndex <= targetLine; lineIndex++) { + const lineText = document.lineAt(lineIndex).text; + const trimmed = lineText.trim(); + + // Track fenced code blocks so we don't treat --- inside code samples as separators/frontmatter + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch) { + if (codeFence === null) { + codeFence = fenceMatch[1]; + } else if (trimmed.startsWith(codeFence)) { + codeFence = null; + } + currentLines.push(lineText); + continue; + } - // Find all --- positions - const separatorRegex = /^---\s*$/gm; - const separators: number[] = []; - let match = separatorRegex.exec(text); + if (codeFence !== null) { + currentLines.push(lineText); + continue; + } - while (match !== null) { - separators.push(match.index); - match = separatorRegex.exec(text); - } + if (trimmed === "---") { + if (inFencedFrontmatter) { + currentLines.push(lineText); + inFencedFrontmatter = false; + continue; + } - // Check if we're between pairs of --- - for (let i = 0; i < separators.length - 1; i += 2) { - const start = separators[i]; - const end = separators[i + 1]; - if (offset > start && offset < end + 3) { - return true; - } - } + if (!hasMeaningfulContent(currentLines)) { + inFencedFrontmatter = true; + currentLines.push(lineText); + continue; + } - // Also check for implicit frontmatter (YAML without opening ---) - // This is at the start of a slide block - const lineText = document.lineAt(position.line).text; - if (/^[a-zA-Z][\w-]*:/.test(lineText)) { - // Check if previous non-empty lines are also YAML-like or --- - for (let i = position.line - 1; i >= 0; i--) { - const prevLine = document.lineAt(i).text.trim(); - if (!prevLine) continue; - if (prevLine === "---") return true; - if ( - /^[a-zA-Z][\w-]*:/.test(prevLine) || - /^\s+/.test(document.lineAt(i).text) - ) { + // Implicit frontmatter closer (no opening fence, only key/value lines so far) + if (looksLikeInlineFrontmatter(currentLines)) { + currentLines.push(lineText); continue; } - break; + + // Slide separator: start a new slide context + currentLines = []; + inFencedFrontmatter = false; + continue; } + + currentLines.push(lineText); + } + + if (inFencedFrontmatter) { + return true; } - return false; + return looksLikeInlineFrontmatter(currentLines); } /** diff --git a/packages/vscode/src/extensionManifest.test.ts b/packages/vscode/src/extensionManifest.test.ts new file mode 100644 index 0000000..7a47e45 --- /dev/null +++ b/packages/vscode/src/extensionManifest.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +function readJson(filePath: string): T { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; +} + +const extensionRoot = path.resolve(import.meta.dir, ".."); +const extensionPackageJsonPath = path.join(extensionRoot, "package.json"); +const snippetsPath = path.join(extensionRoot, "snippets", "figdeck.json"); +const grammarPath = path.join( + extensionRoot, + "syntaxes", + "figdeck-markdown.injection.tmLanguage.json", +); + +describe("VS Code extension packaging", () => { + it("contributes figdeck snippets for markdown and yaml", () => { + const packageJson = readJson<{ + contributes?: { snippets?: Array<{ language: string; path: string }> }; + }>(extensionPackageJsonPath); + + const snippets = packageJson.contributes?.snippets ?? []; + expect(Array.isArray(snippets)).toBe(true); + + expect( + snippets.some( + (snippet) => + snippet.language === "markdown" && snippet.path === "./snippets/figdeck.json", + ), + ).toBe(true); + + expect( + snippets.some( + (snippet) => + snippet.language === "yaml" && snippet.path === "./snippets/figdeck.json", + ), + ).toBe(true); + }); + + it("keeps the figdeck-global snippet prefix present", () => { + const snippetsJson = readJson>( + snippetsPath, + ); + + const globalFrontmatter = snippetsJson["figdeck: Global Frontmatter"]; + expect(globalFrontmatter).toBeDefined(); + + const prefixes = globalFrontmatter.prefix; + expect(Array.isArray(prefixes)).toBe(true); + expect(prefixes).toContain("figdeck-global"); + }); +}); + +describe("figdeck markdown injection grammar", () => { + it("ends a frontmatter block on non-YAML content lines", () => { + const grammar = readJson<{ + repository?: Record< + string, + { name?: string; begin?: string; end?: string } + >; + }>(grammarPath); + + const frontmatter = grammar.repository?.["figdeck-frontmatter-block"]; + expect(frontmatter).toBeDefined(); + expect(typeof frontmatter?.name).toBe("string"); + expect(frontmatter?.name === "meta.embedded.block.frontmatter.figdeck").toBe( + false, + ); + expect(typeof frontmatter?.end).toBe("string"); + + const endRegex = new RegExp(frontmatter?.end ?? "", "m"); + + // Closing fence should end the block + expect(endRegex.test("---")).toBe(true); + + // YAML-looking lines should NOT end the block + expect(endRegex.test('background: "#000"')).toBe(false); + expect(endRegex.test(" size: 12")).toBe(false); + + // Typical slide content should end the block (prevents --- separators from swallowing the next slide) + expect(endRegex.test("## Slide 2")).toBe(true); + expect(endRegex.test(":::columns")).toBe(true); + expect(endRegex.test("- bullet")).toBe(true); + }); +}); diff --git a/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json b/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json index a2b471a..e556888 100644 --- a/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json +++ b/packages/vscode/syntaxes/figdeck-markdown.injection.tmLanguage.json @@ -12,12 +12,12 @@ ], "repository": { "figdeck-frontmatter-block": { - "name": "meta.embedded.block.frontmatter.figdeck", + "name": "meta.block.frontmatter.figdeck", "begin": "^(---)[\\t ]*$", "beginCaptures": { "1": { "name": "punctuation.definition.frontmatter.begin.figdeck" } }, - "end": "^(---)[\\t ]*$", + "end": "^(---)[\\t ]*$|^(?=\\S)(?![\\t ]*[a-zA-Z_][a-zA-Z0-9_-]*:)(?![\\t ]+)", "endCaptures": { "1": { "name": "punctuation.definition.frontmatter.end.figdeck" } }, From ae328b15e33607d60f5c0f3fd0bb69b11f19b77d Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:39:24 +0900 Subject: [PATCH 09/11] docs: add VS Code extension CI workflow --- .github/workflows/publish-vscode.yml | 65 +++++++++++++++++++ packages/vscode/src/extensionManifest.test.ts | 17 ++--- 2 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/publish-vscode.yml diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml new file mode 100644 index 0000000..318f583 --- /dev/null +++ b/.github/workflows/publish-vscode.yml @@ -0,0 +1,65 @@ +name: Publish VS Code Extension + +on: + workflow_dispatch: + inputs: + publish_openvsx: + description: "Also publish to Open VSX Registry" + required: false + type: boolean + default: true + confirm: + description: "Confirm publish" + required: true + type: boolean + default: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + if: ${{ inputs.confirm }} + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install dependencies + run: bun install + + - name: Build extension + run: bun run build + working-directory: packages/vscode + + - name: Run typecheck + run: bun run typecheck + working-directory: packages/vscode + + - name: Install vsce + run: npm install -g @vscode/vsce + + - name: Publish to VS Code Marketplace + run: vsce publish --no-dependencies + working-directory: packages/vscode + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + + - name: Install ovsx + if: ${{ inputs.publish_openvsx }} + run: npm install -g ovsx + + - name: Publish to Open VSX + if: ${{ inputs.publish_openvsx }} + run: ovsx publish --no-dependencies + working-directory: packages/vscode + env: + OVSX_PAT: ${{ secrets.OVSX_TOKEN }} diff --git a/packages/vscode/src/extensionManifest.test.ts b/packages/vscode/src/extensionManifest.test.ts index 7a47e45..445124a 100644 --- a/packages/vscode/src/extensionManifest.test.ts +++ b/packages/vscode/src/extensionManifest.test.ts @@ -27,22 +27,23 @@ describe("VS Code extension packaging", () => { expect( snippets.some( (snippet) => - snippet.language === "markdown" && snippet.path === "./snippets/figdeck.json", + snippet.language === "markdown" && + snippet.path === "./snippets/figdeck.json", ), ).toBe(true); expect( snippets.some( (snippet) => - snippet.language === "yaml" && snippet.path === "./snippets/figdeck.json", + snippet.language === "yaml" && + snippet.path === "./snippets/figdeck.json", ), ).toBe(true); }); it("keeps the figdeck-global snippet prefix present", () => { - const snippetsJson = readJson>( - snippetsPath, - ); + const snippetsJson = + readJson>(snippetsPath); const globalFrontmatter = snippetsJson["figdeck: Global Frontmatter"]; expect(globalFrontmatter).toBeDefined(); @@ -65,9 +66,9 @@ describe("figdeck markdown injection grammar", () => { const frontmatter = grammar.repository?.["figdeck-frontmatter-block"]; expect(frontmatter).toBeDefined(); expect(typeof frontmatter?.name).toBe("string"); - expect(frontmatter?.name === "meta.embedded.block.frontmatter.figdeck").toBe( - false, - ); + expect( + frontmatter?.name === "meta.embedded.block.frontmatter.figdeck", + ).toBe(false); expect(typeof frontmatter?.end).toBe("string"); const endRegex = new RegExp(frontmatter?.end ?? "", "m"); From 7f6ed80ab18b633de601980b72e08ca0d75b3494 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:56:11 +0900 Subject: [PATCH 10/11] feat(vscode): enhance Figma block analysis to validate position values with units --- .../vscode/src/diagnostics/analyzer.test.ts | 16 ++++++++++++++++ packages/vscode/src/diagnostics/analyzer.ts | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/vscode/src/diagnostics/analyzer.test.ts b/packages/vscode/src/diagnostics/analyzer.test.ts index ef76461..49880fb 100644 --- a/packages/vscode/src/diagnostics/analyzer.test.ts +++ b/packages/vscode/src/diagnostics/analyzer.test.ts @@ -561,6 +561,22 @@ describe("analyzeFigmaBlocks", () => { expect(issues[0].message).toContain("y"); }); + it("should detect position values with units", () => { + const lines = [ + ":::figma", + "link=https://www.figma.com/file/abc", + "x=10px", + "y=20rem", + ":::", + ]; + const issues = analyzeFigmaBlocks(lines); + expect(issues).toHaveLength(2); + expect(issues[0].code).toBe("figma-invalid-position"); + expect(issues[0].message).toContain("x"); + expect(issues[1].code).toBe("figma-invalid-position"); + expect(issues[1].message).toContain("y"); + }); + it("should allow percentage position values", () => { const lines = [ ":::figma", diff --git a/packages/vscode/src/diagnostics/analyzer.ts b/packages/vscode/src/diagnostics/analyzer.ts index fa090ff..e54bc8e 100644 --- a/packages/vscode/src/diagnostics/analyzer.ts +++ b/packages/vscode/src/diagnostics/analyzer.ts @@ -512,6 +512,19 @@ export function validateImageAlt( return issues; } +function isValidNumberOrPercentage(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + + if (trimmed.endsWith("%")) { + const numberPart = trimmed.slice(0, -1).trim(); + if (!numberPart) return false; + return Number.isFinite(Number(numberPart)); + } + + return Number.isFinite(Number(trimmed)); +} + /** * Analyze :::figma blocks for issues * @internal Exported for testing @@ -593,8 +606,8 @@ export function analyzeFigmaBlocks(lines: string[]): Issue[] { const posMatch = trimmed.match(/^(x|y)\s*=\s*(.+)$/); if (posMatch) { const prop = posMatch[1]; - const value = posMatch[2]; - if (value && Number.isNaN(Number.parseFloat(value.replace("%", "")))) { + const value = posMatch[2].trim(); + if (value && !isValidNumberOrPercentage(value)) { issues.push({ severity: "warning", message: `Invalid ${prop} value: expected number or percentage`, From 1a6336f685deba0f5211a7b0661eaef11ee6a1ae Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:46:36 +0900 Subject: [PATCH 11/11] refactor(vscode): streamline frontmatter handling and CLI detection logic --- .../src/authoring/frontmatterCompletion.ts | 384 +++-------- packages/vscode/src/authoring/slideParser.ts | 32 +- packages/vscode/src/diagnostics/analyzer.ts | 68 +- .../vscode/src/diagnostics/codeActions.ts | 62 +- .../src/diagnostics/frontmatterValidator.ts | 626 +++++++++--------- packages/vscode/src/extension.ts | 22 +- packages/vscode/src/frontmatter-spec.ts | 364 ++++++++++ packages/vscode/src/frontmatter-utils.ts | 26 + packages/vscode/src/ops/cli-runner.test.ts | 2 - packages/vscode/src/ops/cli-runner.ts | 8 +- packages/vscode/src/ops/figdeckCli.ts | 4 - packages/vscode/src/ops/serverManager.ts | 11 +- 12 files changed, 851 insertions(+), 758 deletions(-) create mode 100644 packages/vscode/src/frontmatter-spec.ts create mode 100644 packages/vscode/src/frontmatter-utils.ts diff --git a/packages/vscode/src/authoring/frontmatterCompletion.ts b/packages/vscode/src/authoring/frontmatterCompletion.ts index 93078bc..ce78a68 100644 --- a/packages/vscode/src/authoring/frontmatterCompletion.ts +++ b/packages/vscode/src/authoring/frontmatterCompletion.ts @@ -1,193 +1,63 @@ import * as vscode from "vscode"; +import { FRONTMATTER_SPEC, type FrontmatterDef } from "../frontmatter-spec"; +import { + hasMeaningfulContent, + looksLikeInlineFrontmatter, +} from "../frontmatter-utils"; + +function getObjectChildren( + def: FrontmatterDef, +): Record | undefined { + if (def.kind === "object") return def.children; + if (def.kind === "oneOf") { + const entries = def.options.flatMap((option) => { + const children = getObjectChildren(option); + return children ? Object.entries(children) : []; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + } + return undefined; +} -/** - * Frontmatter property definitions for autocompletion - */ -const FRONTMATTER_PROPERTIES: Record< - string, - { - description: string; - values?: string[]; - children?: Record; +function hasChildren(def: FrontmatterDef): boolean { + return Boolean(getObjectChildren(def)); +} + +function getDefAtPath(path: string[]): FrontmatterDef | undefined { + let current: FrontmatterDef = { + kind: "object", + description: "Frontmatter", + children: FRONTMATTER_SPEC, + }; + + for (const key of path) { + const children = getObjectChildren(current); + if (!children) return undefined; + const next = children[key]; + if (!next) return undefined; + current = next; } -> = { - figdeck: { - description: "Enable figdeck processing for this file", - values: ["true", "false"], - }, - background: { - description: "Solid background color (e.g., #1a1a2e)", - }, - gradient: { - description: - "Gradient background (e.g., #0d1117:0%,#1f2937:50%,#58a6ff:100%@45)", - }, - backgroundImage: { - description: "Background image path or URL", - }, - template: { - description: "Figma paint style name", - }, - color: { - description: "Base text color for all elements", - }, - align: { - description: "Horizontal alignment", - values: ["left", "center", "right"], - }, - valign: { - description: "Vertical alignment", - values: ["top", "middle", "bottom"], - }, - headings: { - description: "Heading styles configuration", - children: { - h1: { description: "H1 heading style" }, - h2: { description: "H2 heading style" }, - h3: { description: "H3 heading style" }, - h4: { description: "H4 heading style" }, - }, - }, - paragraphs: { - description: "Paragraph style configuration", - children: { - size: { description: "Font size in pixels" }, - color: { description: "Text color" }, - x: { description: "Absolute X position" }, - y: { description: "Absolute Y position" }, - }, - }, - bullets: { - description: "Bullet list style configuration", - children: { - size: { description: "Font size in pixels" }, - color: { description: "Text color" }, - x: { description: "Absolute X position" }, - y: { description: "Absolute Y position" }, - spacing: { description: "Gap between bullet items" }, - }, - }, - code: { - description: "Code block style configuration", - children: { - size: { description: "Font size in pixels" }, - }, - }, - fonts: { - description: "Custom font configuration", - children: { - h1: { description: "H1 font family" }, - h2: { description: "H2 font family" }, - h3: { description: "H3 font family" }, - h4: { description: "H4 font family" }, - body: { description: "Body text font family" }, - bullets: { description: "Bullet text font family" }, - code: { description: "Code font family" }, - }, - }, - slideNumber: { - description: "Slide number configuration", - children: { - show: { - description: "Show/hide slide numbers", - values: ["true", "false"], - }, - position: { - description: "Position of slide number", - values: ["bottom-right", "bottom-left", "top-right", "top-left"], - }, - size: { description: "Font size in pixels" }, - color: { description: "Text color" }, - format: { - description: 'Display format (e.g., "{{current}} / {{total}}")', - }, - link: { description: "Custom Frame design Figma link" }, - startFrom: { description: "Start showing from slide N" }, - }, - }, - titlePrefix: { - description: "Title prefix component configuration", - children: { - link: { description: "Figma component link" }, - spacing: { description: "Gap between prefix and title" }, - }, - }, - transition: { - description: "Slide transition animation", - values: [ - "none", - "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", - "move-from-left", - "move-from-right", - "move-from-top", - "move-from-bottom", - ], - children: { - style: { - description: "Animation style", - values: [ - "none", - "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", - "move-from-left", - "move-from-right", - "move-from-top", - "move-from-bottom", - ], - }, - duration: { description: "Duration in seconds (0.01-10)" }, - curve: { - description: "Easing curve", - values: [ - "ease-in", - "ease-out", - "ease-in-and-out", - "linear", - "gentle", - "quick", - "bouncy", - "slow", - ], - }, - timing: { description: "Timing configuration" }, - }, - }, -}; -/** - * Nested property definitions (for size, color, etc.) - */ -const STYLE_PROPERTIES: Record = { - size: { description: "Font size in pixels" }, - color: { description: "Text color" }, - x: { description: "Absolute X position" }, - y: { description: "Absolute Y position" }, -}; - -const FONT_PROPERTIES: Record = { - family: { description: "Font family name" }, - style: { description: 'Base style (default: "Regular")' }, - bold: { description: 'Bold variant (default: "Bold")' }, - italic: { description: 'Italic variant (default: "Italic")' }, - boldItalic: { description: "Bold Italic variant" }, -}; + return current; +} + +function getCompletionValues(def: FrontmatterDef): string[] { + if (def.kind === "oneOf") { + const values = def.options.flatMap(getCompletionValues); + return [...new Set(values)]; + } + + if (def.kind === "string") { + return def.values ? [...def.values] : []; + } + + if (def.kind === "boolean") { + const allowed = def.allowedValues ?? [true, false]; + return allowed.map((value) => (value ? "true" : "false")); + } + + return []; +} /** * Check if cursor is inside a frontmatter block @@ -206,26 +76,6 @@ function isInFrontmatter( let inFencedFrontmatter = false; let codeFence: string | null = null; - const hasMeaningfulContent = (lines: string[]): boolean => - lines.some((l) => l.trim() !== ""); - - const looksLikeInlineFrontmatter = (lines: string[]): boolean => { - let sawKey = false; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) { - sawKey = true; - continue; - } - if (/^\s+/.test(line) && sawKey) { - continue; - } - return false; - } - return sawKey; - }; - for (let lineIndex = 0; lineIndex <= targetLine; lineIndex++) { const lineText = document.lineAt(lineIndex).text; const trimmed = lineText.trim(); @@ -355,109 +205,39 @@ export class FrontmatterCompletionProvider private getKeyCompletions( parentKeys: string[], - _indent: number, + indent: number, ): vscode.CompletionItem[] { - const items: vscode.CompletionItem[] = []; - - if (parentKeys.length === 0) { - // Top-level keys - for (const [key, def] of Object.entries(FRONTMATTER_PROPERTIES)) { - const item = new vscode.CompletionItem( - key, - vscode.CompletionItemKind.Property, - ); - item.detail = def.description; - item.insertText = def.children ? `${key}:\n ` : `${key}: `; - items.push(item); - } - } else { - // Nested keys - const parentKey = parentKeys[parentKeys.length - 1]; - const parentDef = FRONTMATTER_PROPERTIES[parentKey]; - - if (parentDef?.children) { - for (const [key, def] of Object.entries(parentDef.children)) { - const item = new vscode.CompletionItem( - key, - vscode.CompletionItemKind.Property, - ); - item.detail = def.description; - item.insertText = `${key}: `; - items.push(item); - } - } - - // Add style properties for headings children (h1, h2, etc.) - if ( - parentKeys.includes("headings") || - parentKeys.includes("paragraphs") || - parentKeys.includes("bullets") - ) { - for (const [key, def] of Object.entries(STYLE_PROPERTIES)) { - const item = new vscode.CompletionItem( - key, - vscode.CompletionItemKind.Property, - ); - item.detail = def.description; - item.insertText = `${key}: `; - items.push(item); - } - } - - // Add font properties for fonts children - if (parentKeys.includes("fonts")) { - for (const [key, def] of Object.entries(FONT_PROPERTIES)) { - const item = new vscode.CompletionItem( - key, - vscode.CompletionItemKind.Property, - ); - item.detail = def.description; - item.insertText = `${key}: `; - items.push(item); - } - } - } - - return items; + const def = getDefAtPath(parentKeys); + const children = def ? getObjectChildren(def) : undefined; + if (!children) return []; + + return Object.entries(children).map(([key, childDef]) => { + const item = new vscode.CompletionItem( + key, + vscode.CompletionItemKind.Property, + ); + item.detail = childDef.description; + item.insertText = hasChildren(childDef) + ? `${key}:\n${" ".repeat(indent + 2)}` + : `${key}: `; + return item; + }); } private getValueCompletions( key: string, parentKeys: string[], ): vscode.CompletionItem[] { - const items: vscode.CompletionItem[] = []; - - // Check for nested key values - if (parentKeys.length > 0) { - const parentKey = parentKeys[parentKeys.length - 1]; - const parentDef = FRONTMATTER_PROPERTIES[parentKey]; - const childDef = parentDef?.children?.[key]; - - if (childDef?.values) { - for (const value of childDef.values) { - const item = new vscode.CompletionItem( - value, - vscode.CompletionItemKind.Value, - ); - items.push(item); - } - return items; - } - } - - // Check for top-level key values - const def = FRONTMATTER_PROPERTIES[key]; - if (def?.values) { - for (const value of def.values) { - const item = new vscode.CompletionItem( - value, - vscode.CompletionItemKind.Value, - ); - items.push(item); - } - } - - return items; + const def = getDefAtPath([...parentKeys, key]); + if (!def) return []; + + return getCompletionValues(def).map((value) => { + const item = new vscode.CompletionItem( + value, + vscode.CompletionItemKind.Value, + ); + return item; + }); } } diff --git a/packages/vscode/src/authoring/slideParser.ts b/packages/vscode/src/authoring/slideParser.ts index f73142d..29f89c2 100644 --- a/packages/vscode/src/authoring/slideParser.ts +++ b/packages/vscode/src/authoring/slideParser.ts @@ -1,3 +1,8 @@ +import { + hasMeaningfulContent, + looksLikeInlineFrontmatter, +} from "../frontmatter-utils"; + /** * Represents a slide in the outline */ @@ -8,33 +13,6 @@ export interface SlideInfo { endLine: number; } -/** - * Check if lines look like implicit frontmatter (YAML key/value pairs). - */ -function looksLikeInlineFrontmatter(lines: string[]): boolean { - let sawKey = false; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) { - sawKey = true; - continue; - } - if (/^\s+/.test(line) && sawKey) { - continue; - } - return false; - } - return sawKey; -} - -/** - * Check if lines array has meaningful content (non-empty lines) - */ -function hasMeaningfulContent(lines: string[]): boolean { - return lines.some((l) => l.trim() !== ""); -} - /** * Check if content is only frontmatter (no actual slide content) */ diff --git a/packages/vscode/src/diagnostics/analyzer.ts b/packages/vscode/src/diagnostics/analyzer.ts index e54bc8e..ff7fecf 100644 --- a/packages/vscode/src/diagnostics/analyzer.ts +++ b/packages/vscode/src/diagnostics/analyzer.ts @@ -114,6 +114,30 @@ function toCandidatePaths( return candidates; } +function* iterateLinesOutsideCodeFences( + lines: string[], +): Generator<{ line: string; trimmed: string; index: number }> { + let codeFence: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch) { + if (codeFence === null) { + codeFence = fenceMatch[1]; + } else if (trimmed.startsWith(codeFence)) { + codeFence = null; + } + continue; + } + + if (codeFence !== null) continue; + yield { line, trimmed, index: i }; + } +} + /** * Analyze a figdeck markdown document for issues */ @@ -360,19 +384,7 @@ export async function analyzeImages( const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)/g; const maxSizeMb = resolveMaxSizeMb(options); - let inCodeFence = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Track code fences - if (trimmed.match(/^(```+|~~~+)/)) { - inCodeFence = !inCodeFence; - continue; - } - - if (inCodeFence) continue; - + for (const { line, index: i } of iterateLinesOutsideCodeFences(lines)) { // Find images in line for (const match of line.matchAll(imagePattern)) { const alt = match[1]; @@ -532,25 +544,13 @@ function isValidNumberOrPercentage(value: string): boolean { export function analyzeFigmaBlocks(lines: string[]): Issue[] { const issues: Issue[] = []; - let inCodeFence = false; let inFigmaBlock = false; let figmaBlockStart = -1; let hasLink = false; let linkValue = ""; let linkLine = -1; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Track code fences - if (trimmed.match(/^(```+|~~~+)/)) { - inCodeFence = !inCodeFence; - continue; - } - - if (inCodeFence) continue; - + for (const { trimmed, index: i } of iterateLinesOutsideCodeFences(lines)) { if (trimmed === ":::figma") { inFigmaBlock = true; figmaBlockStart = i; @@ -615,7 +615,7 @@ export function analyzeFigmaBlocks(lines: string[]): Issue[] { startLine: i, startColumn: 0, endLine: i, - endColumn: line.length, + endColumn: lines[i].length, }, code: "figma-invalid-position", }); @@ -663,25 +663,13 @@ export function isValidFigmaUrl(url: string): boolean { export function analyzeColumnsBlocks(lines: string[]): Issue[] { const issues: Issue[] = []; - let inCodeFence = false; let inColumnsBlock = false; let columnsBlockStart = -1; let columnCount = 0; let columnsParams = ""; let columnsParamsLine = -1; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Track code fences - if (trimmed.match(/^(```+|~~~+)/)) { - inCodeFence = !inCodeFence; - continue; - } - - if (inCodeFence) continue; - + for (const { trimmed, index: i } of iterateLinesOutsideCodeFences(lines)) { const columnsMatch = trimmed.match(/^:::columns\s*(.*)$/); if (columnsMatch) { inColumnsBlock = true; diff --git a/packages/vscode/src/diagnostics/codeActions.ts b/packages/vscode/src/diagnostics/codeActions.ts index 6868f21..26901ff 100644 --- a/packages/vscode/src/diagnostics/codeActions.ts +++ b/packages/vscode/src/diagnostics/codeActions.ts @@ -1,4 +1,9 @@ import * as vscode from "vscode"; +import { + TRANSITION_CURVES, + TRANSITION_STYLES, + TRANSITION_TIMING_TYPES, +} from "../frontmatter-spec"; /** * CodeAction provider for figdeck diagnostics @@ -280,44 +285,12 @@ function normalizeColor(value: string): string | null { return null; } -const TRANSITION_STYLES = [ - "none", - "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", - "move-from-left", - "move-from-right", - "move-from-top", - "move-from-bottom", - "slide-out-to-left", - "slide-out-to-right", - "slide-out-to-top", - "slide-out-to-bottom", - "move-out-to-left", - "move-out-to-right", - "move-out-to-top", - "move-out-to-bottom", -]; - -const TRANSITION_CURVES = [ - "ease-in", - "ease-out", - "ease-in-and-out", - "linear", - "gentle", - "quick", - "bouncy", - "slow", -]; - -const TIMING_TYPES = ["on-click", "after-delay"]; +function isOneOf( + options: T, + value: string, +): value is T[number] { + return (options as readonly string[]).includes(value); +} /** * Normalize transition style/curve values @@ -328,7 +301,7 @@ function normalizeTransitionValue(key: string, value: string): string | null { // Check against valid values if (key === "style") { - if (TRANSITION_STYLES.includes(normalized)) { + if (isOneOf(TRANSITION_STYLES, normalized)) { return normalized; } // Try fuzzy matching for common mistakes @@ -337,7 +310,7 @@ function normalizeTransitionValue(key: string, value: string): string | null { } if (key === "curve") { - if (TRANSITION_CURVES.includes(normalized)) { + if (isOneOf(TRANSITION_CURVES, normalized)) { return normalized; } // Handle common variations @@ -351,7 +324,7 @@ function normalizeTransitionValue(key: string, value: string): string | null { } if (key === "type") { - if (TIMING_TYPES.includes(normalized)) { + if (isOneOf(TRANSITION_TIMING_TYPES, normalized)) { return normalized; } // Handle common variations @@ -363,7 +336,7 @@ function normalizeTransitionValue(key: string, value: string): string | null { ) { return "after-delay"; } - const closest = findClosestMatch(normalized, TIMING_TYPES); + const closest = findClosestMatch(normalized, TRANSITION_TIMING_TYPES); return closest; } @@ -373,7 +346,10 @@ function normalizeTransitionValue(key: string, value: string): string | null { /** * Find closest matching string using Levenshtein distance */ -function findClosestMatch(value: string, candidates: string[]): string | null { +function findClosestMatch( + value: string, + candidates: readonly string[], +): string | null { let bestMatch: string | null = null; let bestDistance = Number.POSITIVE_INFINITY; diff --git a/packages/vscode/src/diagnostics/frontmatterValidator.ts b/packages/vscode/src/diagnostics/frontmatterValidator.ts index 7b8ce44..b99d63b 100644 --- a/packages/vscode/src/diagnostics/frontmatterValidator.ts +++ b/packages/vscode/src/diagnostics/frontmatterValidator.ts @@ -1,210 +1,121 @@ import { parse as parseYaml } from "yaml"; +import { + FRONTMATTER_SPEC, + type FrontmatterDef, + TRANSITION_STYLES, +} from "../frontmatter-spec"; import type { Issue } from "./types"; -/** - * Property definition for validation - */ -interface PropertyDef { - type: "string" | "number" | "boolean" | "object" | "array"; - values?: string[]; - pattern?: RegExp; - min?: number; - max?: number; - children?: Record; - patternError?: string; +type FrontmatterSchema = Record; + +function countErrors(issues: Issue[]): number { + return issues.filter((issue) => issue.severity === "error").length; } -/** - * Frontmatter schema definition - */ -const FRONTMATTER_SCHEMA: Record = { - figdeck: { - type: "boolean", - }, - background: { - type: "string", - pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, - patternError: "Invalid color format. Use #rgb or #rrggbb", - }, - gradient: { - type: "string", - pattern: /^#[0-9a-fA-F]{3,6}:\d+%/, - patternError: "Invalid gradient format. Use #color:0%,#color:100%[@angle]", - }, - backgroundImage: { - type: "string", - }, - template: { - type: "string", - }, - color: { - type: "string", - pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, - patternError: "Invalid color format. Use #rgb or #rrggbb", - }, - align: { - type: "string", - values: ["left", "center", "right"], - }, - valign: { - type: "string", - values: ["top", "middle", "bottom"], - }, - headings: { - type: "object", - children: { - h1: { type: "object", children: createStyleSchema() }, - h2: { type: "object", children: createStyleSchema() }, - h3: { type: "object", children: createStyleSchema() }, - h4: { type: "object", children: createStyleSchema() }, - }, - }, - paragraphs: { - type: "object", - children: createStyleSchema(), - }, - bullets: { - type: "object", - children: { - ...createStyleSchema(), - spacing: { type: "number", min: 0 }, - }, - }, - code: { - type: "object", - children: { - size: { type: "number", min: 1 }, - }, - }, - fonts: { - type: "object", - children: { - h1: { type: "object", children: createFontSchema() }, - h2: { type: "object", children: createFontSchema() }, - h3: { type: "object", children: createFontSchema() }, - h4: { type: "object", children: createFontSchema() }, - body: { type: "object", children: createFontSchema() }, - bullets: { type: "object", children: createFontSchema() }, - code: { type: "object", children: createFontSchema() }, - }, - }, - slideNumber: { - type: "object", - children: { - show: { type: "boolean" }, - position: { - type: "string", - values: ["bottom-right", "bottom-left", "top-right", "top-left"], - }, - size: { type: "number", min: 1 }, - color: { - type: "string", - pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, - patternError: "Invalid color format", - }, - format: { type: "string" }, - link: { - type: "string", - pattern: /^https:\/\/(www\.)?figma\.com\//, - patternError: "Must be a valid Figma URL", - }, - startFrom: { type: "number", min: 1 }, - }, - }, - titlePrefix: { - type: "object", - children: { - link: { - type: "string", - pattern: /^https:\/\/(www\.)?figma\.com\//, - patternError: "Must be a valid Figma URL", - }, - spacing: { type: "number", min: 0 }, - }, - }, - transition: { - type: "object", - children: { - style: { - type: "string", - values: [ - "none", - "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", - "move-from-left", - "move-from-right", - "move-from-top", - "move-from-bottom", - "slide-out-to-left", - "slide-out-to-right", - "slide-out-to-top", - "slide-out-to-bottom", - "move-out-to-left", - "move-out-to-right", - "move-out-to-top", - "move-out-to-bottom", - ], - }, - duration: { type: "number", min: 0.01, max: 10 }, - curve: { - type: "string", - values: [ - "ease-in", - "ease-out", - "ease-in-and-out", - "linear", - "gentle", - "quick", - "bouncy", - "slow", - ], +function compareIssueSets(a: Issue[], b: Issue[]): number { + const aErrors = countErrors(a); + const bErrors = countErrors(b); + if (aErrors !== bErrors) return aErrors - bErrors; + return a.length - b.length; +} + +function isValueCompatibleWithDef( + value: unknown, + def: FrontmatterDef, +): boolean { + if (def.kind === "oneOf") { + return def.options.some((option) => + isValueCompatibleWithDef(value, option), + ); + } + + switch (def.kind) { + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "boolean": + return typeof value === "boolean"; + case "object": + return ( + typeof value === "object" && value !== null && !Array.isArray(value) + ); + } +} + +function validateTransitionShorthand( + raw: string, + line: number, + lineLength: number, +): Issue[] { + const issues: Issue[] = []; + + const trimmed = raw.trim(); + if (!trimmed) { + issues.push({ + code: "frontmatter-invalid-value", + message: "'transition' must not be empty", + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, }, - timing: { - type: "object", - children: { - type: { type: "string", values: ["on-click", "after-delay"] }, - delay: { type: "number", min: 0, max: 30 }, - }, + }); + return issues; + } + + const [styleRaw, durationRaw] = trimmed.split(/\s+/, 2); + const style = styleRaw.toLowerCase().replace(/_/g, "-"); + + if ( + !TRANSITION_STYLES.includes(style as (typeof TRANSITION_STYLES)[number]) + ) { + issues.push({ + code: "frontmatter-invalid-value", + message: `'transition' must be one of: ${TRANSITION_STYLES.join(", ")}`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, }, - }, - }, -}; + }); + return issues; + } -/** - * Create style schema (size, color, x, y) - */ -function createStyleSchema(): Record { - return { - size: { type: "number", min: 1 }, - color: { - type: "string", - pattern: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, - patternError: "Invalid color format", - }, - x: { type: "number" }, - y: { type: "number" }, - }; -} + if (durationRaw) { + const duration = Number(durationRaw); + if (!Number.isFinite(duration)) { + issues.push({ + code: "frontmatter-invalid-format", + message: "'transition' duration must be a number", + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } else if (duration < 0.01 || duration > 10) { + issues.push({ + code: "frontmatter-out-of-range", + message: "'transition' duration must be between 0.01 and 10", + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + } -/** - * Create font schema - */ -function createFontSchema(): Record { - return { - family: { type: "string" }, - style: { type: "string" }, - bold: { type: "string" }, - italic: { type: "string" }, - boldItalic: { type: "string" }, - }; + return issues; } /** @@ -239,146 +150,221 @@ function findKeyLine( */ function validateValue( value: unknown, - def: PropertyDef, + def: FrontmatterDef, keyPath: string[], lines: string[], startLine: number, endLine: number, ): Issue[] { - const issues: Issue[] = []; const line = findKeyLine(lines, startLine, endLine, keyPath); const keyName = keyPath.join("."); const lineLength = lines[line]?.length ?? 100; - // Type check - if (def.type === "number") { - if (typeof value !== "number") { - issues.push({ - code: "frontmatter-invalid-type", - message: `'${keyName}' must be a number`, - severity: "error", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); - return issues; - } - if (def.min !== undefined && value < def.min) { - issues.push({ - code: "frontmatter-out-of-range", - message: `'${keyName}' must be at least ${def.min}`, - severity: "warning", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); + if (keyPath.length === 1 && keyPath[0] === "transition") { + if (typeof value === "string") { + return validateTransitionShorthand(value, line, lineLength); } - if (def.max !== undefined && value > def.max) { - issues.push({ - code: "frontmatter-out-of-range", - message: `'${keyName}' must be at most ${def.max}`, - severity: "warning", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, + } + + if (def.kind === "oneOf") { + const candidates = def.options.filter((candidate) => + isValueCompatibleWithDef(value, candidate), + ); + if (candidates.length === 0) { + return [ + { + code: "frontmatter-invalid-type", + message: `'${keyName}' has an invalid type`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, }, - }); + ]; } - } else if (def.type === "boolean") { - if (typeof value !== "boolean") { - issues.push({ - code: "frontmatter-invalid-type", - message: `'${keyName}' must be true or false`, - severity: "error", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); + + let bestIssues: Issue[] | null = null; + for (const candidate of candidates) { + const candidateIssues = validateValue( + value, + candidate, + keyPath, + lines, + startLine, + endLine, + ); + if (candidateIssues.length === 0) return candidateIssues; + if (!bestIssues || compareIssueSets(candidateIssues, bestIssues) < 0) { + bestIssues = candidateIssues; + } } - } else if (def.type === "string") { - if (typeof value !== "string") { - issues.push({ - code: "frontmatter-invalid-type", - message: `'${keyName}' must be a string`, - severity: "error", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); + + return bestIssues ?? []; + } + + switch (def.kind) { + case "number": { + if (typeof value !== "number") { + return [ + { + code: "frontmatter-invalid-type", + message: `'${keyName}' must be a number`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }, + ]; + } + + const issues: Issue[] = []; + if (def.min !== undefined && value < def.min) { + issues.push({ + code: "frontmatter-out-of-range", + message: `'${keyName}' must be at least ${def.min}`, + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + if (def.max !== undefined && value > def.max) { + issues.push({ + code: "frontmatter-out-of-range", + message: `'${keyName}' must be at most ${def.max}`, + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } return issues; } - // Value validation - if (def.values && !def.values.includes(value)) { - issues.push({ - code: "frontmatter-invalid-value", - message: `'${keyName}' must be one of: ${def.values.join(", ")}`, - severity: "error", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); + + case "boolean": { + if (typeof value !== "boolean") { + return [ + { + code: "frontmatter-invalid-type", + message: `'${keyName}' must be true or false`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }, + ]; + } + + if (def.allowedValues && !def.allowedValues.includes(value)) { + return [ + { + code: "frontmatter-invalid-value", + message: `'${keyName}' must be ${def.allowedValues.join(" or ")}`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }, + ]; + } + + return []; } - // Pattern validation - if (def.pattern && !def.pattern.test(value)) { - issues.push({ - code: "frontmatter-invalid-format", - message: def.patternError || `'${keyName}' has invalid format`, - severity: "warning", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); + + case "string": { + if (typeof value !== "string") { + return [ + { + code: "frontmatter-invalid-type", + message: `'${keyName}' must be a string`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }, + ]; + } + + const issues: Issue[] = []; + if (def.values && !def.values.includes(value)) { + issues.push({ + code: "frontmatter-invalid-value", + message: `'${keyName}' must be one of: ${def.values.join(", ")}`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + + if (def.pattern && !def.pattern.test(value)) { + issues.push({ + code: "frontmatter-invalid-format", + message: def.patternError || `'${keyName}' has invalid format`, + severity: "warning", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }); + } + + return issues; } - } else if (def.type === "object" && def.children) { - if (typeof value === "object" && value !== null) { - issues.push( - ...validateObject( - value as Record, - def.children, - keyPath, - lines, - startLine, - endLine, - ), + + case "object": { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return [ + { + code: "frontmatter-invalid-type", + message: `'${keyName}' must be an object`, + severity: "error", + range: { + startLine: line, + startColumn: 0, + endLine: line, + endColumn: lineLength, + }, + }, + ]; + } + + return validateObject( + value as Record, + def.children, + keyPath, + lines, + startLine, + endLine, ); - } else if (typeof value !== "boolean") { - // Allow boolean false for properties like titlePrefix that can be disabled - // But report error for other non-object types (strings, numbers) - issues.push({ - code: "frontmatter-invalid-type", - message: `'${keyName}' must be an object or false`, - severity: "error", - range: { - startLine: line, - startColumn: 0, - endLine: line, - endColumn: lineLength, - }, - }); } } - - return issues; } /** @@ -386,7 +372,7 @@ function validateValue( */ function validateObject( obj: Record, - schema: Record, + schema: FrontmatterSchema, parentPath: string[], lines: string[], startLine: number, @@ -626,7 +612,7 @@ export function validateFrontmatter(lines: string[]): Issue[] { issues.push( ...validateObject( parsed as Record, - FRONTMATTER_SCHEMA, + FRONTMATTER_SPEC, [], lines, block.startLine, diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 8c4e20b..879165e 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -68,11 +68,6 @@ export function activate(context: vscode.ExtensionContext) { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const cliResult = await detectCli(workspaceFolder); - if (!cliResult.found) { - await showCliNotFoundNotification(); - return; - } - // Ask for output filename const filename = await vscode.window.showInputBox({ prompt: "Enter filename for the new slides file", @@ -112,6 +107,12 @@ export function activate(context: vscode.ExtensionContext) { }, }); } catch (error) { + if ( + error instanceof Error && + error.message.includes("Failed to spawn figdeck CLI") + ) { + await showCliNotFoundNotification(); + } vscode.window.showErrorMessage(`Failed to run figdeck init: ${error}`); } }), @@ -127,11 +128,6 @@ export function activate(context: vscode.ExtensionContext) { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const cliResult = await detectCli(workspaceFolder); - if (!cliResult.found) { - await showCliNotFoundNotification(); - return; - } - const filePath = editor.document.uri.fsPath; const outputPath = filePath.replace(/\.md$/, ".json"); @@ -154,6 +150,12 @@ export function activate(context: vscode.ExtensionContext) { }, }); } catch (error) { + if ( + error instanceof Error && + error.message.includes("Failed to spawn figdeck CLI") + ) { + await showCliNotFoundNotification(); + } vscode.window.showErrorMessage(`Failed to run figdeck build: ${error}`); } }), diff --git a/packages/vscode/src/frontmatter-spec.ts b/packages/vscode/src/frontmatter-spec.ts new file mode 100644 index 0000000..13e5ec0 --- /dev/null +++ b/packages/vscode/src/frontmatter-spec.ts @@ -0,0 +1,364 @@ +export type FrontmatterDef = + | FrontmatterStringDef + | FrontmatterNumberDef + | FrontmatterBooleanDef + | FrontmatterObjectDef + | FrontmatterOneOfDef; + +export type FrontmatterDefKind = + | "string" + | "number" + | "boolean" + | "object" + | "oneOf"; + +export type FrontmatterStringDef = { + kind: "string"; + description: string; + values?: readonly string[]; + pattern?: RegExp; + patternError?: string; +}; + +export type FrontmatterNumberDef = { + kind: "number"; + description: string; + min?: number; + max?: number; +}; + +export type FrontmatterBooleanDef = { + kind: "boolean"; + description: string; + allowedValues?: readonly boolean[]; +}; + +export type FrontmatterObjectDef = { + kind: "object"; + description: string; + children: Record; +}; + +export type FrontmatterOneOfDef = { + kind: "oneOf"; + description: string; + options: readonly FrontmatterDef[]; +}; + +const COLOR_HEX_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; +const FIGMA_URL_PATTERN = /^https:\/\/(www\.)?figma\.com\//; + +export const TRANSITION_STYLES = [ + "none", + "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", + "move-from-left", + "move-from-right", + "move-from-top", + "move-from-bottom", + "slide-out-to-left", + "slide-out-to-right", + "slide-out-to-top", + "slide-out-to-bottom", + "move-out-to-left", + "move-out-to-right", + "move-out-to-top", + "move-out-to-bottom", +] as const; + +export const TRANSITION_CURVES = [ + "ease-in", + "ease-out", + "ease-in-and-out", + "linear", + "gentle", + "quick", + "bouncy", + "slow", +] as const; + +export const TRANSITION_TIMING_TYPES = ["on-click", "after-delay"] as const; + +const TEXT_STYLE_SCHEMA: Record = { + size: { + kind: "number", + description: "Font size in pixels", + min: 1, + max: 200, + }, + color: { + kind: "string", + description: "Text color", + pattern: COLOR_HEX_PATTERN, + patternError: "Invalid color format. Use #rgb or #rrggbb", + }, + x: { kind: "number", description: "Absolute X position" }, + y: { kind: "number", description: "Absolute Y position" }, + spacing: { kind: "number", description: "Gap between items", min: 0 }, +}; + +const FONT_VARIANT_SCHEMA: Record = { + family: { kind: "string", description: "Font family name" }, + style: { kind: "string", description: 'Base style (default: "Regular")' }, + bold: { kind: "string", description: 'Bold variant (default: "Bold")' }, + italic: { kind: "string", description: 'Italic variant (default: "Italic")' }, + boldItalic: { kind: "string", description: "Bold Italic variant" }, +}; + +const FONT_DEF: FrontmatterDef = { + kind: "oneOf", + description: "Font configuration (family string or object)", + options: [ + { kind: "string", description: "Font family name" }, + { + kind: "object", + description: "Font configuration", + children: FONT_VARIANT_SCHEMA, + }, + ], +}; + +const TRANSITION_DEF: FrontmatterDef = { + kind: "oneOf", + description: "Slide transition animation", + options: [ + { + kind: "string", + description: + "Transition shorthand (e.g., dissolve or slide-from-right 0.5)", + values: TRANSITION_STYLES, + }, + { + kind: "object", + description: "Transition configuration", + children: { + style: { + kind: "string", + description: "Animation style", + values: TRANSITION_STYLES, + }, + duration: { + kind: "number", + description: "Duration in seconds (0.01-10)", + min: 0.01, + max: 10, + }, + curve: { + kind: "string", + description: "Easing curve", + values: TRANSITION_CURVES, + }, + timing: { + kind: "object", + description: "Timing configuration", + children: { + type: { + kind: "string", + description: "Timing type", + values: TRANSITION_TIMING_TYPES, + }, + delay: { + kind: "number", + description: "Auto-advance delay (seconds) 0-30", + min: 0, + max: 30, + }, + }, + }, + }, + }, + ], +}; + +export const FRONTMATTER_SPEC: Record = { + figdeck: { + kind: "boolean", + description: "Enable figdeck processing for this file", + }, + background: { + kind: "string", + description: "Solid background color (e.g., #1a1a2e)", + pattern: COLOR_HEX_PATTERN, + patternError: "Invalid color format. Use #rgb or #rrggbb", + }, + gradient: { + kind: "string", + description: + "Gradient background (e.g., #0d1117:0%,#1f2937:50%,#58a6ff:100%@45)", + pattern: /^#[0-9a-fA-F]{3,6}:\d+%/, + patternError: "Invalid gradient format. Use #color:0%,#color:100%[@angle]", + }, + backgroundImage: { + kind: "string", + description: "Background image path or URL", + }, + template: { + kind: "string", + description: "Figma paint style name", + }, + color: { + kind: "string", + description: "Base text color for all elements", + pattern: COLOR_HEX_PATTERN, + patternError: "Invalid color format. Use #rgb or #rrggbb", + }, + align: { + kind: "string", + description: "Horizontal alignment", + values: ["left", "center", "right"], + }, + valign: { + kind: "string", + description: "Vertical alignment", + values: ["top", "middle", "bottom"], + }, + headings: { + kind: "object", + description: "Heading styles configuration", + children: { + h1: { + kind: "object", + description: "H1 heading style", + children: TEXT_STYLE_SCHEMA, + }, + h2: { + kind: "object", + description: "H2 heading style", + children: TEXT_STYLE_SCHEMA, + }, + h3: { + kind: "object", + description: "H3 heading style", + children: TEXT_STYLE_SCHEMA, + }, + h4: { + kind: "object", + description: "H4 heading style", + children: TEXT_STYLE_SCHEMA, + }, + }, + }, + paragraphs: { + kind: "object", + description: "Paragraph style configuration", + children: TEXT_STYLE_SCHEMA, + }, + bullets: { + kind: "object", + description: "Bullet list style configuration", + children: TEXT_STYLE_SCHEMA, + }, + code: { + kind: "object", + description: "Code block style configuration", + children: TEXT_STYLE_SCHEMA, + }, + fonts: { + kind: "object", + description: "Custom font configuration", + children: { + h1: FONT_DEF, + h2: FONT_DEF, + h3: FONT_DEF, + h4: FONT_DEF, + body: FONT_DEF, + bullets: FONT_DEF, + code: FONT_DEF, + }, + }, + slideNumber: { + kind: "oneOf", + description: "Slide number configuration", + options: [ + { + kind: "boolean", + description: "Boolean shorthand (true = show, false = hide)", + allowedValues: [true, false], + }, + { + kind: "object", + description: "Slide number configuration", + children: { + show: { kind: "boolean", description: "Show/hide slide numbers" }, + position: { + kind: "string", + description: "Position of slide number", + values: ["bottom-right", "bottom-left", "top-right", "top-left"], + }, + size: { + kind: "number", + description: "Font size in pixels", + min: 1, + max: 200, + }, + color: { + kind: "string", + description: "Text color", + pattern: COLOR_HEX_PATTERN, + patternError: "Invalid color format. Use #rgb or #rrggbb", + }, + paddingX: { kind: "number", description: "Horizontal padding" }, + paddingY: { kind: "number", description: "Vertical padding" }, + format: { + kind: "string", + description: 'Display format (e.g., "{{current}} / {{total}}")', + }, + link: { + kind: "string", + description: "Custom Frame design Figma link", + pattern: FIGMA_URL_PATTERN, + patternError: "Must be a valid Figma URL", + }, + nodeId: { kind: "string", description: "Custom Frame node-id" }, + startFrom: { + kind: "number", + description: "Start showing from slide N", + min: 1, + }, + offset: { + kind: "number", + description: "Number to add to displayed slide number", + }, + }, + }, + ], + }, + titlePrefix: { + kind: "oneOf", + description: "Title prefix component configuration", + options: [ + { + kind: "boolean", + description: "Disable title prefix", + allowedValues: [false], + }, + { + kind: "object", + description: "Title prefix component configuration", + children: { + link: { + kind: "string", + description: "Figma component link", + pattern: FIGMA_URL_PATTERN, + patternError: "Must be a valid Figma URL", + }, + nodeId: { kind: "string", description: "Figma node-id" }, + spacing: { + kind: "number", + description: "Gap between prefix and title", + min: 0, + }, + }, + }, + ], + }, + transition: TRANSITION_DEF, +}; diff --git a/packages/vscode/src/frontmatter-utils.ts b/packages/vscode/src/frontmatter-utils.ts new file mode 100644 index 0000000..ceb3ce9 --- /dev/null +++ b/packages/vscode/src/frontmatter-utils.ts @@ -0,0 +1,26 @@ +/** + * Check if lines look like implicit frontmatter (YAML key/value pairs). + */ +export function looksLikeInlineFrontmatter(lines: readonly string[]): boolean { + let sawKey = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^[a-zA-Z][\w-]*:\s*/.test(trimmed)) { + sawKey = true; + continue; + } + if (/^\s+/.test(line) && sawKey) { + continue; + } + return false; + } + return sawKey; +} + +/** + * Check if lines array has meaningful content (non-empty lines). + */ +export function hasMeaningfulContent(lines: readonly string[]): boolean { + return lines.some((line) => line.trim() !== ""); +} diff --git a/packages/vscode/src/ops/cli-runner.test.ts b/packages/vscode/src/ops/cli-runner.test.ts index 297a3a5..2c5b7d7 100644 --- a/packages/vscode/src/ops/cli-runner.test.ts +++ b/packages/vscode/src/ops/cli-runner.test.ts @@ -4,7 +4,6 @@ import { type CliDetectionResult, runCli } from "./cli-runner"; describe("runCli", () => { it("streams stdout/stderr and reports exit code", async () => { const cliResult: CliDetectionResult = { - found: true, command: [process.execPath], source: "config", }; @@ -39,7 +38,6 @@ describe("runCli", () => { if (process.platform === "win32") return; const cliResult: CliDetectionResult = { - found: true, command: [`definitely-not-a-command-${Date.now()}`], source: "config", }; diff --git a/packages/vscode/src/ops/cli-runner.ts b/packages/vscode/src/ops/cli-runner.ts index f081342..2f8e662 100644 --- a/packages/vscode/src/ops/cli-runner.ts +++ b/packages/vscode/src/ops/cli-runner.ts @@ -4,7 +4,6 @@ import { type ChildProcess, spawn } from "node:child_process"; * Result of CLI detection */ export interface CliDetectionResult { - found: boolean; command: string[]; source: "workspace" | "path" | "config" | "none"; } @@ -49,11 +48,10 @@ export async function runCli( cliResult: CliDetectionResult, options: RunCliOptions, ): Promise { - if (!cliResult.found) { - throw new Error("figdeck CLI not found"); - } - const [cmd, ...baseArgs] = cliResult.command; + if (!cmd) { + throw new Error("figdeck CLI command is empty"); + } const args = [...baseArgs, ...options.args]; let proc: ChildProcess; diff --git a/packages/vscode/src/ops/figdeckCli.ts b/packages/vscode/src/ops/figdeckCli.ts index 91fbad8..34208ec 100644 --- a/packages/vscode/src/ops/figdeckCli.ts +++ b/packages/vscode/src/ops/figdeckCli.ts @@ -28,7 +28,6 @@ export async function detectCli( ); if (fs.existsSync(localBin)) { return { - found: true, command: [localBin], source: "workspace", }; @@ -41,7 +40,6 @@ export async function detectCli( const which = process.platform === "win32" ? "where" : "which"; execSync(`${which} figdeck`, { stdio: "ignore" }); return { - found: true, command: ["figdeck"], source: "path", }; @@ -54,7 +52,6 @@ export async function detectCli( const configCommand = config.get("command"); if (configCommand && configCommand.length > 0) { return { - found: true, command: configCommand, source: "config", }; @@ -62,7 +59,6 @@ export async function detectCli( // 4. Fallback to npx figdeck@latest return { - found: true, command: ["npx", "figdeck@latest"], source: "none", }; diff --git a/packages/vscode/src/ops/serverManager.ts b/packages/vscode/src/ops/serverManager.ts index 6c27ad9..c38330a 100644 --- a/packages/vscode/src/ops/serverManager.ts +++ b/packages/vscode/src/ops/serverManager.ts @@ -67,11 +67,6 @@ export class ServerManager implements vscode.Disposable { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const cliResult = await detectCli(workspaceFolder); - if (!cliResult.found) { - await showCliNotFoundNotification(); - return; - } - // Get config const config = vscode.workspace.getConfiguration("figdeck.serve"); const host = config.get("host", "127.0.0.1"); @@ -162,6 +157,12 @@ export class ServerManager implements vscode.Disposable { } catch (error) { this.outputChannel.appendLine(`[figdeck] Error: ${error}`); this.setState("error"); + if ( + error instanceof Error && + error.message.includes("Failed to spawn figdeck CLI") + ) { + await showCliNotFoundNotification(); + } vscode.window.showErrorMessage(`Failed to start figdeck serve: ${error}`); } }