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/.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/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/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/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/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/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/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 プラグインをリアルタイムで更新します。 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/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/images/icon.png b/packages/vscode/images/icon.png new file mode 100644 index 0000000..1d4ccc8 Binary files /dev/null and b/packages/vscode/images/icon.png differ diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..d7495d7 --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,198 @@ +{ + "name": "figdeck-vscode", + "displayName": "figdeck", + "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", + "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" + }, + { + "language": "yaml", + "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.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, + "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 --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": { + "@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..ce78a68 --- /dev/null +++ b/packages/vscode/src/authoring/frontmatterCompletion.ts @@ -0,0 +1,259 @@ +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; +} + +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; + } + + 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 + */ +function isInFrontmatter( + document: vscode.TextDocument, + position: vscode.Position, +): boolean { + // 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; + + 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; + } + + if (codeFence !== null) { + currentLines.push(lineText); + continue; + } + + if (trimmed === "---") { + if (inFencedFrontmatter) { + currentLines.push(lineText); + inFencedFrontmatter = false; + continue; + } + + if (!hasMeaningfulContent(currentLines)) { + inFencedFrontmatter = true; + currentLines.push(lineText); + continue; + } + + // Implicit frontmatter closer (no opening fence, only key/value lines so far) + if (looksLikeInlineFrontmatter(currentLines)) { + currentLines.push(lineText); + continue; + } + + // Slide separator: start a new slide context + currentLines = []; + inFencedFrontmatter = false; + continue; + } + + currentLines.push(lineText); + } + + if (inFencedFrontmatter) { + return true; + } + + return looksLikeInlineFrontmatter(currentLines); +} + +/** + * 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 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 def = getDefAtPath([...parentKeys, key]); + if (!def) return []; + + return getCompletionValues(def).map((value) => { + const item = new vscode.CompletionItem( + value, + vscode.CompletionItemKind.Value, + ); + return item; + }); + } +} + +/** + * 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..29f89c2 --- /dev/null +++ b/packages/vscode/src/authoring/slideParser.ts @@ -0,0 +1,279 @@ +import { + hasMeaningfulContent, + looksLikeInlineFrontmatter, +} from "../frontmatter-utils"; + +/** + * Represents a slide in the outline + */ +export interface SlideInfo { + index: number; + title: string; + startLine: number; + endLine: number; +} + +/** + * 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; + + 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 = []; + return; + } + + 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..49880fb --- /dev/null +++ b/packages/vscode/src/diagnostics/analyzer.test.ts @@ -0,0 +1,985 @@ +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/); +} + +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 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", + "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"); + }); +}); + +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 new file mode 100644 index 0000000..ff7fecf --- /dev/null +++ b/packages/vscode/src/diagnostics/analyzer.ts @@ -0,0 +1,781 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type * as vscode from "vscode"; +import { parse as parseYaml } from "yaml"; +import { + extractFrontmatterBlocks, + validateFrontmatter, +} from "./frontmatterValidator"; +import type { AnalysisResult, Issue } from "./types"; + +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< + string, + { expiresAt: number; stat: CachedFileStat } +>(); + +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 fsStat = await fs.promises.stat(filePath); + stat = { exists: true, isFile: fsStat.isFile(), size: fsStat.size }; + } catch { + stat = { exists: false, isFile: false, size: 0 }; + } + + 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 { + 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; +} + +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 + */ +export async function analyzeDocument( + document: vscode.TextDocument, + basePath: string, + options?: AnalyzeDocumentOptions, +): Promise { + 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( + ...(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)); + + 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; +} + +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 + */ +export async function analyzeImages( + lines: string[], + basePath: string, + documentUri: vscode.Uri, + options?: AnalyzeImagesOptions, +): Promise { + const issues: Issue[] = []; + const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)/g; + const maxSizeMb = resolveMaxSizeMb(options); + + for (const { line, index: i } of iterateLinesOutsideCodeFences(lines)) { + // 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, + )) { + 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; + } + } + + // 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; +} + +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 + */ +export function analyzeFigmaBlocks(lines: string[]): Issue[] { + const issues: Issue[] = []; + + let inFigmaBlock = false; + let figmaBlockStart = -1; + let hasLink = false; + let linkValue = ""; + let linkLine = -1; + + for (const { trimmed, index: i } of iterateLinesOutsideCodeFences(lines)) { + 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].trim(); + if (value && !isValidNumberOrPercentage(value)) { + issues.push({ + severity: "warning", + message: `Invalid ${prop} value: expected number or percentage`, + range: { + startLine: i, + startColumn: 0, + endLine: i, + endColumn: lines[i].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 inColumnsBlock = false; + let columnsBlockStart = -1; + let columnCount = 0; + let columnsParams = ""; + let columnsParamsLine = -1; + + for (const { trimmed, index: i } of iterateLinesOutsideCodeFences(lines)) { + 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..26901ff --- /dev/null +++ b/packages/vscode/src/diagnostics/codeActions.ts @@ -0,0 +1,410 @@ +import * as vscode from "vscode"; +import { + TRANSITION_CURVES, + TRANSITION_STYLES, + TRANSITION_TIMING_TYPES, +} from "../frontmatter-spec"; + +/** + * 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); + case "frontmatter-invalid-format": + return this.createColorNormalizationFix(document, diagnostic); + case "frontmatter-invalid-value": + return this.createTransitionNormalizationFix(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; + } + + /** + * 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; +} + +function isOneOf( + options: T, + value: string, +): value is T[number] { + return (options as readonly string[]).includes(value); +} + +/** + * 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 (isOneOf(TRANSITION_STYLES, normalized)) { + return normalized; + } + // Try fuzzy matching for common mistakes + const closest = findClosestMatch(normalized, TRANSITION_STYLES); + return closest; + } + + if (key === "curve") { + if (isOneOf(TRANSITION_CURVES, 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 (isOneOf(TRANSITION_TIMING_TYPES, 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, TRANSITION_TIMING_TYPES); + return closest; + } + + return null; +} + +/** + * Find closest matching string using Levenshtein distance + */ +function findClosestMatch( + value: string, + candidates: readonly 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]; +} + +/** + * 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..b99d63b --- /dev/null +++ b/packages/vscode/src/diagnostics/frontmatterValidator.ts @@ -0,0 +1,642 @@ +import { parse as parseYaml } from "yaml"; +import { + FRONTMATTER_SPEC, + type FrontmatterDef, + TRANSITION_STYLES, +} from "../frontmatter-spec"; +import type { Issue } from "./types"; + +type FrontmatterSchema = Record; + +function countErrors(issues: Issue[]): number { + return issues.filter((issue) => issue.severity === "error").length; +} + +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, + }, + }); + 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; + } + + 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, + }, + }); + } + } + + return issues; +} + +/** + * 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: FrontmatterDef, + keyPath: string[], + lines: string[], + startLine: number, + endLine: number, +): Issue[] { + const line = findKeyLine(lines, startLine, endLine, keyPath); + const keyName = keyPath.join("."); + const lineLength = lines[line]?.length ?? 100; + + if (keyPath.length === 1 && keyPath[0] === "transition") { + if (typeof value === "string") { + return validateTransitionShorthand(value, line, 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, + }, + }, + ]; + } + + 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; + } + } + + 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; + } + + 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 []; + } + + 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; + } + + 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, + ); + } + } +} + +/** + * Validate an object against a schema + */ +function validateObject( + obj: Record, + schema: FrontmatterSchema, + 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) + * @internal Exported for use by analyzer + */ +export 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_SPEC, + [], + 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..879165e --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,219 @@ +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( + (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); + + // 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); + + // 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) { + if ( + error instanceof Error && + error.message.includes("Failed to spawn figdeck CLI") + ) { + await showCliNotFoundNotification(); + } + 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); + + 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) { + if ( + error instanceof Error && + error.message.includes("Failed to spawn figdeck CLI") + ) { + await showCliNotFoundNotification(); + } + 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/extensionManifest.test.ts b/packages/vscode/src/extensionManifest.test.ts new file mode 100644 index 0000000..445124a --- /dev/null +++ b/packages/vscode/src/extensionManifest.test.ts @@ -0,0 +1,88 @@ +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/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 new file mode 100644 index 0000000..2c5b7d7 --- /dev/null +++ b/packages/vscode/src/ops/cli-runner.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { type CliDetectionResult, runCli } from "./cli-runner"; + +describe("runCli", () => { + it("streams stdout/stderr and reports exit code", async () => { + const cliResult: CliDetectionResult = { + 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 = { + 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..2f8e662 --- /dev/null +++ b/packages/vscode/src/ops/cli-runner.ts @@ -0,0 +1,108 @@ +import { type ChildProcess, spawn } from "node:child_process"; + +/** + * Result of CLI detection + */ +export interface CliDetectionResult { + 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 { + const [cmd, ...baseArgs] = cliResult.command; + if (!cmd) { + throw new Error("figdeck CLI command is empty"); + } + 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 new file mode 100644 index 0000000..34208ec --- /dev/null +++ b/packages/vscode/src/ops/figdeckCli.ts @@ -0,0 +1,92 @@ +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"; + +/** + * 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 { + 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 { + 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 { + command: configCommand, + source: "config", + }; + } + + // 4. Fallback to npx figdeck@latest + return { + command: ["npx", "figdeck@latest"], + source: "none", + }; +} + +/** + * 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..c38330a --- /dev/null +++ b/packages/vscode/src/ops/serverManager.ts @@ -0,0 +1,297 @@ +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); + + // Get config + 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; + 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)]; + + // 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, + 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}`); + // 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( + `\n[figdeck] Process exited with code ${code}`, + ); + this.process = null; + if (this.state === "stopped") return; + 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"); + if ( + error instanceof Error && + error.message.includes("Failed to spawn figdeck CLI") + ) { + await showCliNotFoundNotification(); + } + 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..e556888 --- /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.block.frontmatter.figdeck", + "begin": "^(---)[\\t ]*$", + "beginCaptures": { + "1": { "name": "punctuation.definition.frontmatter.begin.figdeck" } + }, + "end": "^(---)[\\t ]*$|^(?=\\S)(?![\\t ]*[a-zA-Z_][a-zA-Z0-9_-]*:)(?![\\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"] +}