diff --git a/docs/src/lib/components/sidebar/Sidebar.svelte b/docs/src/lib/components/sidebar/Sidebar.svelte index b02930c..cb82c46 100644 --- a/docs/src/lib/components/sidebar/Sidebar.svelte +++ b/docs/src/lib/components/sidebar/Sidebar.svelte @@ -45,6 +45,12 @@ Community Plugins + + + + Using Components + +

Plugins

diff --git a/docs/src/lib/styles/markdown.scss b/docs/src/lib/styles/markdown.scss index cef9e64..61eb539 100644 --- a/docs/src/lib/styles/markdown.scss +++ b/docs/src/lib/styles/markdown.scss @@ -46,8 +46,18 @@ @apply rounded bg-neutral-800 px-1 text-neutral-50; } + p, + h1, + h2, + h3 { + code { + @apply px-1; + } + } + .carta-editor code { font-family: 'Fira Code', monospace; background: transparent; + padding: 0; } } diff --git a/docs/src/pages/editing-styles.svelte.md b/docs/src/pages/editing-styles.svelte.md index e54df5a..81736ed 100644 --- a/docs/src/pages/editing-styles.svelte.md +++ b/docs/src/pages/editing-styles.svelte.md @@ -43,11 +43,39 @@ While the core styles are embedded in the Svelte components, the others can be s ### Using multiple themes -By using the `theme` property in the editor you can differentiate the themes of multiple editors. +By using the `theme` property in `` you can differentiate the themes of multiple editors. -## Changing Markdown color theme +## Dark mode -Carta uses [Shiki](https://shiki.matsu.io/) for syntax highlighting. Two default themes are included in the core package, which are as a [dual theme](https://shiki.matsu.io/guide/dual-themes) used for light and dark mode. +When using dark mode, there are two different themes that have to be changed: the editor theme and the one used for syntax highlighting: + + + +```css +/* Editor dark mode */ +/* Only if you are using the default theme */ +html.dark .carta-theme__default { + --border-color: var(--border-color-dark); + --selection-color: var(--selection-color-dark); + --focus-outline: var(--focus-outline-dark); + --hover-color: var(--hover-color-dark); + --caret-color: var(--caret-color-dark); + --text-color: var(--text-color-dark); +} + +/* Code dark mode */ +/* Only if you didn't specify a custom code theme */ +html.dark .shiki, +html.dark .shiki span { + color: var(--shiki-dark) !important; +} +``` + + + +## Changing Markdown input color theme + +Carta uses [Shiki](https://shiki.matsu.io/) for syntax highlighting. Two default themes are included in the core package, which are set as a [dual theme](https://shiki.matsu.io/guide/dual-themes) to support light and dark mode. If you plan to use a custom one with light/dark modes, make sure to use a dual theme as well. You can change theme in the options: @@ -62,7 +90,7 @@ const carta = new Carta({ -If you use a [custom theme](https://shiki.matsu.io/guide/load-theme)(or also a custom language), you need to specify it, so that it gets loaded into the highlighter: +If you use a [custom theme](https://shiki.matsu.io/guide/load-theme)(or a custom language), you need to provide it inside the options, so that it gets loaded into the highlighter: diff --git a/docs/src/pages/migration.svelte.md b/docs/src/pages/migration.svelte.md index 1a2383c..72d25dc 100644 --- a/docs/src/pages/migration.svelte.md +++ b/docs/src/pages/migration.svelte.md @@ -24,8 +24,8 @@ import 'carta-md/light.css'; // 👈 To be removed! And also update the default theme. Previous based selectors should be removed: ```css +/* 👇 To be removed! */ [class*='shj-lang-'] { - /* 👈 To be removed! */ /* ... */ } ``` @@ -38,7 +38,7 @@ Many exports have been renamed to make them less verbose: - `CartaRenderer` -> `Markdown` (old one still supported); - `CartaEvent` -> `Event`; - `CartaEventType` -> `EventType`; -- `CartaExtension` -> `Extension`; +- `CartaExtension` -> `Plugin`; - `CartaExtensionComponent` -> `ExtensionComponent`; - `CartaOptions` -> `Options`; - `CartaHistory` -> `TextAreaHistory`; diff --git a/docs/src/pages/using-components.svelte.md b/docs/src/pages/using-components.svelte.md new file mode 100644 index 0000000..f301c51 --- /dev/null +++ b/docs/src/pages/using-components.svelte.md @@ -0,0 +1,217 @@ +--- +title: Using Svelte Components +section: Overview +--- + + + +Svelte components can be embedded into the rendered HTML to make certain elements interactive. However, they require a bit more work, as Remark is configured to only render static HTML. To get around this, the idea is to do the following: + +1. Create a Unified plugin to isolate the targeted element; +2. Replace all the elements with the component, after every render. + +## Example + +Let's say we want to replace all hashtags, such as `#something`, with a custom component. Here is as example of how that could be achieved. + +### Parsing the hashtags + +First things first: we need to tell the parser that we want to parse hashtags as custom elements. To do this, it's useful to first install the following packages: + + + +```shell +npm i unist-util-visit +# Types +npm i -D unified hast +``` + + + +Let's create a Unified plugin. The basic structure of a plugin is the following: + + + +```ts +import type { Plugin as UnifiedPlugin } from 'unified' +import { SKIP, visit } from 'unist-util-visit' + +const unifiedPlugin: UnifiedPlugin<[], hast.Root> = () => { + return function (tree) { + // Visit every node in the syntax tree + visit(tree, (node, index, parent) => { + // Do something with the node + } + } +} +``` + + + +We now want to parse text nodes, so that words such as `#pizza` and `#123` are separated from the rest. This is a possible implementation: + + + +```ts +const unifiedPlugin: UnifiedPlugin<[], hast.Root> = () => { + return function (tree) { + visit(tree, (node, index, parent) => { + // Skip code blocks and their children + if (node.type === 'element' && node.tagName === 'pre') return [SKIP]; + // Skip non-text nodes + if (node.type !== 'text') return; + const text = node as hast.Text; + + // Parse the text node and replace hashtags with spans + const regex = /#(\w+)/g; + const children: (hast.Element | hast.Text)[] = []; + let lastIndex = 0; + let match; + while ((match = regex.exec(text.value))) { + const before = text.value.slice(lastIndex, match.index); + if (before) { + children.push({ type: 'text', value: before }); + } + children.push({ + type: 'element', + tagName: 'span', + properties: { type: 'hashtag', value: match[1] }, + children: [{ type: 'text', value: match[0] }] + }); + lastIndex = regex.lastIndex; + } + if (lastIndex < text.value.length) { + children.push({ type: 'text', value: text.value.slice(lastIndex) }); + } + + // Replace the text node with all the children + parent!.children.splice(index!, 1, ...children); + + // Skip the children + return [SKIP, index! + children.length]; + }); + }; +}; +``` + + + +If you want a more in-depth guide on writing Unified plugins, you can check out the official [documentation](https://unifiedjs.com/learn/guide/create-a-plugin/). + +Notice that hashtags are now replaced with the following: + +```html + #pizza +``` + +### Configuring the transformer + +Unified plugins need to be wrapped inside a `UnifiedTransformer` type, to be able to be used in Carta. + + + +```ts +import type { UnifiedTransformer } from 'carta-md'; + +const hashtagTransformer: UnifiedTransformer<'sync'> = { + execution: 'sync', // Sync, since the plugin is synchronous + type: 'rehype', // Rehype, since it operates on HTML + transform({ processor }) { + processor.use(unifiedPlugin); + } +}; +``` + + + +### Mounting the components + +We now want to replace the generated hashtag placeholders with the following element: + + + +```svelte + + + + +``` + + + +To do that, we create a listener that: + +1. Finds all the previous placeholders; +2. Mounts the component next to them; +3. Removes the placeholders. + + + +```ts +import type { Listener } from 'carta-md'; +import Hashtag from './Hashtag.svelte'; + +const convertHashtags: Listener<'carta-render'> = [ + 'carta-render', + function onRender({ detail: { carta } }) { + const rendererContainer = carta.renderer?.container; + if (!rendererContainer) return; + + // Find all hashtag spans and replace them with Svelte components + const hashtagSpans = rendererContainer.querySelectorAll('span[type="hashtag"]'); + for (const span of hashtagSpans) { + const hashtag = span.getAttribute('value') ?? ''; + + new Hashtag({ + target: span.parentElement!, + anchor: span, + props: { value: hashtag } + }); + + span.remove(); + } + } +]; +``` + + + +### Using the plugin + +Let's now create a Plugin with the transformer and the listener: + + + +```ts +import type { Plugin } from 'carta-md'; + +export const hashtag = (): Plugin => ({ + transformers: [hashtagTransformer], + listeners: [convertHashtags] +}); +``` + + + +We can now use the plugin with the following: + +```ts +import { Carta } from 'carta-md'; + +const carta = new Carta({ + // ... + extensions: [hashtag()] +}); +``` + +You can find the example source code [here](https://github.com/BearToCode/svelte-in-carta-example). diff --git a/packages/carta-md/package.json b/packages/carta-md/package.json index e1eeb05..bc074ef 100644 --- a/packages/carta-md/package.json +++ b/packages/carta-md/package.json @@ -39,7 +39,7 @@ "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.0", - "shiki": "^1.3.0", + "shiki": "^1.4.0", "unified": "^11.0.4" }, "peerDependencies": { diff --git a/packages/carta-md/src/lib/internal/components/Input.svelte b/packages/carta-md/src/lib/internal/components/Input.svelte index c3fe8b6..3874591 100644 --- a/packages/carta-md/src/lib/internal/components/Input.svelte +++ b/packages/carta-md/src/lib/internal/components/Input.svelte @@ -63,18 +63,14 @@ } }; - const normalize = (text: string) => { - return text.replaceAll('\r\n', '\n'); - }; - const highlightNestedLanguages = debounce(async (text: string) => { const highlighter = await carta.highlighter(); const { updated } = await loadNestedLanguages(highlighter, text); if (updated) highlight(text); }, 300); - $: highlight(normalize(value)).then(resize); - $: highlightNestedLanguages(normalize(value)); + $: highlight(value).then(resize); + $: highlightNestedLanguages(value); onMount(() => { mounted = true; diff --git a/packages/carta-md/src/lib/internal/highlight.ts b/packages/carta-md/src/lib/internal/highlight.ts index ed2df86..16cfddf 100644 --- a/packages/carta-md/src/lib/internal/highlight.ts +++ b/packages/carta-md/src/lib/internal/highlight.ts @@ -249,6 +249,8 @@ const findNestedLanguages = (text: string) => { * @returns Whether the highlighter was updated with new languages. */ export const loadNestedLanguages = async (highlighter: Highlighter, text: string) => { + text = text.replaceAll('\r\n', '\n'); // Normalize line endings + const languages = findNestedLanguages(text); const loadedLanguages = highlighter.getLoadedLanguages(); let updated = false; diff --git a/packages/plugin-anchor/src/lib/default.css b/packages/plugin-anchor/src/lib/default.css index afbed18..1d18753 100644 --- a/packages/plugin-anchor/src/lib/default.css +++ b/packages/plugin-anchor/src/lib/default.css @@ -1,18 +1,18 @@ -.carta-renderer h1, -.carta-renderer h2, -.carta-renderer h3, -.carta-renderer h4, -.carta-renderer h5, -.carta-renderer h6 { +.carta-viewer h1, +.carta-viewer h2, +.carta-viewer h3, +.carta-viewer h4, +.carta-viewer h5, +.carta-viewer h6 { position: relative; } -.carta-renderer h1 .icon.icon-link, -.carta-renderer h2 .icon.icon-link, -.carta-renderer h3 .icon.icon-link, -.carta-renderer h4 .icon.icon-link, -.carta-renderer h5 .icon.icon-link, -.carta-renderer h6 .icon.icon-link { +.carta-viewer h1 .icon.icon-link, +.carta-viewer h2 .icon.icon-link, +.carta-viewer h3 .icon.icon-link, +.carta-viewer h4 .icon.icon-link, +.carta-viewer h5 .icon.icon-link, +.carta-viewer h6 .icon.icon-link { opacity: 0; content: url('./link.svg'); position: absolute; @@ -22,11 +22,11 @@ transform: translateY(-50%); } -.carta-renderer h1:hover .icon-link, -.carta-renderer h2:hover .icon-link, -.carta-renderer h3:hover .icon-link, -.carta-renderer h4:hover .icon-link, -.carta-renderer h5:hover .icon-link, -.carta-renderer h6:hover .icon-link { +.carta-viewer h1:hover .icon-link, +.carta-viewer h2:hover .icon-link, +.carta-viewer h3:hover .icon-link, +.carta-viewer h4:hover .icon-link, +.carta-viewer h5:hover .icon-link, +.carta-viewer h6:hover .icon-link { opacity: 1; } diff --git a/packages/plugin-code/README.md b/packages/plugin-code/README.md index 7eab23b..cbb5c68 100644 --- a/packages/plugin-code/README.md +++ b/packages/plugin-code/README.md @@ -1,13 +1,11 @@ # Carta Code Plugin -This plugin adds support for code blocks **syntax highlighting**. Install it using: +This plugin adds support for code blocks **syntax highlighting**. It uses the same highlighter from the core package(Shiki). ``` npm i @cartamd/plugin-code ``` -This is done using [Speed-highlight JS](https://github.com/speed-highlight/core), which supports dynamic imports. This way, languages definitions are only imported at the moment of need. - ## Setup ### Styles @@ -20,7 +18,7 @@ import '@cartamd/plugin-code/default.css'; ### Using the default highlighter -Carta comes with a default highlighter that matches the one used to highlight markdown in the editor and is used by default (Shiki). If you want to use a theme different from the one used to highlight Markdown, you can specify it in the options. +Carta comes with a default highlighter that matches the one used to highlight markdown in the editor and is used by default (Shiki). If you want to use a theme different from the one used to highlight Markdown, you can specify it in the options. Remember to also have it loaded into the highlighter, by specifying it in `shikiOptions`. ```ts const carta = new Carta({ @@ -29,7 +27,10 @@ const carta = new Carta({ code({ theme: 'ayu-light' }) - ] + ], + shikiOptions: { + themes: ['ayu-light'] + } }); ``` diff --git a/packages/plugin-code/package.json b/packages/plugin-code/package.json index e57937b..0da3535 100644 --- a/packages/plugin-code/package.json +++ b/packages/plugin-code/package.json @@ -16,7 +16,7 @@ "build": "tsc && tscp" }, "devDependencies": { - "@shikijs/rehype": "^1.3.0", + "@shikijs/rehype": "^1.4.0", "@types/node": "^18.16.3", "carta-md": "workspace:*", "typescript": "^5.0.4", @@ -30,7 +30,7 @@ ], "version": "4.0.0", "dependencies": { - "@shikijs/rehype": "^1.3.0", + "@shikijs/rehype": "^1.4.0", "unified": "^11.0.4" }, "keywords": [ diff --git a/packages/plugin-tikz/src/index.ts b/packages/plugin-tikz/src/index.ts index 895eb62..8ef05de 100644 --- a/packages/plugin-tikz/src/index.ts +++ b/packages/plugin-tikz/src/index.ts @@ -86,14 +86,18 @@ const tikzTransformer: UnifiedPlugin< hast.Root > = ({ carta, options }) => { return async function (tree) { - visit(tree, (node, index, parent) => { + visit(tree, (pre, index, parent) => { if (typeof document === 'undefined') { // Cannot run outside the browser return; } - if (node.type !== 'element') return; - const element = node as hast.Element; + if (pre.type !== 'element') return; + const preElement = pre as hast.Element; + if (preElement.tagName !== 'pre') return; + const element = pre.children.at(0) as hast.Element | undefined; + if (!element) return; + if (element.tagName !== 'code') return; if (!element.properties['className']) return; if (!(element.properties['className'] as string[]).includes('language-tikz')) return; @@ -130,6 +134,7 @@ const tikzTransformer: UnifiedPlugin< } const hastNode = fromDom(container) as hast.Element; + parent?.children.splice(index!, 1, hastNode); return [SKIP, index!]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2eb6c4..1968455 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: ^11.1.0 version: 11.1.0 shiki: - specifier: ^1.3.0 - version: 1.3.0 + specifier: ^1.4.0 + version: 1.4.0 svelte: specifier: ^3.54.0 || ^4.0.0 version: 4.2.2 @@ -297,8 +297,8 @@ importers: packages/plugin-code: dependencies: '@shikijs/rehype': - specifier: ^1.3.0 - version: 1.3.0 + specifier: ^1.4.0 + version: 1.4.0 unified: specifier: ^11.0.4 version: 11.0.4 @@ -1422,25 +1422,25 @@ packages: - supports-color dev: true - /@shikijs/core@1.3.0: - resolution: {integrity: sha512-7fedsBfuILDTBmrYZNFI8B6ATTxhQAasUHllHmjvSZPnoq4bULWoTpHwmuQvZ8Aq03/tAa2IGo6RXqWtHdWaCA==} + /@shikijs/core@1.4.0: + resolution: {integrity: sha512-CxpKLntAi64h3j+TwWqVIQObPTED0FyXLHTTh3MKXtqiQNn2JGcMQQ362LftDbc9kYbDtrksNMNoVmVXzKFYUQ==} dev: false - /@shikijs/rehype@1.3.0: - resolution: {integrity: sha512-CknEidx0ZTg3TeYAPU4ah8cr31a16neBbMyQ5kwAVdkloCe65uhQp+C/FEFs8NRir4eU5XCDA/+w2v5wnN6zgQ==} + /@shikijs/rehype@1.4.0: + resolution: {integrity: sha512-Ba6QHYx+EIEvmqyNy/B49KAz3rXsTfAqYRY3KTZjPWonytokGOiJ1q/FV9l13D/ad6Qv+eWKhkAz6ITxx6ziFA==} dependencies: - '@shikijs/transformers': 1.3.0 + '@shikijs/transformers': 1.4.0 '@types/hast': 3.0.4 hast-util-to-string: 3.0.0 - shiki: 1.3.0 + shiki: 1.4.0 unified: 11.0.4 unist-util-visit: 5.0.0 dev: false - /@shikijs/transformers@1.3.0: - resolution: {integrity: sha512-3mlpg2I9CjhjE96dEWQOGeCWoPcyTov3s4aAsHmgvnTHa8MBknEnCQy8/xivJPSpD+olqOqIEoHnLfbNJK29AA==} + /@shikijs/transformers@1.4.0: + resolution: {integrity: sha512-kzvlWmWYYSeaLKRce/kgmFFORUtBtFahfXRKndor0b60ocYiXufBQM6d6w1PlMuUkdk55aor9xLvy9wy7hTEJg==} dependencies: - shiki: 1.3.0 + shiki: 1.4.0 dev: false /@sveltejs/adapter-auto@3.1.1(@sveltejs/kit@2.5.4): @@ -6435,10 +6435,10 @@ packages: engines: {node: '>=8'} dev: true - /shiki@1.3.0: - resolution: {integrity: sha512-9aNdQy/etMXctnPzsje1h1XIGm9YfRcSksKOGqZWXA/qP9G18/8fpz5Bjpma8bOgz3tqIpjERAd6/lLjFyzoww==} + /shiki@1.4.0: + resolution: {integrity: sha512-5WIn0OL8PWm7JhnTwRWXniy6eEDY234mRrERVlFa646V2ErQqwIFd2UML7e0Pq9eqSKLoMa3Ke+xbsF+DAuy+Q==} dependencies: - '@shikijs/core': 1.3.0 + '@shikijs/core': 1.4.0 dev: false /signal-exit@3.0.7: