diff --git a/.gitignore b/.gitignore index 0457094..ae883ed 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ logs .vscode .history + +# Svelte +.svelte-kit diff --git a/AGENTS.md b/AGENTS.md index 1ea725a..ff656ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This is a **monorepo** containing multiple packages related to Comark (Component - Fast synchronous and async parsing via markdown-it - Streaming support for real-time/incremental parsing -- Vue and React renderers +- Vue, React and Svelte renderers - Syntax highlighting via Shiki - Auto-close utilities for incomplete markdown (useful for AI streaming) @@ -21,7 +21,8 @@ This is a **monorepo** containing multiple packages related to Comark (Component ├── packages/ # All publishable packages │ ├── comark/ # Main Comark parser package │ ├── comark-cjk/ # CJK support plugin (@comark/cjk) -│ └── comark-math/ # Math formula support (@comark/math) +│ ├── comark-math/ # Math formula support (@comark/math) +│ └── comark-svelte/ # Svelte renderer (@comark/svelte) ├── examples/ # Example applications │ ├── vue-vite/ # Vue + Vite + Tailwind CSS v4 │ ├── react-vite/ # React 19 + Vite + Tailwind CSS v4 @@ -197,6 +198,58 @@ $$ - Vue and React components for easy integration - Automatic tokenization at parse time (not render time) for performance +## Package: @comark/svelte + +Svelte 5 renderer for Comark. Located at `packages/comark-svelte/`: + +``` +packages/comark-svelte/ +├── src/ +│ ├── index.ts # Entry point (@comark/svelte) +│ ├── types.ts # Shared prop interfaces +│ ├── Comark.svelte # High-level markdown → render ($state + $effect) +│ ├── ComarkAsync.svelte # High-level markdown → render (experimental await) +│ ├── ComarkRenderer.svelte # Low-level AST → render component +│ └── ComarkNode.svelte # Recursive AST node renderer +├── svelte.config.js # Svelte config (experimental.async enabled) +├── vitest.config.ts # Dual test config (server + client browser) +├── tsconfig.json +└── package.json +``` + +### Build + +Uses `@sveltejs/package` (`svelte-package`) — the standard Svelte library packaging tool. Ships `.svelte` source files (compiled by consumer's bundler) with `.d.ts` type definitions generated via `svelte2tsx`. + +### Testing + +Uses Vitest with two test projects: +- **`server`**: Node environment, `*.test.ts` files — SSR tests using `svelte/server` `render()` +- **`client`**: Browser environment (Playwright/Chromium), `*.svelte.test.ts` files — real DOM tests using `vitest-browser-svelte` + +### Usage + +**Manual state (stable API)**: +```svelte + + +``` + +**Experimental async** (requires `experimental.async` in Svelte config): +```svelte + + + + {#snippet pending()} +

Loading...

+ {/snippet} +
+``` + ## Package Exports ```typescript @@ -216,6 +269,12 @@ import { Comark } from 'comark/vue' // React components import { Comark } from 'comark/react' + +// Svelte components +import { Comark, ComarkRenderer } from '@comark/svelte' +import { ComarkAsync } from '@comark/svelte/async' // requires experimental.async +import { math, Math } from '@comark/svelte/plugin-math' +import { mermaid, Mermaid } from '@comark/svelte/plugin-mermaid' ``` ## Coding Principles @@ -250,7 +309,7 @@ const matches = line.match(/\*+/g) // Don't do this 1. Keep internal implementation in `packages/comark/src/internal/` (parsing in `internal/parse/`, stringification in `internal/stringify/`) 2. AST types and utilities in `packages/comark/src/ast/` -3. Framework-specific code in `packages/comark/src/vue/` and `packages/comark/src/react/` +3. Framework-specific code in `packages/comark/src/vue/`, `packages/comark/src/react/`, and `packages/comark-svelte/src/` 4. Export public APIs from entry points (`index.ts`, `ast/index.ts`) 5. Document exported functions with JSDoc including `@example` @@ -351,7 +410,7 @@ Example: } ``` -## Vue/React Components +## Vue/React/Svelte Components ### Comark Component (High-level) @@ -371,6 +430,31 @@ Accepts markdown string, handles parsing internally. {content} ``` +**Svelte** (manual state — stable API, uses `$state` + `$effect`): + +```svelte + + + +``` + +**Svelte** (experimental async — requires `experimental.async` in Svelte config): + +```svelte + + + + + {#snippet pending()} +

Loading...

+ {/snippet} +
+``` + ## Common Tasks ### Adding a new utility function @@ -390,7 +474,8 @@ Accepts markdown string, handles parsing internally. 1. Vue components in `packages/comark/src/vue/components/` 2. React components in `packages/comark/src/react/components/` -3. Both should have similar APIs for consistency +3. Svelte components in `packages/comark-svelte/src/` +4. All three should have similar APIs for consistency ### Adding a new package diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts index e3cd7a3..3d56c2c 100644 --- a/docs/app/app.config.ts +++ b/docs/app/app.config.ts @@ -1,7 +1,7 @@ export default defineAppConfig({ seo: { title: 'Comark', - description: 'Components in Markdown (Comark) parser with streaming support for Vue and React.', + description: 'Components in Markdown (Comark) parser with streaming support for Vue, React and Svelte.', url: 'https://comark.dev', socials: { github: 'comarkdown/comark', @@ -12,7 +12,7 @@ export default defineAppConfig({ }, title: 'Comark', - description: 'Components in Markdown (Comark) parser with streaming support for Vue and React.', + description: 'Components in Markdown (Comark) parser with streaming support for Vue, React and Svelte.', url: 'https://comark.dev', ui: { @@ -32,6 +32,7 @@ export default defineAppConfig({ 'md': 'i-custom-comark', 'react': 'i-logos-react', 'html': 'i-vscode-icons-file-type-html', + 'svelte': 'i-simple-icons-svelte', }, }, }, diff --git a/docs/content/2.vite/svelte-vite/README.md b/docs/content/2.vite/svelte-vite/README.md new file mode 100644 index 0000000..66f870d --- /dev/null +++ b/docs/content/2.vite/svelte-vite/README.md @@ -0,0 +1,139 @@ +--- +title: Svelte +category: Vite +description: A minimal example showing how to use Comark with Svelte and Vite. +navigation: + icon: i-simple-icons-svelte +--- + +::code-tree{expand-all default-value="src/App.svelte"} +```ts [src/main.ts] +import { mount } from 'svelte' +import App from './App.svelte' + +mount(App, { + target: document.getElementById('app')!, +}) +``` + +```svelte [src/App.svelte] + + + +``` + +```svelte [src/components/Alert.svelte] + + + +``` + +```ts [vite.config.ts] +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + svelte(), + tailwindcss(), + ], +}) +``` + +```json [package.json] +{ + "name": "comark-svelte-vite", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@comark/svelte": "workspace:*", + "@tailwindcss/vite": "^4.2.0", + "comark": "workspace:*" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.53.7", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} +``` + +```html [index.html] + + + + + + Comark - Svelte Example + + +
+ + + +``` + +```json [tsconfig.json] +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} +``` +:: + +This example demonstrates the simplest way to use Comark with Svelte - use the `Comark` component and pass it markdown content. The component handles parsing and rendering automatically using Svelte 5's `$state` and `$effect` runes. diff --git a/docs/content/3.rendering/1.vue.md b/docs/content/3.rendering/1.vue.md index 1792153..8257d54 100644 --- a/docs/content/3.rendering/1.vue.md +++ b/docs/content/3.rendering/1.vue.md @@ -10,6 +10,11 @@ links: to: /rendering/react color: neutral variant: soft + - label: Svelte Rendering + icon: i-simple-icons-svelte + to: /rendering/svelte + color: neutral + variant: soft - label: Streaming icon: i-lucide-radio to: /rendering/streaming diff --git a/docs/content/3.rendering/2.react.md b/docs/content/3.rendering/2.react.md index f7faac8..a9a58d2 100644 --- a/docs/content/3.rendering/2.react.md +++ b/docs/content/3.rendering/2.react.md @@ -10,6 +10,11 @@ links: to: /rendering/vue color: neutral variant: soft + - label: Svelte Rendering + icon: i-simple-icons-svelte + to: /rendering/svelte + color: neutral + variant: soft - label: Streaming icon: i-lucide-radio to: /rendering/streaming diff --git a/docs/content/3.rendering/3.svelte.md b/docs/content/3.rendering/3.svelte.md new file mode 100644 index 0000000..3967f44 --- /dev/null +++ b/docs/content/3.rendering/3.svelte.md @@ -0,0 +1,307 @@ +--- +title: Render Comark in Svelte +description: Learn how to render Comark in a Svelte 5 application with custom components, plugins, and streaming support. +navigation: + title: Svelte + icon: i-simple-icons-svelte +links: + - label: Vue Rendering + icon: i-simple-icons-vuedotjs + to: /rendering/vue + color: neutral + variant: soft + - label: React Rendering + icon: i-lucide-atom + to: /rendering/react + color: neutral + variant: soft + - label: Streaming + icon: i-lucide-radio + to: /rendering/streaming + color: neutral + variant: soft +--- + +The `@comark/svelte` package provides two high-level components for rendering markdown in Svelte 5: + +- **``** — Uses `$state` + `$effect` for async parsing. No experimental features required. +- **``** — Uses Svelte's experimental `await` support with ``. Cleaner, but requires `experimental.async` in your Svelte config. + +Both components accept the same props and produce the same output. + +### Installation + +```bash +npm install @comark/svelte +``` + +### Basic Usage + +```svelte [App.svelte] + + + +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `markdown` | `string` | `''` | Markdown content to parse and render | +| `options` | `ParseOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | +| `plugins` | `ComarkPlugin[]` | `[]` | Array of plugins (highlight, emoji, toc, etc.) | +| `components` | `Record` | `{}` | Custom Svelte component mappings | +| `componentsManifest` | `ComponentManifest` | `undefined` | Dynamic component resolver function | +| `streaming` | `boolean` | `false` | Enable streaming mode | +| `caret` | `boolean \| { class: string }` | `false` | Append caret to last text node | +| `class` | `string` | `''` | CSS class for wrapper element | + +### With Parser Options + +Use the `options` prop to configure parser behavior: + +```svelte [App.svelte] + + + +``` + +### With Plugins + +Use the `plugins` prop to add functionality like syntax highlighting, emoji support, or table of contents: + +```svelte [App.svelte] + + + +``` + +### With Math Plugin + +The `@comark/svelte/plugin-math` subpath bundles the math plugin and a Svelte rendering component: + +```svelte [App.svelte] + + + +``` + +### With Mermaid Plugin + +The `@comark/svelte/plugin-mermaid` subpath provides Mermaid diagram support: + +```svelte [App.svelte] + + + +``` + +### With Custom Components + +Map Svelte components to Comark elements using the `components` prop. Components are resolved by: +1. `Prose{PascalTag}` — e.g., `ProseH1` for `

` elements +2. `PascalTag` — e.g., `Alert` for `::alert` components +3. `tag` — e.g., `alert` for `::alert` components + +```svelte [App.svelte] + + + +``` + +A custom component receives the AST node's attributes as props and its children as a Svelte `children` snippet: + +```svelte [Alert.svelte] + + + +``` + +#### Overriding Native HTML Elements + +Use the `Prose` prefix to override how native HTML elements render. For example, to customize all `

` elements: + +```svelte [ProseH1.svelte] + + +

+ {@render children?.()} +

+``` + +```svelte [App.svelte] + +``` + +## Experimental Async (`ComarkAsync`) + +The `ComarkAsync` component uses Svelte's experimental `await` in `$derived` for a more declarative approach. This requires `experimental.async` in your Svelte config: + +```js [svelte.config.js] +const config = { + compilerOptions: { + experimental: { + async: true, + }, + }, +} + +export default config +``` + +Wrap `ComarkAsync` in a `` to handle loading and error states: + +```svelte [App.svelte] + + + + + {#snippet pending()} +

Loading...

+ {/snippet} + {#snippet failed(error, reset)} +

Error: {error.message}

+ + {/snippet} +
+``` + +::callout{icon="i-lucide-triangle-alert" color="warning"} +The `experimental.async` feature is still experimental in Svelte 5. For production use, prefer the stable `` component. +:: + +## Low-Level Rendering + +For more control, use `ComarkRenderer` to render a pre-parsed AST tree: + +```svelte [App.svelte] + + +{#if tree} + +{/if} +``` + +## Streaming + +Enable streaming mode to render content as it arrives, with a blinking caret indicator: + +```svelte [AiChat.svelte] + + + +``` + +::callout{icon="i-lucide-info" color="info"} +`autoClose` is enabled by default — incomplete syntax like `**bold text` is automatically closed on every parse. The caret is only visible while `streaming` is `true`. +:: + +Customize the caret with a CSS class: + +```svelte + +``` + +For more details on streaming, see the [Streaming guide](/rendering/streaming). + +--- + +## Next Steps + +- [Streaming](/rendering/streaming) - Real-time incremental rendering with auto-close +- [Vue Rendering](/rendering/vue) - Full Vue component API +- [React Rendering](/rendering/react) - Full React component API +- [Parse API](/api/parse) - Parser options and configuration diff --git a/docs/content/3.rendering/3.streaming.md b/docs/content/3.rendering/4.streaming.md similarity index 87% rename from docs/content/3.rendering/3.streaming.md rename to docs/content/3.rendering/4.streaming.md index bb91e17..7bc5071 100644 --- a/docs/content/3.rendering/3.streaming.md +++ b/docs/content/3.rendering/4.streaming.md @@ -112,6 +112,40 @@ export default function AiChat() { } ``` +## Svelte Streaming + +```svelte [components/AiChat.svelte] + + + +``` + ## Caret Indicator The `caret` prop appends a blinking cursor to the last text node, giving visual feedback that content is still arriving: @@ -217,4 +251,5 @@ When calling `autoCloseMarkdown` manually, pass `autoClose: false` to `parse` to - [Auto-Close API](/api/auto-close) - Detailed auto-close function reference - [Vue Rendering](/rendering/vue) - Full Vue component API - [React Rendering](/rendering/react) - Full React component API +- [Svelte Rendering](/rendering/svelte) - Full Svelte component API - [Parse API](/api/parse) - Parser options and configuration diff --git a/docs/content/4.plugins/2.external/10.mermaid.md b/docs/content/4.plugins/2.external/10.mermaid.md index 80d95e2..4b28ced 100644 --- a/docs/content/4.plugins/2.external/10.mermaid.md +++ b/docs/content/4.plugins/2.external/10.mermaid.md @@ -92,6 +92,24 @@ function App() { } ``` +### With Svelte + +```svelte [App.svelte] + + + +``` + ### With Parse API ```typescript diff --git a/docs/content/4.plugins/2.external/11.math.md b/docs/content/4.plugins/2.external/11.math.md index b86d3d8..4b310c8 100644 --- a/docs/content/4.plugins/2.external/11.math.md +++ b/docs/content/4.plugins/2.external/11.math.md @@ -96,6 +96,25 @@ function App() { } ``` +### With Svelte + +```svelte [App.svelte] + + + +``` + ### With Parse API ```typescript [parse.ts] diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts index ab68a7d..23a6245 100644 --- a/docs/nuxt.config.ts +++ b/docs/nuxt.config.ts @@ -23,7 +23,7 @@ export default defineNuxtConfig({ build: { markdown: { highlight: { - langs: ['tsx', 'vue', 'html', 'css', 'json', 'markdown', 'bash', 'shell', 'astro'], + langs: ['tsx', 'svelte', 'vue', 'html', 'css', 'json', 'markdown', 'bash', 'shell', 'astro'], }, }, }, diff --git a/eslint.config.mjs b/eslint.config.mjs index e13d87f..dfbc76e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,8 @@ // @ts-check import { createConfigForNuxt } from '@nuxt/eslint-config/flat' +import svelte from 'eslint-plugin-svelte' +import stylistic from '@stylistic/eslint-plugin' +import svelteConfig from './packages/comark-svelte/svelte.config.js' // Run `npx @eslint/config-inspector` to inspect the resolved config interactively export default createConfigForNuxt({ @@ -10,6 +13,36 @@ export default createConfigForNuxt({ stylistic: true, }, }) + .append( + ...svelte.configs.recommended, + ) + .append( + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + svelteConfig, + }, + }, + }, + ) + .append( + { + // Apply stylistic rules to Svelte files (nuxt config only targets js/ts/vue) + files: ['**/*.svelte'], + plugins: { + '@stylistic': stylistic, + }, + rules: { + ...stylistic.configs.recommended.rules, + '@stylistic/brace-style': ['error', 'stroustrup', { allowSingleLine: true }], + '@stylistic/comma-dangle': ['error', 'always-multiline'], + '@stylistic/indent': ['error', 2], + '@stylistic/quotes': ['error', 'single'], + '@stylistic/semi': ['error', 'never'], + }, + }, + ) .append( { rules: { diff --git a/examples/2.vite/svelte-vite/README.md b/examples/2.vite/svelte-vite/README.md new file mode 100644 index 0000000..3016f4b --- /dev/null +++ b/examples/2.vite/svelte-vite/README.md @@ -0,0 +1,141 @@ +--- +title: Svelte +description: A minimal example showing how to use Comark with Svelte and Vite. +navigation.icon: i-simple-icons-svelte +category: Vite +path: /examples/vite/svelte-vite +--- + +::code-tree{defaultValue="src/App.svelte" expandAll} + +```ts [src/main.ts] +import { mount } from 'svelte' +import App from './App.svelte' + +mount(App, { + target: document.getElementById('app')!, +}) +``` + +```svelte [src/App.svelte] + + + +``` + +```svelte [src/components/Alert.svelte] + + + +``` + +```ts [vite.config.ts] +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + svelte(), + tailwindcss(), + ], +}) +``` + +```json [package.json] +{ + "name": "comark-svelte-vite", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@comark/svelte": "workspace:*", + "@tailwindcss/vite": "^4.2.0", + "comark": "workspace:*" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.53.7", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} +``` + +```html [index.html] + + + + + + Comark - Svelte Example + + +
+ + + +``` + +```json [tsconfig.json] +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} +``` + +:: + +This example demonstrates the simplest way to use Comark with Svelte - use the `Comark` component and pass it markdown content. The component handles parsing and rendering automatically using Svelte 5's `$state` and `$effect` runes. diff --git a/examples/2.vite/svelte-vite/index.html b/examples/2.vite/svelte-vite/index.html new file mode 100644 index 0000000..610008d --- /dev/null +++ b/examples/2.vite/svelte-vite/index.html @@ -0,0 +1,13 @@ + + + + + + Comark - Svelte Example + + + +
+ + + diff --git a/examples/2.vite/svelte-vite/package.json b/examples/2.vite/svelte-vite/package.json new file mode 100644 index 0000000..4960e24 --- /dev/null +++ b/examples/2.vite/svelte-vite/package.json @@ -0,0 +1,24 @@ +{ + "name": "comark-svelte-vite", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@comark/svelte": "workspace:*", + "@tailwindcss/vite": "^4.2.0", + "comark": "workspace:*" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "autoprefixer": "^10.4.24", + "postcss": "^8.5.6", + "svelte": "^5.53.7", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/examples/2.vite/svelte-vite/src/App.svelte b/examples/2.vite/svelte-vite/src/App.svelte new file mode 100644 index 0000000..6d7b9a3 --- /dev/null +++ b/examples/2.vite/svelte-vite/src/App.svelte @@ -0,0 +1,14 @@ + + + diff --git a/examples/2.vite/svelte-vite/src/components/Alert.svelte b/examples/2.vite/svelte-vite/src/components/Alert.svelte new file mode 100644 index 0000000..e74024e --- /dev/null +++ b/examples/2.vite/svelte-vite/src/components/Alert.svelte @@ -0,0 +1,25 @@ + + + diff --git a/examples/2.vite/svelte-vite/src/index.css b/examples/2.vite/svelte-vite/src/index.css new file mode 100644 index 0000000..ea752f8 --- /dev/null +++ b/examples/2.vite/svelte-vite/src/index.css @@ -0,0 +1,6 @@ +@import "tailwindcss"; +@source "./**/*.svelte"; + +body { + @apply bg-neutral-50 dark:bg-neutral-950 p-4 text-neutral-900 dark:text-neutral-50; +} diff --git a/examples/2.vite/svelte-vite/src/main.ts b/examples/2.vite/svelte-vite/src/main.ts new file mode 100644 index 0000000..765afde --- /dev/null +++ b/examples/2.vite/svelte-vite/src/main.ts @@ -0,0 +1,6 @@ +import { mount } from 'svelte' +import App from './App.svelte' + +mount(App, { + target: document.getElementById('app')!, +}) diff --git a/examples/2.vite/svelte-vite/svelte.config.js b/examples/2.vite/svelte-vite/svelte.config.js new file mode 100644 index 0000000..071bad2 --- /dev/null +++ b/examples/2.vite/svelte-vite/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig} */ +const config = { + preprocess: vitePreprocess(), +} + +export default config diff --git a/examples/2.vite/svelte-vite/tsconfig.json b/examples/2.vite/svelte-vite/tsconfig.json new file mode 100644 index 0000000..e092317 --- /dev/null +++ b/examples/2.vite/svelte-vite/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/examples/2.vite/svelte-vite/vite.config.ts b/examples/2.vite/svelte-vite/vite.config.ts new file mode 100644 index 0000000..cac72c7 --- /dev/null +++ b/examples/2.vite/svelte-vite/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + svelte(), + tailwindcss(), + ], +}) diff --git a/package.json b/package.json index 0df2474..f2d62ce 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:playground": "nuxt dev playground", "dev:prepare": "nuxt prepare playground", "dev:react": "pnpm --filter comark-react-vite run dev", + "dev:svelte": "pnpm --filter comark-svelte-vite run dev", "dev:vue": "pnpm --filter comark-vue-vite run dev", "dev:vue:mermaid": "pnpm --filter comark-vue-vite-mermaid run dev", "dev:vue:math": "pnpm --filter comark-vue-vite-math run dev", @@ -34,7 +35,7 @@ "verify": "pnpm run lint && pnpm run test && pnpm run typecheck", "release": "pnpm --filter 'comark' run release && pnpm --filter '@comark/cjk' run release && pnpm --filter '@comark/math' run release", "release:dry": "pnpm --filter 'comark' run release:dry && pnpm --filter '@comark/cjk' run release:dry && pnpm --filter '@comark/math' run release:dry", - "postinstall": "pnpm --filter 'comark' run build --stub && pnpm --filter '@comark/*' run build --stub" + "postinstall": "pnpm --filter 'comark' run build --stub && pnpm --filter '@comark/*' --filter '!@comark/svelte' run build --stub" }, "devDependencies": { "@nuxt/eslint-config": "^1.15.2", @@ -42,6 +43,7 @@ "@types/node": "^25.3.3", "@vitejs/plugin-vue": "^6.0.4", "eslint": "^10.0.2", + "eslint-plugin-svelte": "^3.15.0", "nuxt": "^4.3.1", "release-it": "^19.2.4", "remark-gfm": "^4.0.1", diff --git a/packages/comark-svelte/package.json b/packages/comark-svelte/package.json new file mode 100644 index 0000000..be9c8b9 --- /dev/null +++ b/packages/comark-svelte/package.json @@ -0,0 +1,73 @@ +{ + "name": "@comark/svelte", + "version": "1.0.0", + "description": "Svelte renderer for Comark (Components in Markdown)", + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "license": "MIT", + "repository": "comarkdown/comark", + "keywords": [ + "markdown", + "mdc", + "comark", + "svelte", + "renderer", + "streaming" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js" + }, + "./async": { + "types": "./dist/async/index.d.ts", + "svelte": "./dist/async/index.js" + }, + "./plugin-math": { + "types": "./dist/plugin-math/index.d.ts", + "svelte": "./dist/plugin-math/index.js" + }, + "./plugin-mermaid": { + "types": "./dist/plugin-mermaid/index.d.ts", + "svelte": "./dist/plugin-mermaid/index.js" + } + }, + "files": [ + "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "svelte-package --input src", + "build:watch": "svelte-package --input src --watch", + "test": "vitest run", + "check": "svelte-check --tsconfig ./tsconfig.json", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "dependencies": { + "@comark/math": "workspace:*", + "@comark/mermaid": "workspace:*", + "beautiful-mermaid": "^1.1.3", + "comark": "workspace:*", + "katex": "^0.16.33", + "scule": "^1.3.0" + }, + "devDependencies": { + "@sveltejs/package": "^2.5.7", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "svelte": "^5.53.7", + "svelte-check": "^4.2.1", + "vitest": "^4.0.18", + "vitest-browser-svelte": "^0.1.0" + } +} diff --git a/packages/comark-svelte/src/Comark.svelte b/packages/comark-svelte/src/Comark.svelte new file mode 100644 index 0000000..b53f1c9 --- /dev/null +++ b/packages/comark-svelte/src/Comark.svelte @@ -0,0 +1,81 @@ + + + +{#if parsed} + +{/if} diff --git a/packages/comark-svelte/src/ComarkNode.svelte b/packages/comark-svelte/src/ComarkNode.svelte new file mode 100644 index 0000000..74b4107 --- /dev/null +++ b/packages/comark-svelte/src/ComarkNode.svelte @@ -0,0 +1,138 @@ + + + +{#if isText} + {node}{#if caretClass !== null}{CARET_TEXT}{/if} +{:else if Component} + + {#each children as child, i (i)} + + {/each} + +{:else if isVoid} + +{:else if tag} + + {#each children as child, i (i)} + + {/each} + +{/if} diff --git a/packages/comark-svelte/src/ComarkNode.svelte.test.ts b/packages/comark-svelte/src/ComarkNode.svelte.test.ts new file mode 100644 index 0000000..c2c2e52 --- /dev/null +++ b/packages/comark-svelte/src/ComarkNode.svelte.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { parse } from 'comark' +import ComarkRenderer from './ComarkRenderer.svelte' +import ComarkNode from './ComarkNode.svelte' +import Alert from './test-components/Alert.svelte' +import ProseH1 from './test-components/ProseH1.svelte' + +describe('ComarkNode', () => { + it('renders a paragraph', async () => { + const tree = await parse('Hello world') + const screen = render(ComarkNode, { node: tree.nodes[0] }) + await expect.element(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('renders nested inline markup', async () => { + const tree = await parse('Hello **World**') + const screen = render(ComarkNode, { node: tree.nodes[0] }) + await expect.element(screen.getByText('Hello World')).toBeInTheDocument() + await expect.element(screen.getByText('World')).toBeInTheDocument() + }) + + it('renders a link with href', async () => { + const tree = await parse('[link](/about)') + const screen = render(ComarkNode, { node: tree.nodes[0] }) + await expect + .element(screen.getByRole('link', { name: 'link' })) + .toHaveAttribute('href', '/about') + }) + + it('maps className to class', async () => { + const screen = render(ComarkNode, { + node: ['div', { className: 'my-class' }, 'content'], + }) + const div = screen.container.querySelector('.my-class')! + expect(div).not.toBeNull() + await expect.element(div).toHaveTextContent('content') + }) + + it('renders caret with custom class', async () => { + const screen = render(ComarkNode, { + node: 'text', + caretClass: 'test-caret', + }) + const caret = screen.container.querySelector('.test-caret')! + expect(caret).not.toBeNull() + await expect.element(caret).toHaveStyle({ display: 'inline-block' }) + }) + + it('does not render caret when caretClass is null', async () => { + const screen = render(ComarkNode, { + node: 'text', + caretClass: null, + }) + expect(screen.container.querySelector('span')).toBeNull() + }) + + it('threads caret to deepest last text node', async () => { + const tree = await parse('first **last**') + const screen = render(ComarkNode, { + node: tree.nodes[0], + caretClass: 'caret', + }) + const strong = screen.container.querySelector('strong')! + expect(strong.querySelector('.caret')).not.toBeNull() + expect(screen.container.querySelectorAll('.caret').length).toBe(1) + }) +}) + +describe('ComarkRenderer', () => { + it('renders a heading with inline markup', async () => { + const tree = await parse('# Hello **World**') + const screen = render(ComarkRenderer, { tree }) + const heading = screen.getByRole('heading', { + name: 'Hello World', + level: 1, + }) + await expect.element(heading).toBeInTheDocument() + await expect.element(heading).toHaveAttribute('id', 'hello-strong-world') + }) + + it('renders multiple block elements', async () => { + const tree = await parse('# Heading\n\nA paragraph\n\n- item 1\n- item 2') + const screen = render(ComarkRenderer, { tree }) + + await expect + .element(screen.getByRole('heading', { name: 'Heading', level: 1 })) + .toBeInTheDocument() + await expect.element(screen.getByText('A paragraph')).toBeInTheDocument() + + const items = screen.getByRole('listitem') + expect(items.elements().length).toBe(2) + await expect.element(items.nth(0)).toHaveTextContent('item 1') + await expect.element(items.nth(1)).toHaveTextContent('item 2') + }) + + it('renders empty tree as empty wrapper', async () => { + const tree = { nodes: [], frontmatter: {}, meta: {} } + const screen = render(ComarkRenderer, { tree }) + const wrapper + = screen.container.querySelector('.comark-content')! + expect(wrapper).not.toBeNull() + expect(wrapper.children.length).toBe(0) + }) + + it('applies custom class to wrapper', async () => { + const tree = await parse('hello') + const screen = render(ComarkRenderer, { tree, class: 'prose' }) + const wrapper + = screen.container.querySelector('.comark-content')! + await expect.element(wrapper).toHaveClass('prose') + }) + + it('renders inline code', async () => { + const tree = await parse('use `const x = 1`') + const screen = render(ComarkRenderer, { tree }) + await expect.element(screen.getByText('const x = 1')).toBeInTheDocument() + }) + + it('renders links with href', async () => { + const tree = await parse('[click](https://example.com)') + const screen = render(ComarkRenderer, { tree }) + await expect + .element(screen.getByRole('link', { name: 'click' })) + .toHaveAttribute('href', 'https://example.com') + }) + + it('renders images with src and alt', async () => { + const tree = await parse('![alt text](image.png)') + const screen = render(ComarkRenderer, { tree }) + await expect + .element(screen.getByAltText('alt text')) + .toHaveAttribute('src', 'image.png') + }) + + it('renders blockquotes', async () => { + const tree = await parse('> quoted text') + const screen = render(ComarkRenderer, { tree }) + expect(screen.container.querySelector('blockquote')).not.toBeNull() + await expect.element(screen.getByText('quoted text')).toBeInTheDocument() + }) + + it('renders emphasis and strong', async () => { + const tree = await parse('*em* and **strong**') + const screen = render(ComarkRenderer, { tree }) + await expect.element(screen.getByText('em')).toBeInTheDocument() + await expect.element(screen.getByText('strong')).toBeInTheDocument() + }) +}) + +describe('custom components', () => { + it('resolves custom component for MDC syntax', async () => { + const tree = await parse('::alert{type="warning"}\nWatch out!\n::') + const screen = render(ComarkRenderer, { + tree, + components: { alert: Alert }, + }) + await expect + .element(screen.getByRole('alert')) + .toHaveTextContent('Watch out!') + await expect.element(screen.getByRole('alert')).toHaveClass('alert-warning') + }) + + it('resolves component by PascalCase key', async () => { + const tree = await parse('::alert{type="info"}\nInfo message\n::') + const screen = render(ComarkRenderer, { tree, components: { Alert } }) + await expect + .element(screen.getByRole('alert')) + .toHaveTextContent('Info message') + await expect.element(screen.getByRole('alert')).toHaveClass('alert-info') + }) + + it('resolves Prose-prefixed component for native tags', async () => { + const tree = await parse('# Custom Heading') + const screen = render(ComarkRenderer, { tree, components: { ProseH1 } }) + await expect + .element( + screen.getByRole('heading', { name: 'Custom Heading', level: 1 }), + ) + .toHaveClass('prose-heading') + }) + + it('renders children inside custom components', async () => { + const tree = await parse('::alert{type="info"}\n**Bold** text\n::') + const screen = render(ComarkRenderer, { + tree, + components: { alert: Alert }, + }) + await expect + .element(screen.getByRole('alert')) + .toHaveTextContent('Bold text') + await expect.element(screen.getByRole('alert')).toHaveClass('alert-info') + }) + + it('falls back to native element when no component matches', async () => { + const tree = await parse('::alert{type="info"}\ncontent\n::') + const screen = render(ComarkRenderer, { tree, components: {} }) + const alert = screen.container.querySelector('alert')! + expect(alert).not.toBeNull() + await expect.element(alert).toHaveTextContent('content') + }) +}) diff --git a/packages/comark-svelte/src/ComarkNode.test.ts b/packages/comark-svelte/src/ComarkNode.test.ts new file mode 100644 index 0000000..f7371e7 --- /dev/null +++ b/packages/comark-svelte/src/ComarkNode.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest' +import { render } from 'svelte/server' +import { parse } from 'comark' +import ComarkRenderer from './ComarkRenderer.svelte' +import ComarkNode from './ComarkNode.svelte' +import Alert from './test-components/Alert.svelte' +import ProseH1 from './test-components/ProseH1.svelte' + +/** Strip Svelte SSR hydration comments from rendered HTML */ +function html(body: string): string { + return body.replace(//g, '').replace(//g, '') +} + +const CARET_STYLE + = 'background-color: currentColor; display: inline-block; margin-left: 0.25rem; margin-right: 0.25rem; animation: pulse 0.75s cubic-bezier(0.4,0,0.6,1) infinite;' + +describe('ComarkNode', () => { + it('renders a paragraph', async () => { + const tree = await parse('A paragraph') + const { body } = render(ComarkNode, { props: { node: tree.nodes[0] } }) + expect(html(body)).toBe('

A paragraph

') + }) + + it('renders nested inline markup', async () => { + const tree = await parse('Hello **World**') + const { body } = render(ComarkNode, { props: { node: tree.nodes[0] } }) + expect(html(body)).toBe('

Hello World

') + }) + + it('renders mixed inline markup', async () => { + const tree = await parse('one *two* three') + const { body } = render(ComarkNode, { props: { node: tree.nodes[0] } }) + expect(html(body)).toBe('

one two three

') + }) + + it('renders a link with attributes', async () => { + const tree = await parse('[link](/about)') + const { body } = render(ComarkNode, { props: { node: tree.nodes[0] } }) + expect(html(body)).toBe('

link

') + }) + + it('renders a thematic break', async () => { + const tree = await parse('---') + const { body } = render(ComarkNode, { props: { node: tree.nodes[0] } }) + expect(html(body)).toBe('
') + }) + + it('skips comment nodes (null tag)', () => { + const { body } = render(ComarkNode, { + props: { node: [null, {}, 'a comment'] }, + }) + expect(html(body)).toBe('') + }) + + it('maps className to class', () => { + const { body } = render(ComarkNode, { + props: { node: ['div', { className: 'my-class' }, 'content'] }, + }) + expect(html(body)).toBe('
content
') + }) + + it('parses colon-prefixed props as values', () => { + const { body } = render(ComarkNode, { + props: { node: ['div', { ':hidden': 'true' }, 'content'] }, + }) + expect(html(body)).toBe('') + }) + + it('parses colon-prefixed JSON values', () => { + const { body } = render(ComarkNode, { + props: { node: ['div', { ':data-count': '42' }, 'content'] }, + }) + expect(html(body)).toBe('
content
') + }) + + it('does not render caret when caretClass is null', async () => { + const tree = await parse('some text') + const { body } = render(ComarkNode, { + props: { node: tree.nodes[0], caretClass: null }, + }) + expect(html(body)).toBe('

some text

') + expect(html(body)).not.toContain(' { + const { body } = render(ComarkNode, { + props: { node: 'text', caretClass: 'my-caret' }, + }) + expect(html(body)).toBe( + `text\u2009`, + ) + }) + + it('renders caret without class when caretClass is empty string', () => { + const { body } = render(ComarkNode, { + props: { node: 'text', caretClass: '' }, + }) + expect(html(body)).toBe(`text\u2009`) + }) + + it('threads caret to deepest last text node', async () => { + const tree = await parse('first **last**') + const { body } = render(ComarkNode, { + props: { node: tree.nodes[0], caretClass: '' }, + }) + const output = html(body) + // Caret should be inside , after "last" + expect(output).toContain( + `last\u2009`, + ) + // Not after the + expect(output).not.toContain(` { + const tree = await parse('*__**deep**__*') + const { body } = render(ComarkNode, { + props: { node: tree.nodes[0], caretClass: 'c' }, + }) + const output = html(body) + // Caret should be at the very deepest level, after "deep" + expect(output).toContain( + `deep\u2009`, + ) + }) + + it('does not attach caret to non-last children', async () => { + const tree = await parse('**first** last') + const { body } = render(ComarkNode, { + props: { node: tree.nodes[0], caretClass: '' }, + }) + const output = html(body) + // Caret should be after "last" (the last child), not inside + expect(output).toContain(`last\u2009`) + expect(output).not.toContain(`first { + it('renders a heading with inline markup', async () => { + const tree = await parse('# Hello **World**') + const { body } = render(ComarkRenderer, { props: { tree } }) + const output = html(body) + expect(output).toContain('

') + expect(output).toContain('Hello World') + expect(output).toContain('

') + expect(output).toMatch(/^
.*<\/div>$/) + }) + + it('renders multiple block-level elements', async () => { + const tree = await parse('# Heading\n\nA paragraph\n\n- item 1\n- item 2') + const { body } = render(ComarkRenderer, { props: { tree } }) + const output = html(body) + expect(output).toContain('A paragraph

') + expect(output).toContain('
    ') + expect(output).toContain('
  • item 1
  • ') + expect(output).toContain('
  • item 2
  • ') + }) + + it('renders an empty tree as an empty wrapper', () => { + const tree = { nodes: [], frontmatter: {}, meta: {} } + const { body } = render(ComarkRenderer, { props: { tree } }) + expect(html(body)).toBe('
    ') + }) + + it('applies a custom class to the wrapper', async () => { + const tree = await parse('hello') + const { body } = render(ComarkRenderer, { + props: { tree, class: 'prose' }, + }) + const output = html(body) + expect(output).toMatch(/^
    /) + expect(output).toContain('

    hello

    ') + }) + + it('renders inline code', async () => { + const tree = await parse('use `const x = 1`') + const { body } = render(ComarkRenderer, { props: { tree } }) + expect(html(body)).toContain('const x = 1') + }) + + it('renders links', async () => { + const tree = await parse('[click me](https://example.com)') + const { body } = render(ComarkRenderer, { props: { tree } }) + expect(html(body)).toContain('click me') + }) + + it('renders images', async () => { + const tree = await parse('![alt text](image.png)') + const { body } = render(ComarkRenderer, { props: { tree } }) + expect(html(body)).toContain('alt text') + }) + + it('renders blockquotes', async () => { + const tree = await parse('> quoted text') + const { body } = render(ComarkRenderer, { props: { tree } }) + const output = html(body) + expect(output).toContain('
    ') + expect(output).toContain('quoted text') + }) + + it('renders emphasis and strong', async () => { + const tree = await parse('*em* and **strong**') + const { body } = render(ComarkRenderer, { props: { tree } }) + const output = html(body) + expect(output).toContain('em') + expect(output).toContain('strong') + }) +}) + +describe('custom components', () => { + it('resolves custom component for MDC syntax', async () => { + const tree = await parse('::alert{type="warning"}\nWatch out!\n::') + const { body } = render(ComarkRenderer, { + props: { tree, components: { alert: Alert } }, + }) + const output = html(body) + expect(output).toContain('