Compare commits

...

42 commits

Author SHA1 Message Date
BearToCode
2177545454 docs: fix grammar 2024-05-02 19:28:12 +02:00
BearToCode
a303834127 build: fix shiki version difference 2024-05-02 19:26:54 +02:00
BearToCode
3fd18a6796 fix: update shiki, fix latex highlighting issues 2024-05-02 17:26:17 +02:00
BearToCode
486a885704 docs: specify to load the language into the highlighter 2024-05-02 17:11:26 +02:00
BearToCode
8e5803a1c4 docs: fix style check issue 2024-05-02 11:30:35 +02:00
BearToCode
0ff9e5b124 docs: add guide on using Svelte components 2024-05-02 11:26:12 +02:00
BearToCode
eb114a7311 docs: small fix in style 2024-05-01 18:21:40 +02:00
BearToCode
34d4f400e6 docs: fix wrong name for Plugins 2024-05-01 18:15:42 +02:00
BearToCode
cc79b25288 docs: add missing copy button 2024-05-01 16:35:47 +02:00
BearToCode
a44b8e971d docs: add dark mode chapter 2024-05-01 16:30:13 +02:00
BearToCode
bafc86d8d3 fix: handle caret endings in Markdown component
fix: #56
2024-04-28 15:46:51 +02:00
BearToCode
a086ece088 docs: update old plugin code readme 2024-04-20 20:05:27 +02:00
BearToCode
dddaad1f1b fix(plugin-anchor): wrong class in default stylesheet 2024-04-20 19:46:00 +02:00
BearToCode
7261d295e2 docs: fix editor code having padding 2024-04-18 12:49:11 +02:00
BearToCode
482fd4b8af fix(plugin-tikz): remove wrapping pre 2024-04-15 18:16:43 +02:00
BearToCode
dfc8812123 fix(plugin-tikz): prevent crash when className property is not set 2024-04-14 18:37:48 +02:00
BearToCode
54358b649b fix(plugin-code): do not import isSingleTheme for main package
fix #50
2024-04-14 18:24:18 +02:00
BearToCode
7ad874e6aa fix(plugin-code): module not found error 2024-04-14 18:08:33 +02:00
BearToCode
545864a202 docs: fix grammar/style 2024-04-13 19:59:43 +02:00
BearToCode
0cef0b94ed docs: fix laggy header tracker 2024-04-12 20:14:11 +02:00
BearToCode
20d58a856b docs: fix code background 2024-04-12 16:34:34 +02:00
BearToCode
1a61f58e7d fix(plugin-tikz): invalid version 2024-04-12 16:22:49 +02:00
BearToCode
7d4034c316 fix(plugin-code): invalid version 2024-04-12 16:18:06 +02:00
BearToCode
26241b5bcd fix(plugin-emoji): invalid version 2024-04-12 16:12:56 +02:00
BearToCode
3785fd01c0 fix(plugin-slash): invalid version 2024-04-12 16:07:48 +02:00
BearToCode
c9be45a1ee fix(plugin-math): invalid version 2024-04-12 16:02:45 +02:00
BearToCode
c4428b8150 docs: fix relative links 2024-04-12 16:00:39 +02:00
Davide
036af273f9
v4 (#46) 2024-04-12 15:55:06 +02:00
BearToCode
58a7a8848f chore: migrate plugin-attachment 2024-04-12 08:29:41 +02:00
BearToCode
255913bd35 docs: minor fixes 2024-04-12 08:24:47 +02:00
BearToCode
e175e87bcc style: fix formatting 2024-04-12 08:24:46 +02:00
BearToCode
7e4a387523 docs: update docs, add migration guide 2024-04-12 08:24:46 +02:00
BearToCode
342c8d24d0 feat: use unified+rehype for parsing markdown
`marked` has been replaced with a more modern setup involving Unified JS, Rehype and various
plugins.

BREAKING CHANGE: Replaced `marked` with `unified` and `rehype`
2024-04-12 08:24:46 +02:00
BearToCode
9e1b0d1850 docs: update README 2024-04-12 08:24:46 +02:00
BearToCode
b30b2af6d9 fix: default theme placeholder color 2024-04-12 08:24:46 +02:00
BearToCode
c6a81acedd fix: default theme caret color 2024-04-12 08:24:46 +02:00
BearToCode
d1590ea384 build: move plugins peer-deps to v4 2024-04-12 08:24:46 +02:00
BearToCode
24be57a10f refactor: require explicit disabling of sanitizer
BREAKING CHANGE: Require `sanitizer` to be provided in the options. To disable it, set it explicitly
to `false`.
2024-04-12 08:24:46 +02:00
BearToCode
eb647b416f refactor: do not store options inside Carta
BREAKING CHANGE: Remove `Carta.options`. Update API
2024-04-12 08:24:46 +02:00
BearToCode
ae15b5b590 feat: use shiki for syntax highlighting
Remove Speed-Syntax Highlight, as the newer versions stopped working, and use ShikiJS instead. Also
has better syntax and more languages supported.

BREAKING CHANGE: Replace SHJ with ShikiJS, removed old themes and added new ones.
2024-04-12 08:24:46 +02:00
BearToCode
6e9cb68141 wip: use shiki for highlighting 2024-04-12 08:24:45 +02:00
BearToCode
f0abf195b8 refactor: remove verbose prefixes
Remove "Carta" prefixes from various interface/components.

BREAKING CHANGE: Different objects have been renamed
2024-04-12 08:24:37 +02:00
101 changed files with 5337 additions and 1291 deletions

View file

@ -5,11 +5,15 @@
"coldark", "coldark",
"dompurify", "dompurify",
"flexsearch", "flexsearch",
"Gemoji",
"gruvbox", "gruvbox",
"iconify",
"Katex", "Katex",
"mdsvex", "mdsvex",
"oldschool", "oldschool",
"rehype", "rehype",
"shiki",
"shikijs",
"tikz", "tikz",
"tikzjax", "tikzjax",
"typeof" "typeof"
@ -17,5 +21,6 @@
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"[svelte]": { "[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode" "editor.defaultFormatter": "svelte.svelte-vscode"
} },
"css.customData": [".vscode/tailwind.json"]
} }

55
.vscode/tailwind.json vendored Normal file
View file

@ -0,0 +1,55 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View file

@ -1,31 +1,25 @@
<div align="right"> <div align="right">
<a href="https://www.npmjs.com/package/carta-md"> <a href="https://www.npmjs.com/package/carta-md">
<img src="https://img.shields.io/npm/v/carta-md?color=0384fc&labelColor=171d27&logo=npm&logoColor=white" alt="npm"> <img src="https://img.shields.io/npm/v/carta-md?color=ff7cc6&labelColor=171d27&logo=npm&logoColor=white" alt="npm">
</a> </a>
<a href="https://bundlephobia.com/package/carta-md"> <a href="https://bundlephobia.com/package/carta-md">
<img src="https://img.shields.io/bundlephobia/min/carta-md?color=0384fc&labelColor=171d27&logo=javascript&logoColor=white" alt="bundle"> <img src="https://img.shields.io/bundlephobia/min/carta-md?color=4dacfa&labelColor=171d27&logo=javascript&logoColor=white" alt="bundle">
</a> </a>
<a href="https://github.com/BearToCode/carta/blob/master/LICENSE"> <a href="https://github.com/BearToCode/carta/blob/master/LICENSE">
<img src="https://img.shields.io/npm/l/carta-md?color=0384fc&labelColor=171d27&logo=git&logoColor=white" alt="license"> <img src="https://img.shields.io/npm/l/carta-md?color=71d58a&labelColor=171d27&logo=git&logoColor=white" alt="license">
</a> </a>
<a href="http://beartocode.github.io/carta/"> <a href="http://beartocode.github.io/carta/">
<img src="https://img.shields.io/readthedocs/carta?logo=svelte&color=0384fc&logoColor=ffffff&labelColor=171d27" alt="docs"> <img src="https://img.shields.io/readthedocs/carta?logo=svelte&color=b581fd&logoColor=ffffff&labelColor=171d27" alt="docs">
</a> </a>
</div> </div>
<div align="center"> [![Carta.png](https://i.postimg.cc/nV6DMXKM/Carta.png)](https://beartocode.github.io/carta/)
<a href="https://beartocode.github.io/carta/">
<img alt="banner" src="https://i.postimg.cc/1XPm8FSD/Frame-8.png">
</a>
</div>
<br> <h1 align="center"><strong>Carta</strong></h1>
<div align="center">Modern, lightweight, powerful Markdown Editor.</div>
<div align="center"><strong>Carta</strong></div>
<div align="center">Swiftly edit and render Markdown, with no overhead.</div>
<br /> <br />
<div align="center"> <div align="center">
<a href="https://beartocode.github.io/carta/">Documentation</a> <a href="https://beartocode.github.io/carta/">📚 Documentation</a>
<span> · </span> <span> · </span>
<a href="https://github.com/BearToCode/carta">GitHub</a> <a href="https://github.com/BearToCode/carta">GitHub</a>
</div> </div>
@ -34,24 +28,30 @@
# Introduction # Introduction
Carta is a **lightweight**, **fast** and **extensible** Svelte Markdown editor and viewer, based on [Marked](https://github.com/markedjs/marked). Check out the [examples](http://beartocode.github.io/carta/examples) to see it in action. > [!NOTE]
Differently from most editors, Carta includes neither ProseMirror nor CodeMirror, allowing for an extremely small bundle size and fast loading time. > Carta has recently been updated to `v4`, which features numerous major changes.
>
> Follow the [Migration Guide](http://beartocode.github.io/carta/migration) to update your project.
Carta is a **lightweight**, **fast** and **extensible** Svelte Markdown editor and viewer. It is powered by [unified](https://github.com/unifiedjs/unified), [remark](https://github.com/remarkjs/remark) and [rehype](https://github.com/rehypejs/rehype). Check out the [examples](http://beartocode.github.io/carta/examples) to see it in action.
Differently from most editors, Carta does not include a code editor, but it is _just_ a textarea with syntax highlighting, shortcuts and more.
## Features ## Features
- Keyboard **shortcuts** (extensible); - 🌈 Markdown syntax highlighting ([Shiki](https://shiki.style/));
- Toolbar (extensible); - 🛠️ Toolbar (extensible);
- Markdown syntax highlighting; - ⌨️ Keyboard **shortcuts** (extensible);
- Scroll sync; - 📦 Supports **[150+ plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins)** thanks to remark;
- Accessibility friendly; - 🔀 Scroll sync;
- **SSR** compatible; - ✅ Accessibility friendly;
- **Katex** support (plugin); - 🖥️ **SSR** compatible;
- **Slash** commands (plugin); - ⚗️ **KaTeX** support (plugin);
- **Emojis**, with included search (plugin); - 🔨 **Slash** commands (plugin);
- **Tikz** support (plugin); - 😄 **Emojis**, with included search (plugin);
- **Attachment** support (plugin); - ✏️ **TikZ** support (plugin);
- **Anchor** links in headings; - 📂 **Attachment** support (plugin);
- Code blocks **syntax highlighting** (plugin). - ⚓ **Anchor** links in headings (plugin);
- 🌈 Code blocks **syntax highlighting** (plugin).
## Packages ## Packages
@ -80,6 +80,7 @@ Differently from most editors, Carta includes neither ProseMirror nor CodeMirror
> [!WARNING] > [!WARNING]
> Sanitization is not dealt with by Carta. You need to provide a `sanitizer` in the options. > Sanitization is not dealt with by Carta. You need to provide a `sanitizer` in the options.
> Common sanitizers are [isomorphic-dompurify](https://www.npmjs.com/package/isomorphic-dompurify) (suggested) and [sanitize-html](https://www.npmjs.com/package/sanitize-html). > Common sanitizers are [isomorphic-dompurify](https://www.npmjs.com/package/isomorphic-dompurify) (suggested) and [sanitize-html](https://www.npmjs.com/package/sanitize-html).
> Checkout the documentation for an example.
## Installation ## Installation
@ -99,11 +100,9 @@ npm i @cartamd/plugin-name
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
// Component default theme // Component default theme
import 'carta-md/default.css'; import 'carta-md/default.css';
// Markdown input theme (Speed Highlight)
import 'carta-md/light.css';
const carta = new Carta({ const carta = new Carta({
// Remember to use a sanitizer to prevent XSS attacks // Remember to use a sanitizer to prevent XSS attacks
@ -111,13 +110,14 @@ npm i @cartamd/plugin-name
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
<style> <style>
/* Or in global stylesheet */ /* Or in global stylesheet */
/* Set your custom monospace font */ /* Set your custom monospace font */
:global(.carta-font-code) { :global(.carta-font-code) {
font-family: '...', monospace; font-family: '...', monospace;
font-size: 1.1rem;
} }
</style> </style>
``` ```

View file

@ -47,8 +47,8 @@
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cmdk-sv": "^0.0.6", "cmdk-sv": "^0.0.6",
"flexsearch": "0.7.21", "flexsearch": "0.7.21",
"iconify-icon": "^2.0.0",
"katex": "^0.16.10", "katex": "^0.16.10",
"radix-icons-svelte": "^1.2.1",
"tailwind-merge": "^2.0.0" "tailwind-merge": "^2.0.0"
} }
} }

View file

@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import { Copy } from 'radix-icons-svelte';
let elem: HTMLElement; let elem: HTMLElement;
</script> </script>
@ -13,10 +11,10 @@
navigator.clipboard.writeText(elem.innerText); navigator.clipboard.writeText(elem.innerText);
}} }}
class=" class="
absolute right-4 top-[min(50%_,_32px)] -translate-y-1/2 transform absolute right-4 top-[min(50%_,_32px)] aspect-square -translate-y-1/2 transform
rounded p-2 hover:bg-neutral-800 hover:text-neutral-300 active:text-sky-300 rounded hover:bg-neutral-800 hover:text-neutral-300 active:text-sky-300
" "
> >
<Copy class="h-5 w-5" /> <iconify-icon icon="octicon:copy-16" class="p-2 text-lg"></iconify-icon>
</button> </button>
</div> </div>

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onNavigate } from '$app/navigation';
import { debounce, throttle } from '$lib/utils';
import { onMount } from 'svelte';
const PADDING = 80; const PADDING = 80;
@ -7,49 +9,58 @@
let className = ''; let className = '';
let headers: HTMLElement[] = []; let headers: HTMLElement[] = [];
let scrollY = 0; let selectedHeaderIndex = 0;
function retrieveHeaders() { function retrieveHeaders() {
const markdownContainer = document.querySelector('.markdown'); headers = Array.from(
if (!markdownContainer) return; document.querySelectorAll('.markdown > h1, .markdown > h2, .markdown > h3')
headers = Array.from(markdownContainer.querySelectorAll('h1, h2, h3')) as HTMLElement[]; ) as HTMLElement[];
} }
function highlightHeader(header: HTMLElement, nextHeader: HTMLElement | null, index: number) { const highlightHeader = () => {
const headerHasReachedTop = header.getBoundingClientRect().top <= PADDING || index == 0; for (let index = headers.length - 1; index >= 0; index--) {
const nextHeaderReachedTop = nextHeader && nextHeader.getBoundingClientRect().top <= PADDING; const header = headers[index];
return !nextHeaderReachedTop && headerHasReachedTop; const rect = header.getBoundingClientRect();
} if (rect.top < PADDING) {
selectedHeaderIndex = index;
return;
}
}
selectedHeaderIndex = 0;
};
let observer: MutationObserver; const [throttledHighlightHeader] = throttle(highlightHeader, 100);
const debouncedHighlightHeader = debounce(highlightHeader, 100);
onMount(() => { onNavigate(() => {
observer = new MutationObserver(retrieveHeaders); setTimeout(() => {
observer.observe(document.body, { childList: true, subtree: true }); retrieveHeaders();
retrieveHeaders(); highlightHeader();
}); }, 300);
onDestroy(() => {
observer?.disconnect();
}); });
onMount(retrieveHeaders);
</script> </script>
<svelte:window bind:scrollY /> <svelte:window
on:scroll={() => {
throttledHighlightHeader();
debouncedHighlightHeader(); // So it is called at the end of the scroll event
}}
/>
<div class="h-full space-y-3 {className}"> <div class="h-full space-y-3 {className}">
{#each headers as header, i} {#each headers as header, i}
{@const margin = Number(header.tagName.split('')[1]) - 1} {@const margin = Number(header.tagName.split('')[1]) - 1}
{@const nextHeader = headers[i + 1]} {#key selectedHeaderIndex}
{#if header.children[0] instanceof HTMLAnchorElement && header.children[0].href} {#if header.children[0] instanceof HTMLAnchorElement && header.children[0].href}
{#key scrollY}
<a <a
style="margin-left: {margin * 0.75}rem;" style="margin-left: {margin * 0.75}rem;"
class="block text-sm {highlightHeader(header, nextHeader, i) class="block text-sm {selectedHeaderIndex === i
? 'font-medium text-sky-300' ? 'font-medium text-sky-300'
: 'text-neutral-400'}" : 'text-neutral-400'}"
href={header.children[0].href}>{header.innerText}</a href={header.children[0].href}>{header.innerText}</a
> >
{/key} {/if}
{/if} {/key}
{/each} {/each}
</div> </div>

View file

@ -1,18 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Archive, GithubLogo } from 'radix-icons-svelte';
export let npmLink: string; export let npmLink: string;
export let githubLink: string; export let githubLink: string;
</script> </script>
<div class="plugin-link mb-2 mt-6 inline-flex items-center space-x-3"> <div class="plugin-link mb-2 mt-6 flex items-end space-x-3">
<slot /> <slot />
<a href={githubLink}> <a href={githubLink} class="flex aspect-square">
<GithubLogo class="h-6 w-6 text-white hover:text-sky-300" /> <iconify-icon icon="mdi:github" class="text-3xl text-white hover:text-sky-300"></iconify-icon>
</a> </a>
<a href={npmLink}> <a href={npmLink} class="flex aspect-square">
<Archive class="h-6 w-6 text-white hover:text-sky-300" /> <iconify-icon icon="gg:npm" class="text-3xl text-white hover:text-sky-300"></iconify-icon>
</a> </a>
</div> </div>

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { Cross1, HamburgerMenu } from 'radix-icons-svelte';
import Sidebar from '../sidebar/Sidebar.svelte'; import Sidebar from '../sidebar/Sidebar.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@ -16,7 +15,7 @@
<div class="container mb-4 w-full border-b border-neutral-800 px-4 pb-1 sm:px-6 {className}"> <div class="container mb-4 w-full border-b border-neutral-800 px-4 pb-1 sm:px-6 {className}">
<button on:click={() => (enabled = !enabled)} class="text-neutral-500 hover:text-neutral-200"> <button on:click={() => (enabled = !enabled)} class="text-neutral-500 hover:text-neutral-200">
<HamburgerMenu class="h-7 w-7" /> <iconify-icon icon="ci:hamburger-lg" class="text-3xl"></iconify-icon>
</button> </button>
</div> </div>
@ -29,7 +28,7 @@
on:click={() => (enabled = false)} on:click={() => (enabled = false)}
class="absolute right-4 top-4 text-neutral-500 hover:text-neutral-200" class="absolute right-4 top-4 text-neutral-500 hover:text-neutral-200"
> >
<Cross1 class="h-4 w-4" /> <iconify-icon icon="charm:cross" class="text-2xl"></iconify-icon>
</button> </button>
</div> </div>
{/if} {/if}

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { GithubLogo, Star } from 'radix-icons-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export { className as class }; export { className as class };
@ -19,15 +18,15 @@
href="https://github.com/BearToCode/carta" href="https://github.com/BearToCode/carta"
class="flex h-12 items-center space-x-2 p-2 {className}" class="flex h-12 items-center space-x-2 p-2 {className}"
> >
<GithubLogo class="h-5 w-5" /> <iconify-icon icon="mdi:github" class="text-2xl"></iconify-icon>
<div class="hidden h-min flex-col justify-center space-y-1 md:flex"> <div class="hidden h-min flex-col justify-center space-y-1 md:flex">
<p class="text-[0.9rem] font-semibold leading-3">BearToCode/carta</p> <p class="text-[0.9rem] font-semibold leading-3">BearToCode/carta</p>
{#if loading} {#if loading}
<div class="pulse my-1.5 h-3 w-[80px] rounded-full bg-neutral-800" /> <div class="pulse my-1.5 h-3 w-[80px] rounded-full bg-neutral-800" />
{:else} {:else}
<div class="inline-flex items-center space-x-1"> <div class="inline-flex items-center space-x-1">
<Star class="h-3 w-3" /> <iconify-icon icon="ic:round-star" class="h-3 w-3"></iconify-icon>
<span class="text-[0.8rem] leading-3">{stars}</span> <span class="mt-1 text-[0.8rem] leading-3">{stars}</span>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Command from '$lib/components/ui/command'; import * as Command from '$lib/components/ui/command';
import type { Document } from 'flexsearch'; import type { Document } from 'flexsearch';
import { Enter, MagnifyingGlass } from 'radix-icons-svelte';
import { import {
enrichResult, enrichResult,
initializeSearch, initializeSearch,
@ -54,8 +53,8 @@
<svelte:window on:keydown={handleKeydown} /> <svelte:window on:keydown={handleKeydown} />
<button on:click={() => (open = !open)} class="mr-2 block md:hidden"> <button on:click={() => (open = !open)} class="mr-2 block aspect-square md:hidden">
<MagnifyingGlass class="h-7 w-7 text-neutral-200" /> <iconify-icon icon="ion:search" class="text-2xl text-neutral-200"></iconify-icon>
</button> </button>
<button <button
@ -63,7 +62,7 @@
on:click={() => (open = !open)} on:click={() => (open = !open)}
> >
<div class="inline-flex items-center space-x-2"> <div class="inline-flex items-center space-x-2">
<MagnifyingGlass class="h-5 w-5 text-neutral-500" /> <iconify-icon icon="ion:search" class="text-xl text-neutral-500"></iconify-icon>
<span class="text-neutral-500">Search...</span> <span class="text-neutral-500">Search...</span>
</div> </div>
<kbd <kbd
@ -102,7 +101,7 @@
{/if} {/if}
<div class="absolute right-2 top-1/2 hidden -translate-y-1/2 group-aria-selected:block"> <div class="absolute right-2 top-1/2 hidden -translate-y-1/2 group-aria-selected:block">
<Enter class="h-5 w-5 text-neutral-400" /> <iconify-icon icon="mi:enter" class="text-xl text-neutral-400"></iconify-icon>
</div> </div>
</Command.Item> </Command.Item>
{/each} {/each}

View file

@ -1,18 +1,4 @@
<script lang="ts"> <script lang="ts">
import {
Code,
CodesandboxLogo,
Cube,
Dashboard,
Download,
Face,
FontFamily,
Link2,
Slash,
File,
FontStyle,
Stack
} from 'radix-icons-svelte';
import SidebarLink from './SidebarLink.svelte'; import SidebarLink from './SidebarLink.svelte';
export { className as class }; export { className as class };
@ -25,75 +11,87 @@
<!-- Introduction --> <!-- Introduction -->
<SidebarLink href="/introduction"> <SidebarLink href="/introduction">
<Dashboard class="h-5 w-5" /> <iconify-icon icon="radix-icons:dashboard" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Introduction</span> <span class="text-[0.95rem]">Introduction</span>
</SidebarLink> </SidebarLink>
<!-- Examples --> <!-- Examples -->
<SidebarLink href="/examples"> <SidebarLink href="/examples">
<CodesandboxLogo class="h-5 w-5" /> <iconify-icon icon="ph:codesandbox-logo" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Examples</span> <span class="text-[0.95rem]">Examples</span>
</SidebarLink> </SidebarLink>
<!-- Getting Started --> <!-- Getting Started -->
<SidebarLink href="/getting-started"> <SidebarLink href="/getting-started">
<Download class="h-5 w-5" /> <iconify-icon icon="ic:round-download" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Getting Started</span> <span class="text-[0.95rem]">Getting Started</span>
</SidebarLink> </SidebarLink>
<!-- Editing Styles --> <!-- Editing Styles -->
<SidebarLink href="/editing-styles"> <SidebarLink href="/editing-styles">
<FontStyle class="h-5 w-5" /> <iconify-icon icon="lucide:palette" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Editing Styles</span> <span class="text-[0.95rem]">Editing Styles</span>
</SidebarLink> </SidebarLink>
<!-- Migration -->
<SidebarLink href="/migration">
<iconify-icon icon="material-symbols:upgrade" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Migration</span>
</SidebarLink>
<!-- Community Plugins --> <!-- Community Plugins -->
<SidebarLink href="/community-plugins"> <SidebarLink href="/community-plugins">
<Stack class="h-5 w-5" /> <iconify-icon icon="ph:stack-fill" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Community Plugins</span> <span class="text-[0.95rem]">Community Plugins</span>
</SidebarLink> </SidebarLink>
<!-- Using Svelte Components -->
<SidebarLink href="/using-components">
<iconify-icon icon="ri:svelte-fill" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Using Components</span>
</SidebarLink>
<h3 class="mb-3 ml-4 mt-6 text-sm font-medium first:mt-0 last:mb-0">Plugins</h3> <h3 class="mb-3 ml-4 mt-6 text-sm font-medium first:mt-0 last:mb-0">Plugins</h3>
<!-- Math --> <!-- Math -->
<SidebarLink href="/plugins/math"> <SidebarLink href="/plugins/math">
<FontFamily class="h-5 w-5" /> <iconify-icon icon="tabler:math" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Math</span> <span class="text-[0.95rem]">Math</span>
</SidebarLink> </SidebarLink>
<!-- Code --> <!-- Code -->
<SidebarLink href="/plugins/code"> <SidebarLink href="/plugins/code">
<Code class="h-5 w-5" /> <iconify-icon icon="fluent:code-16-filled" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Code</span> <span class="text-[0.95rem]">Code</span>
</SidebarLink> </SidebarLink>
<!-- Emoji --> <!-- Emoji -->
<SidebarLink href="/plugins/emoji"> <SidebarLink href="/plugins/emoji">
<Face class="h-5 w-5" /> <iconify-icon icon="mingcute:emoji-line" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Emoji</span> <span class="text-[0.95rem]">Emoji</span>
</SidebarLink> </SidebarLink>
<!-- Slash --> <!-- Slash -->
<SidebarLink href="/plugins/slash"> <SidebarLink href="/plugins/slash">
<Slash class="h-5 w-5" /> <iconify-icon icon="tabler:slash" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Slash</span> <span class="text-[0.95rem]">Slash</span>
</SidebarLink> </SidebarLink>
<!-- TikZ --> <!-- TikZ -->
<SidebarLink href="/plugins/tikz"> <SidebarLink href="/plugins/tikz">
<Cube class="h-5 w-5" /> <iconify-icon icon="mdi:draw-pen" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">TikZ</span> <span class="text-[0.95rem]">TikZ</span>
</SidebarLink> </SidebarLink>
<!-- Attachment --> <!-- Attachment -->
<SidebarLink href="/plugins/attachment"> <SidebarLink href="/plugins/attachment">
<File class="h-5 w-5" /> <iconify-icon icon="tdesign:attach" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Attachment</span> <span class="text-[0.95rem]">Attachment</span>
</SidebarLink> </SidebarLink>
<!-- Anchor --> <!-- Anchor -->
<SidebarLink href="/plugins/anchor"> <SidebarLink href="/plugins/anchor">
<Link2 class="h-5 w-5" /> <iconify-icon icon="mingcute:link-fill" class="text-xl"></iconify-icon>
<span class="text-[0.95rem]">Anchor</span> <span class="text-[0.95rem]">Anchor</span>
</SidebarLink> </SidebarLink>

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from 'cmdk-sv'; import { Command as CommandPrimitive } from 'cmdk-sv';
import { MagnifyingGlass } from 'radix-icons-svelte';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
// type $$Props = CommandPrimitive.InputProps; // type $$Props = CommandPrimitive.InputProps;
@ -11,7 +10,7 @@
</script> </script>
<div class="flex items-center border-b px-3" data-cmdk-input-wrapper=""> <div class="flex items-center border-b px-3" data-cmdk-input-wrapper="">
<MagnifyingGlass class="mr-2 h-4 w-4 shrink-0 opacity-50" /> <iconify-icon icon="ion:search" class="mr-2 shrink-0 text-xl opacity-50"></iconify-icon>
<CommandPrimitive.Input <CommandPrimitive.Input
bind:value bind:value
class={cn( class={cn(

View file

@ -2,7 +2,6 @@
import { Dialog as DialogPrimitive } from 'bits-ui'; import { Dialog as DialogPrimitive } from 'bits-ui';
import * as Dialog from '.'; import * as Dialog from '.';
import { cn, flyAndScale } from '$lib/utils'; import { cn, flyAndScale } from '$lib/utils';
import { Cross2 } from 'radix-icons-svelte';
type $$Props = DialogPrimitive.ContentProps; type $$Props = DialogPrimitive.ContentProps;
@ -27,9 +26,9 @@
> >
<slot /> <slot />
<DialogPrimitive.Close <DialogPrimitive.Close
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none" class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-3 my-auto aspect-square rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
> >
<Cross2 class="h-4 w-4" /> <iconify-icon icon="basil:cross-solid" class="text-2xl"></iconify-icon>
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>

View file

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import 'carta-md/dark.css';
import '$lib/styles/discord.scss';
import { emoji } from '@cartamd/plugin-emoji'; import { emoji } from '@cartamd/plugin-emoji';
import { code } from '@cartamd/plugin-code'; import { code } from '@cartamd/plugin-code';
import { PlusCircled } from 'radix-icons-svelte'; import PlusCircled from './assets/PlusIcon.svelte';
import '$lib/styles/discord.scss';
const carta = new Carta({ const carta = new Carta({
sanitizer: false,
disableIcons: true, disableIcons: true,
extensions: [ extensions: [
emoji(), emoji(),
@ -15,8 +16,8 @@
components: [ components: [
{ {
component: PlusCircled, component: PlusCircled,
props: { class: 'discord-plus-icon' }, parent: 'input',
parent: 'input' props: {}
} }
] ]
} }
@ -24,5 +25,4 @@
}); });
</script> </script>
<CartaEditor placeholder="Send a message to @someone" mode="tabs" theme="discord" {carta} <MarkdownEditor placeholder="Send a message to @someone" mode="tabs" theme="discord" {carta} />
></CartaEditor>

View file

@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { attachment } from '@cartamd/plugin-attachment'; import { attachment } from '@cartamd/plugin-attachment';
import { emoji } from '@cartamd/plugin-emoji'; import { emoji } from '@cartamd/plugin-emoji';
import { slash } from '@cartamd/plugin-slash'; import { slash } from '@cartamd/plugin-slash';
import { code } from '@cartamd/plugin-code'; import { code } from '@cartamd/plugin-code';
import 'carta-md/dark.css';
import '$lib/styles/github.scss'; import '$lib/styles/github.scss';
const carta = new Carta({ const carta = new Carta({
sanitizer: false,
extensions: [ extensions: [
attachment({ attachment({
async upload() { async upload() {
@ -20,7 +21,10 @@
] ]
}); });
export let value = 'This is an example inspired by [GitHub](https://github.com)'; export let value = `This is an example inspired by [GitHub](https://github.com)
\`\`\`js
console.log('Hello, World!');
\`\`\``;
</script> </script>
<CartaEditor bind:value mode="tabs" theme="github" {carta} /> <MarkdownEditor bind:value mode="tabs" theme="github" {carta} />

View file

@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor, CartaViewer } from 'carta-md'; import { Carta, MarkdownEditor, Markdown } from 'carta-md';
import placeholder from './math-stack-exchange-placeholder.tex?raw'; import placeholder from './math-stack-exchange-placeholder.tex?raw';
import { math } from '@cartamd/plugin-math'; import { math } from '@cartamd/plugin-math';
import { tikz } from '@cartamd/plugin-tikz'; import { tikz } from '@cartamd/plugin-tikz';
import 'carta-md/dark.css';
import '$lib/styles/math-stack-exchange.scss'; import '$lib/styles/math-stack-exchange.scss';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
const carta = new Carta({ const carta = new Carta({
sanitizer: false,
extensions: [ extensions: [
math(), math(),
tikz({ tikz({
@ -30,9 +31,9 @@
</script> </script>
<div class="math-stack-exchange-container"> <div class="math-stack-exchange-container">
<CartaEditor bind:value mode="tabs" theme="math-stack-exchange" {carta} /> <MarkdownEditor bind:value mode="tabs" theme="math-stack-exchange" {carta} />
{#key value} {#key value}
<CartaViewer theme="math-stack-exchange" {value} {carta} /> <Markdown theme="math-stack-exchange" {value} {carta} />
{/key} {/key}
</div> </div>

View file

@ -0,0 +1,3 @@
<div class="discord-plus-icon">
<iconify-icon icon="radix-icons:plus-circled" class="text-2xl"></iconify-icon>
</div>

View file

@ -27,6 +27,7 @@
.carta-font-code { .carta-font-code {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
caret-color: white; caret-color: white;
font-size: 1.1rem;
} }
.carta-toolbar { .carta-toolbar {
@ -41,10 +42,6 @@
height: 1.75rem; height: 1.75rem;
transform: translateX(-50%) translateY(-50%); transform: translateX(-50%) translateY(-50%);
} }
[class*='shj-lang-'] {
background: transparent;
}
} }
// Plugin emoji // Plugin emoji
@ -79,3 +76,8 @@
background: $background-contrast; background: $background-contrast;
} }
} }
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
}

View file

@ -28,6 +28,7 @@
.carta-font-code { .carta-font-code {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
caret-color: white; caret-color: white;
font-size: 1.1rem;
} }
.carta-toolbar { .carta-toolbar {
@ -115,10 +116,6 @@
} }
} }
} }
[class*='shj-lang-'] {
background: transparent;
}
} }
// Plugin emoji // Plugin emoji
@ -199,3 +196,8 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
}

View file

@ -43,9 +43,21 @@
} }
code { code {
@apply rounded; @apply rounded bg-neutral-800 px-1 text-neutral-50;
&:not([class*='language-']) { }
@apply bg-neutral-800 px-1 text-neutral-100;
p,
h1,
h2,
h3 {
code {
@apply px-1;
} }
} }
.carta-editor code {
font-family: 'Fira Code', monospace;
background: transparent;
padding: 0;
}
} }

View file

@ -34,6 +34,7 @@
.carta-font-code { .carta-font-code {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
caret-color: white; caret-color: white;
font-size: 1.1rem;
} }
.carta-toolbar { .carta-toolbar {
@ -64,10 +65,6 @@
.carta-toolbar-right { .carta-toolbar-right {
justify-content: flex-start; justify-content: flex-start;
} }
[class*='shj-lang-'] {
background: transparent;
}
} }
.carta-icons-menu { .carta-icons-menu {
@ -104,3 +101,8 @@
padding: 1rem; padding: 1rem;
} }
} }
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
}

View file

@ -54,3 +54,41 @@ export const flyAndScale = (
easing: cubicOut easing: cubicOut
}; };
}; };
export const throttle = <R, A extends unknown[]>(
fn: (...args: A) => R,
delay: number
): [(...args: A) => R | undefined, () => void] => {
let wait = false;
let timeout: undefined | number;
let cancelled = false;
return [
(...args: A) => {
if (cancelled) return undefined;
if (wait) return undefined;
const val = fn(...args);
wait = true;
timeout = window.setTimeout(() => {
wait = false;
}, delay);
return val;
},
() => {
cancelled = true;
clearTimeout(timeout);
}
];
};
export function debounce<T extends unknown[]>(cb: (...args: T) => unknown, wait = 1000) {
let timeout: NodeJS.Timeout;
return (...args: T) => {
clearTimeout(timeout);
timeout = setTimeout(() => cb(...args), wait);
};
}

View file

@ -17,9 +17,15 @@ new Carta({
}); });
``` ```
### `gfmOptions`
Type: `GfmOptions`
GitHub Flavored Markdown options.
### `extensions` ### `extensions`
Type: `CartaExtension[]` Type: `Extension[]`
List of extensions(plugins) to use. List of extensions(plugins) to use.
@ -72,9 +78,21 @@ Type: `(html: string) => void`
HTML sanitizer. See [here]({base}/getting-started#sanitization) for more details. HTML sanitizer. See [here]({base}/getting-started#sanitization) for more details.
# `CartaEditor` options ### `shikiOptions`
List of options that can be used in the `<CartaEditor>` component. Type: `ShikiOptions`
Highlighter(Shiki) options.
### `theme`
Type: `Theme | DualTheme`
Shiki theme to use to highlight Markdown.
# `MarkdownEditor` options
List of options that can be used in the `<MarkdownEditor>` component.
### `carta` ### `carta`
@ -128,13 +146,13 @@ instead.
### `labels` ### `labels`
Type: `Partial<CartaLabels>` Type: `Partial<Labels>`
Can be used to provide custom text for labels in the editor. Can be used to provide custom text for labels in the editor.
# `CartaViewer` options # `Markdown` options
List of options that can be used in the `<CartaViewer>` component. List of options that can be used in the `<Markdown>` component.
### `carta` ### `carta`

View file

@ -7,14 +7,14 @@ title: Extension
import Code from '$lib/components/code/Code.svelte'; import Code from '$lib/components/code/Code.svelte';
</script> </script>
# `CartaExtension` properties # `Plugin` properties
You can easily extend Carta by creating custom plugins. You can easily extend Carta by creating custom plugins.
<Code> <Code>
```ts ```ts
const ext: CartaExtension = { const ext: Plugin = {
// ... // ...
}; };
@ -25,11 +25,47 @@ const carta = new Carta({
</Code> </Code>
Here are all the `CartaExtension` properties: Here are all the `Plugin` properties:
### `markedExtensions` ### `transformers`
List of marked extensions. For more information check out [Marked docs](https://marked.js.org/using_pro). Type: `UnifiedTransformer`
Remark or Rehype transformers.
#### `UnifiedTransformer.execution`
Type: `'sync' | 'async'`
If you specify async, this transformer won't be available for SSR.
#### `UnifiedTransformer.type`
Type: `'remark' | 'rehype'`
This determines at which step the transformer will operate, whether on Remark, on a Markdown-based syntax tree, or Rehype, on a HTML-based one.
#### `UnifiedTransformer.transform`
Type: `({ processor, carta }) => void`
The actual processor, can be async if the execution is specified as such.
<Code>
```ts
{
execution: 'sync',
type: 'rehype',
transform({ processor }) {
processor
.use(rehypeSlug)
.use(rehypeAutolinkHeadings);
}
}
```
</Code>
### `shortcuts` ### `shortcuts`
@ -63,7 +99,7 @@ Set of keys, corresponding to the `e.key` of `KeyboardEvent`s, but lowercase.
#### `KeyboardShortcut.action` #### `KeyboardShortcut.action`
Type: `(input: CartaInput) => void` Type: `(input: InputEnhancer) => void`
Shortcut callback. Shortcut callback.
@ -73,14 +109,14 @@ Prevent saving the current state in history.
### `icons` ### `icons`
Type: `CartaIcon[]` Type: `Icon[]`
Additional toolbar icons. For example: Additional toolbar icons. For example:
<Code> <Code>
```ts ```ts
const icon: CartaIcon = { const icon: Icon = {
id: 'heading', id: 'heading',
action: (input) => input.toggleLinePrefix('###'), action: (input) => input.toggleLinePrefix('###'),
component: HeadingIcon component: HeadingIcon
@ -89,19 +125,19 @@ const icon: CartaIcon = {
</Code> </Code>
#### `CartaIcon.id` #### `Icon.id`
Type: `string` Type: `string`
Id of the icon. Id of the icon.
#### `CartaIcon.action` #### `Icon.action`
Type: `(input: CartaInput) => void` Type: `(input: InputEnhancer) => void`
Click callback. Click callback.
#### `CartaIcon.component` #### `Icon.component`
Type: `ComponentType` (SvelteComponent) Type: `ComponentType` (SvelteComponent)
@ -162,15 +198,15 @@ const prefix: Prefix = {
### `listeners` ### `listeners`
Type: `CartaListener[]` Type: `Listener[]`
Textarea event listeners. Has an additional `carta-render` and `carta-render-ssr` events keys. Textarea event listeners. Has an additional `carta-render` and `carta-render-ssr` events keys.
<Code> <Code>
```ts ```ts
const click: CartaListener = ['click', () => console.log('I was clicked!')]; const click: Listener = ['click', () => console.log('I was clicked!')];
const render: CartaListener = [ const render: Listener = [
'carta-render', 'carta-render',
(e) => { (e) => {
const carta = e.detail.carta; const carta = e.detail.carta;
@ -186,33 +222,39 @@ const render: CartaListener = [
### `components` ### `components`
Type: `CartaExtensionComponent[]` Type: `ExtensionComponent[]`
Additional components to be added to the editor or viewer. Additional components to be added to the editor or viewer.
#### `CartaExtensionComponent<T>.component` #### `ExtensionComponent<T>.component`
Type: `typeof SvelteComponentTyped<T & { carta: Carta }>` Type: `typeof SvelteComponentTyped<T & { carta: Carta }>`
Svelte components that exports `carta: Carta` and all the other properties specified as the generic parameter and in `props`. Svelte components that exports `carta: Carta` and all the other properties specified as the generic parameter and in `props`.
#### `CartaExtensionComponent<T>.props` #### `ExtensionComponent<T>.props`
Type: `T` Type: `T`
Properties that will be handed to the component. Properties that will be handed to the component.
#### `CartaExtensionComponent<T>.parent` #### `ExtensionComponent<T>.parent`
Type: `MaybeArray<'editor' | 'input' | 'renderer' | 'preview'>` Type: `MaybeArray<'editor' | 'input' | 'renderer' | 'preview'>`
Where the element will be placed. Where the element will be placed.
### `grammarRules`
Type: `GrammarRule[]`
Custom Markdown TextMate grammar rules for Shiki. They will be injected into the language.
### `highlightRules` ### `highlightRules`
Type: `ShjLanguageDefinition` Type: `HighlightingRule[]`
Custom markdown highlighting rules. See [Speed-Highlight Wiki](https://github.com/speed-highlight/core/wiki/Create-or-suggest-new-languages) for more info. Custom highlighting rules for ShiKi. They will be injected into the selected theme.
### `onLoad` ### `onLoad`

View file

@ -40,3 +40,52 @@ Svelte action that allows you to bind a specific element to the caret position.
<!-- ... --> <!-- ... -->
</div> </div>
``` ```
## `Carta.highlighter`
Get the Shiki highlighter.
```ts
const highlighter = await carta.highlighter();
const userTheme = carta.theme;
```
Here are some other highlight related utilities:
### `isBundleLanguage`
Checks if a language is a bundled language.
```ts
export const isBundleLanguage = (lang: string): lang is BundledLanguage;
```
### `isBundleTheme`
Checks if a theme is a bundled theme.
```ts
export const isBundleTheme = (theme: string): theme is BundledTheme;
```
### `isDualTheme`
Checks if a theme is a dual theme.
```ts
export const isDualTheme = (theme: Theme | DualTheme): theme is DualTheme;
```
### `isSingleTheme`
```ts
export const isSingleTheme = (theme: Theme | DualTheme): theme is Theme;
```
### `isThemeRegistration`
Checks if a theme is a theme registration.
```ts
export const isThemeRegistration = (theme: Theme): theme is ThemeRegistration;
```

View file

@ -43,11 +43,68 @@ While the core styles are embedded in the Svelte components, the others can be s
### Using multiple themes ### 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 `<MarkdownEditor>` you can differentiate the themes of multiple editors.
## Changing Markdown color theme ## Dark mode
Carta uses [Speed Highlight JS](https://github.com/speed-highlight/core) for syntax highlighting. Two default themes are included in the core package, `light.css` and `dark.css`, and others can be found on the Speed Highlight [GitHub](https://github.com/speed-highlight/core/tree/main/src/themes), but you can also easily create your own. When using dark mode, there are two different themes that have to be changed: the editor theme and the one used for syntax highlighting:
<Code>
```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;
}
```
</Code>
## 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:
<Code>
```ts
const carta = new Carta({
// ...
theme: 'github-dark'
});
```
</Code>
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:
<Code>
```ts
const carta = new Carta({
// ...
shikiOptions: {
langs: // ...
themes: // ...
}
})
```
</Code>
## Markdown stylesheets ## Markdown stylesheets

View file

@ -37,9 +37,8 @@ Setup a basic editor:
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import 'carta-md/default.css'; /* Default theme */ import 'carta-md/default.css'; /* Default theme */
import 'carta-md/light.css'; /* Markdown input theme */
const carta = new Carta({ const carta = new Carta({
// Remember to use a sanitizer to prevent XSS attacks! // Remember to use a sanitizer to prevent XSS attacks!
@ -50,12 +49,13 @@ Setup a basic editor:
let value = ''; let value = '';
</script> </script>
<CartaEditor {carta} bind:value /> <MarkdownEditor {carta} bind:value />
<style> <style>
/* Set your custom monospace font */ /* Set your custom monospace font */
:global(.carta-font-code) { :global(.carta-font-code) {
font-family: '...', monospace; font-family: '...', monospace;
font-size: 1.1rem;
} }
</style> </style>
``` ```
@ -68,7 +68,7 @@ Or, if you just want to render content:
```svelte ```svelte
<script> <script>
import { Carta, CartaViewer } from 'carta-md'; import { Carta, Markdown } from 'carta-md';
const carta = new Carta({ const carta = new Carta({
/* ... */ /* ... */
@ -77,7 +77,7 @@ Or, if you just want to render content:
let value = '...'; let value = '...';
</script> </script>
<CartaViewer {carta} {value} /> <Markdown {carta} {value} />
``` ```
</Code> </Code>
@ -102,7 +102,7 @@ Since Carta operates both on the server and the client, you'd need a sanitizer a
let value = ''; let value = '';
</script> </script>
<CartaEditor {carta} bind:value /> <MarkdownEditor {carta} bind:value />
``` ```
</Code> </Code>

View file

@ -5,21 +5,21 @@ section: Overview
<script> <script>
import * as Card from "$lib/components/ui/card"; import * as Card from "$lib/components/ui/card";
import * as Icon from "radix-icons-svelte";
</script> </script>
> Swiftly edit and render Markdown, with no overhead. > Modern, lightweight, powerful Markdown Editor.
Carta is a lightweight, fast and extensible Svelte Markdown editor and viewer, designed for flexibility. It works natively in SvelteKit, and supports Server Side Rendering. Carta is a lightweight, fast and extensible Svelte Markdown editor and viewer, designed for flexibility. It works natively in SvelteKit, and supports Server Side Rendering.
## Features ## Features
- **Lightweight**: no code editor is included, just a textarea with syntax highlighting, with Markdown related utilities. - 🌈 Markdown syntax highlighting ([Shiki](https://shiki.style/));
- **SSR compatible**: works great with SvelteKit. - 🛠️ Toolbar (extensible);
- **Keyboard shortcuts**: extensible and configurable. - ⌨️ Keyboard **shortcuts** (extensible);
- **Toolbar**: add or remove buttons according to your needs. - 📦 Supports **[+150 plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins)** thanks to remark.
- **Plugins friendly**: easily create your own extension. - 🔀 Scroll sync;
- **Accessibility**: includes ARIA roles, arrow keys navigation and labels. - ✅ Accessibility friendly;
- 🖥️ **SSR** compatible;
## Official Plugins ## Official Plugins
@ -31,7 +31,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/math"> <Card.Root href="/plugins/math">
<Card.Header> <Card.Header>
<Icon.FontFamily class="w-8 h-8 text-sky-300" /> <iconify-icon icon="tabler:math" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Math</Card.Title> <Card.Title>Math</Card.Title>
<Card.Description>Support for KaTex expressions.</Card.Description> <Card.Description>Support for KaTex expressions.</Card.Description>
</Card.Header> </Card.Header>
@ -39,7 +39,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/code"> <Card.Root href="/plugins/code">
<Card.Header> <Card.Header>
<Icon.Code class="w-8 h-8 text-sky-300" /> <iconify-icon icon="fluent:code-16-filled" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Code</Card.Title> <Card.Title>Code</Card.Title>
<Card.Description>Code blocks syntax highlighting.</Card.Description> <Card.Description>Code blocks syntax highlighting.</Card.Description>
</Card.Header> </Card.Header>
@ -47,7 +47,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/emoji"> <Card.Root href="/plugins/emoji">
<Card.Header> <Card.Header>
<Icon.Face class="w-8 h-8 text-sky-300" /> <iconify-icon icon="mingcute:emoji-line" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Emoji</Card.Title> <Card.Title>Emoji</Card.Title>
<Card.Description>Embed emojis in Markdown.</Card.Description> <Card.Description>Embed emojis in Markdown.</Card.Description>
</Card.Header> </Card.Header>
@ -55,7 +55,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/slash"> <Card.Root href="/plugins/slash">
<Card.Header> <Card.Header>
<Icon.Slash class="w-8 h-8 text-sky-300" /> <iconify-icon icon="tabler:slash" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Slash</Card.Title> <Card.Title>Slash</Card.Title>
<Card.Description>Support for slash commands.</Card.Description> <Card.Description>Support for slash commands.</Card.Description>
</Card.Header> </Card.Header>
@ -63,7 +63,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/tikz"> <Card.Root href="/plugins/tikz">
<Card.Header> <Card.Header>
<Icon.Cube class="w-8 h-8 text-sky-300" /> <iconify-icon icon="mdi:draw-pen" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>TikZ</Card.Title> <Card.Title>TikZ</Card.Title>
<Card.Description>Support for TikZ/PgfPlots diagrams.</Card.Description> <Card.Description>Support for TikZ/PgfPlots diagrams.</Card.Description>
</Card.Header> </Card.Header>
@ -71,7 +71,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/attachment"> <Card.Root href="/plugins/attachment">
<Card.Header> <Card.Header>
<Icon.File class="w-8 h-8 text-sky-300" /> <iconify-icon icon="tdesign:attach" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Attachment</Card.Title> <Card.Title>Attachment</Card.Title>
<Card.Description>Handle text attachments.</Card.Description> <Card.Description>Handle text attachments.</Card.Description>
</Card.Header> </Card.Header>
@ -79,7 +79,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/plugins/anchor"> <Card.Root href="/plugins/anchor">
<Card.Header> <Card.Header>
<Icon.Link2 class="w-8 h-8 text-sky-300" /> <iconify-icon icon="mingcute:link-fill" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Anchor</Card.Title> <Card.Title>Anchor</Card.Title>
<Card.Description>Add anchor links to headings.</Card.Description> <Card.Description>Add anchor links to headings.</Card.Description>
</Card.Header> </Card.Header>
@ -87,7 +87,7 @@ Carta comes with a set of official plugins for the most common use cases.
<Card.Root href="/community-plugins"> <Card.Root href="/community-plugins">
<Card.Header> <Card.Header>
<Icon.Stack class="w-8 h-8 text-sky-300" /> <iconify-icon icon="ph:stack-fill" class="text-3xl text-sky-300"></iconify-icon>
<Card.Title>Community Plugins</Card.Title> <Card.Title>Community Plugins</Card.Title>
<Card.Description>Explore plugins from the community.</Card.Description> <Card.Description>Explore plugins from the community.</Card.Description>
</Card.Header> </Card.Header>
@ -105,7 +105,7 @@ A list of examples inspired by popular platforms.
<Card.Root href="/examples#github"> <Card.Root href="/examples#github">
<Card.Header> <Card.Header>
<Icon.GithubLogo class="w-8 h-8 text-sky-300" /> <iconify-icon icon="mdi:github" class="text-3xl text-sky-300" ></iconify-icon>
<Card.Title>GitHub</Card.Title> <Card.Title>GitHub</Card.Title>
<Card.Description>Inspired by GitHub.</Card.Description> <Card.Description>Inspired by GitHub.</Card.Description>
</Card.Header> </Card.Header>
@ -113,7 +113,7 @@ A list of examples inspired by popular platforms.
<Card.Root href="/examples#discord"> <Card.Root href="/examples#discord">
<Card.Header> <Card.Header>
<Icon.DiscordLogo class="w-8 h-8 text-sky-300" /> <iconify-icon icon="ic:baseline-discord" class="text-3xl text-sky-300" ></iconify-icon>
<Card.Title>Discord</Card.Title> <Card.Title>Discord</Card.Title>
<Card.Description>Inspired by Discord.</Card.Description> <Card.Description>Inspired by Discord.</Card.Description>
</Card.Header> </Card.Header>
@ -121,7 +121,7 @@ A list of examples inspired by popular platforms.
<Card.Root href="/examples#math-stack-exchange"> <Card.Root href="/examples#math-stack-exchange">
<Card.Header> <Card.Header>
<Icon.Cube class="w-8 h-8 text-sky-300" /> <iconify-icon icon="fluent:math-formula-16-filled" class="text-3xl text-sky-300" ></iconify-icon>
<Card.Title>Math Stack Exchange</Card.Title> <Card.Title>Math Stack Exchange</Card.Title>
<Card.Description>Inspired by Math Stack Exchange.</Card.Description> <Card.Description>Inspired by Math Stack Exchange.</Card.Description>
</Card.Header> </Card.Header>

View file

@ -0,0 +1,57 @@
---
title: Migration Guide
section: Overview
---
# Major Changes
## Removal of Marked
Marked has been replaced with a combination of Unified, Remark and Rehype. If you previously used a custom plugin with it, you'll have to update it manually. Otherwise, all builtin plugins have already been updated. Make sure to **update** them!
Some plugins now have a different implementation and their options have changed. Those plugins are [plugin-math](https://beartocode.github.io/carta/plugins/math) and [plugin-anchor](https://beartocode.github.io/carta/plugins/anchor).
## Syntax highlighter update
SpeedHighlight has been replaced with [Shiki](https://shiki.matsu.io/). It now offers support for more languages, themes, and extensibility.
Make sure to remove previous themes imports, as Shiki uses JS based ones.
```ts
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-'] {
/* ... */
}
```
## Removed verbose prefixes
Many exports have been renamed to make them less verbose:
- `CartaEditor` -> `MarkdownEditor` (old one still supported);
- `CartaRenderer` -> `Markdown` (old one still supported);
- `CartaEvent` -> `Event`;
- `CartaEventType` -> `EventType`;
- `CartaExtension` -> `Plugin`;
- `CartaExtensionComponent` -> `ExtensionComponent`;
- `CartaOptions` -> `Options`;
- `CartaHistory` -> `TextAreaHistory`;
- `CartaHistoryOptions` -> `TextAreaHistoryOptions`;
- `CartaIcon` -> `Icon`;
- `CartaListener` -> `Listener`;
- `CartaInput` -> `InputEnhancer`;
- `CartaRenderer` -> `Renderer`;
- `CartaLabels` -> `Labels`;
# Minor Changes
- If you don't use a sanitizer, you need to explicitly set it to `false`;
- Removed deprecated option `cartaRef` and `shjRef` for extensions;
- Removed deprecated options `postProcess` for `plugin-tikz`;
- `Carta.options` are no longer available.

View file

@ -39,7 +39,7 @@ import '@cartamd/plugin-anchor/default.css';
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { anchor } from '@cartamd/plugin-anchor'; import { anchor } from '@cartamd/plugin-anchor';
const carta = new Carta({ const carta = new Carta({
@ -47,7 +47,7 @@ import '@cartamd/plugin-anchor/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>
@ -59,8 +59,12 @@ Here are the options you can pass to `anchor()`:
```ts ```ts
export interface AnchorExtensionOptions { export interface AnchorExtensionOptions {
/** /**
* Maximum depth of headers to generate anchors for. Defaults to 6. * rehype-slug options.
*/ */
maxDepth?: number; slug?: SlugOptions;
/**
* rehype-autolink-headings options.
*/
autolink?: AutolinkOptions;
} }
``` ```

View file

@ -35,7 +35,7 @@ import '@cartamd/plugin-attachment/default.css';
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { attachment } from '@cartamd/plugin-attachment'; import { attachment } from '@cartamd/plugin-attachment';
const carta = new Carta({ const carta = new Carta({
@ -49,7 +49,7 @@ import '@cartamd/plugin-attachment/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>

View file

@ -7,8 +7,7 @@ title: Code
import Code from '$lib/components/code/Code.svelte'; import Code from '$lib/components/code/Code.svelte';
</script> </script>
This plugin adds support for code blocks **syntax highlighting**. This plugin adds support for code blocks **syntax highlighting**. It uses the same highlighter from the core package(Shiki).
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.
## Installation ## Installation
@ -36,34 +35,34 @@ import '@cartamd/plugin-code/default.css';
### Using the default highlighter ### 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. 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.
The theme is the same as the one used in the main Carta package (`carta-md/light.css` or `carta-md/dark.css`).
[Here](https://github.com/speed-highlight/core/tree/main/src/themes) you can find other themes.
### Using a custom highlighter
You can also provide a custom highlighter, that can be either sync or async.
<Code> <Code>
```ts ```ts
code({ const carta = new Carta({
customHighlight: { // ...
highlighter: (code, lang) => myCustomHighlighter(code, lang), extensions: [
langPrefix: 'my-highlighter-' code({
} theme: 'ayu-light'
})
]
}); });
``` ```
</Code> </Code>
### Using a custom highlighter
It is no longer possible to specify a custom highlighter in this plugin. However, there are many different [Remark plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins) that provide syntax highlighting.
### Extension ### Extension
<Code> <Code>
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { code } from '@cartamd/plugin-code'; import { code } from '@cartamd/plugin-code';
const carta = new Carta({ const carta = new Carta({
@ -71,46 +70,11 @@ code({
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>
## Options ## Options
Here are the options you can pass to `code()`: The options you can pass to `code()` extend the ones provided by [Shiki](https://shiki.matsu.io/guide/transformers).
```ts
interface CodeExtensionOptions {
/**
* Default language when none is provided.
*/
defaultLanguage?: string;
/**
* Whether to autodetect a language when none is provided.
* Overwritten by `defaultLanguage`.
*/
autoDetect?: string;
/**
* Line numbering.
* @defaults false.
*/
lineNumbering?: boolean;
/**
* Options for custom syntax highlighting.
*/
customHighlight?: {
/**
* Custom highlight function. Beware that you'll have to provide your own styles.
* This function needs to convert a string of code into html.
*/
highlighter: (code: string, lang: string) => string | Promise<string>;
/**
* The language tag found immediately after the code block opening marker is
* appended to this to form the class attribute added to the `<code>` element.
*/
langPrefix: string;
};
}
```

View file

@ -39,7 +39,7 @@ import '@cartamd/plugin-emoji/default.css';
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { emoji } from '@cartamd/plugin-emoji'; import { emoji } from '@cartamd/plugin-emoji';
const carta = new Carta({ const carta = new Carta({
@ -47,7 +47,7 @@ import '@cartamd/plugin-emoji/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>

View file

@ -5,7 +5,7 @@ title: Math
<script> <script>
import Code from '$lib/components/code/Code.svelte'; import Code from '$lib/components/code/Code.svelte';
import { CartaViewer, Carta } from 'carta-md'; import { Markdown, Carta } from 'carta-md';
import { math } from '@cartamd/plugin-math'; import { math } from '@cartamd/plugin-math';
import 'katex/dist/katex.css'; import 'katex/dist/katex.css';
@ -78,7 +78,7 @@ or by using a content delivery network:
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { math } from '@cartamd/plugin-math'; import { math } from '@cartamd/plugin-math';
const carta = new Carta({ const carta = new Carta({
@ -86,7 +86,7 @@ or by using a content delivery network:
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>
@ -103,7 +103,7 @@ Pythagorean theorem: $a^2+b^2=c^2$
</Code> </Code>
<CartaViewer {carta} value={inline} /> <Markdown {carta} value={inline} />
<br> <br>
@ -119,7 +119,7 @@ $$
</Code> </Code>
<CartaViewer {carta} value={block} /> <Markdown {carta} value={block} />
## Options ## Options
@ -131,7 +131,6 @@ interface MathExtensionOptions {
* Options for inline katex, eg: $a^2+b^2=c^2$ * Options for inline katex, eg: $a^2+b^2=c^2$
*/ */
inline?: { inline?: {
katexOptions?: KatexOptions;
/** /**
* @default control+m * @default control+m
*/ */
@ -144,23 +143,18 @@ interface MathExtensionOptions {
* $$ * $$
*/ */
block?: { block?: {
/**
* Tag the generated katex will be put into. Must have `display: block`.
*/
tag?: string;
/**
* Whether to center the generated expression.
* @default true
*/
center?: boolean;
/**
* Class for generated katex.
*/
class?: string;
/** /**
* @default ctrl+shift+m * @default ctrl+shift+m
*/ */
shortcut?: Set<string>; shortcut?: Set<string>;
katexOptions?: KatexOptions;
}; };
/**
* Options for remark-math
*/
remarkMath?: RemarkMathOptions;
/**
* Options for rehype-katex
*/
rehypeKatex?: RehypeKatexOptions;
}
``` ```

View file

@ -39,7 +39,7 @@ import '@cartamd/plugin-slash/default.css';
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { slash } from '@cartamd/plugin-slash'; import { slash } from '@cartamd/plugin-slash';
const carta = new Carta({ const carta = new Carta({
@ -47,7 +47,7 @@ import '@cartamd/plugin-slash/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>

View file

@ -20,7 +20,7 @@ npm i @cartamd/plugin-tikz
## Important Notes ## Important Notes
1. This plugin requires the import of a **heavy** library (~7Mb), which is dynamically imported at runtime; 1. This plugin requires the import of a **heavy** library (~7Mb), which is dynamically imported at runtime;
2. Generated images are **not ssr compatible**, as they are rendered in the browser; 2. Generated images are **not SSR compatible**, as they are rendered in the browser;
3. You need to update your sanitizer to allow the specific tag: `<div type="text/tikz">`. 3. You need to update your sanitizer to allow the specific tag: `<div type="text/tikz">`.
## Setup ## Setup
@ -29,7 +29,7 @@ npm i @cartamd/plugin-tikz
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { tikz } from '@cartamd/plugin-tikz'; import { tikz } from '@cartamd/plugin-tikz';
import '@cartamd/plugin-tikz/fonts.css'; import '@cartamd/plugin-tikz/fonts.css';
@ -38,7 +38,7 @@ npm i @cartamd/plugin-tikz
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
</Code> </Code>

View file

@ -0,0 +1,217 @@
---
title: Using Svelte Components
section: Overview
---
<script>
import Code from '$lib/components/code/Code.svelte';
</script>
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:
<Code>
```shell
npm i unist-util-visit
# Types
npm i -D unified hast
```
</Code>
Let's create a Unified plugin. The basic structure of a plugin is the following:
<Code>
```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
}
}
}
```
</Code>
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:
<Code>
```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];
});
};
};
```
</Code>
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
<span type="hashtag" value="pizza"> #pizza </span>
```
### Configuring the transformer
Unified plugins need to be wrapped inside a `UnifiedTransformer` type, to be able to be used in Carta.
<Code>
```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);
}
};
```
</Code>
### Mounting the components
We now want to replace the generated hashtag placeholders with the following element:
<Code>
```svelte
<!-- Hashtag.svelte -->
<script>
export let value;
</script>
<button
on:click={() => {
console.log('Hashtag clicked!');
}}
>
#{value}
</button>
```
</Code>
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.
<Code>
```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();
}
}
];
```
</Code>
### Using the plugin
Let's now create a Plugin with the transformer and the listener:
<Code>
```ts
import type { Plugin } from 'carta-md';
export const hashtag = (): Plugin => ({
transformers: [hashtagTransformer],
listeners: [convertHashtags]
});
```
</Code>
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).

View file

@ -4,8 +4,8 @@
import Navbar from '$lib/components/navbar/Navbar.svelte'; import Navbar from '$lib/components/navbar/Navbar.svelte';
import Sidebar from '$lib/components/sidebar/Sidebar.svelte'; import Sidebar from '$lib/components/sidebar/Sidebar.svelte';
import Footer from '$lib/components/footer/Footer.svelte'; import Footer from '$lib/components/footer/Footer.svelte';
import '../app.postcss';
import { base } from '$app/paths'; import { base } from '$app/paths';
import '../app.postcss';
</script> </script>
<Navbar /> <Navbar />
@ -24,6 +24,6 @@
<slot /> <slot />
<Footer /> <Footer />
</main> </main>
<HeaderTracker class="sticky top-24 hidden w-[30rem] xl:block" /> <HeaderTracker class="sticky top-24 hidden w-[15rem] flex-shrink-0 xl:block" />
</div> </div>
</div> </div>

View file

@ -1 +1,3 @@
import 'iconify-icon'; // Register iconify web components
export const prerender = true; export const prerender = true;

View file

@ -2,10 +2,10 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { onMount, type SvelteComponent } from 'svelte'; import { onMount, type SvelteComponent } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { base } from '$app/paths';
import '$lib/styles/markdown.scss'; import '$lib/styles/markdown.scss';
import '$lib/styles/coldark.scss'; import '$lib/styles/coldark.scss';
import { base } from '$app/paths';
export let data: PageData; export let data: PageData;

View file

@ -1,31 +1,25 @@
<div align="right"> <div align="right">
<a href="https://www.npmjs.com/package/carta-md"> <a href="https://www.npmjs.com/package/carta-md">
<img src="https://img.shields.io/npm/v/carta-md?color=0384fc&labelColor=171d27&logo=npm&logoColor=white" alt="npm"> <img src="https://img.shields.io/npm/v/carta-md?color=ff7cc6&labelColor=171d27&logo=npm&logoColor=white" alt="npm">
</a> </a>
<a href="https://bundlephobia.com/package/carta-md"> <a href="https://bundlephobia.com/package/carta-md">
<img src="https://img.shields.io/bundlephobia/min/carta-md?color=0384fc&labelColor=171d27&logo=javascript&logoColor=white" alt="bundle"> <img src="https://img.shields.io/bundlephobia/min/carta-md?color=4dacfa&labelColor=171d27&logo=javascript&logoColor=white" alt="bundle">
</a> </a>
<a href="https://github.com/BearToCode/carta/blob/master/LICENSE"> <a href="https://github.com/BearToCode/carta/blob/master/LICENSE">
<img src="https://img.shields.io/npm/l/carta-md?color=0384fc&labelColor=171d27&logo=git&logoColor=white" alt="license"> <img src="https://img.shields.io/npm/l/carta-md?color=71d58a&labelColor=171d27&logo=git&logoColor=white" alt="license">
</a> </a>
<a href="http://beartocode.github.io/carta/"> <a href="http://beartocode.github.io/carta/">
<img src="https://img.shields.io/readthedocs/carta?logo=svelte&color=0384fc&logoColor=ffffff&labelColor=171d27" alt="docs"> <img src="https://img.shields.io/readthedocs/carta?logo=svelte&color=b581fd&logoColor=ffffff&labelColor=171d27" alt="docs">
</a> </a>
</div> </div>
<div align="center"> [![Carta.png](https://i.postimg.cc/nV6DMXKM/Carta.png)](https://beartocode.github.io/carta/)
<a href="https://beartocode.github.io/carta/">
<img alt="banner" src="https://i.postimg.cc/1XPm8FSD/Frame-8.png">
</a>
</div>
<br> <h1 align="center"><strong>Carta</strong></h1>
<div align="center">Modern, lightweight, powerful Markdown Editor.</div>
<div align="center"><strong>Carta</strong></div>
<div align="center">Swiftly edit and render Markdown, with no overhead.</div>
<br /> <br />
<div align="center"> <div align="center">
<a href="https://beartocode.github.io/carta/">Documentation</a> <a href="https://beartocode.github.io/carta/">📚 Documentation</a>
<span> · </span> <span> · </span>
<a href="https://github.com/BearToCode/carta">GitHub</a> <a href="https://github.com/BearToCode/carta">GitHub</a>
</div> </div>
@ -34,38 +28,81 @@
# Introduction # Introduction
Carta is a **lightweight**, **fast** and **extensible** Svelte Markdown editor and viewer, based on [Marked](https://github.com/markedjs/marked). Check out the [examples](http://beartocode.github.io/carta/examples) to see it in action. > **NOTE**:
Differently from most editors, Carta includes neither ProseMirror nor CodeMirror, allowing for an extremely small bundle size and fast loading time. > Carta has recently been updated to `v4`, which features numerous major changes.
>
> Follow the [Migration Guide](http://beartocode.github.io/carta/migration) to update your project.
Carta is a **lightweight**, **fast** and **extensible** Svelte Markdown editor and viewer. It is powered by [unified](https://github.com/unifiedjs/unified), [remark](https://github.com/remarkjs/remark) and [rehype](https://github.com/rehypejs/rehype). Check out the [examples](http://beartocode.github.io/carta/examples) to see it in action.
Differently from most editors, Carta does not include a code editor, but it is _just_ a textarea with syntax highlighting, shortcuts and more.
## Features ## Features
- Keyboard **shortcuts** (extensible); - 🌈 Markdown syntax highlighting ([Shiki](https://shiki.style/));
- Toolbar (extensible); - 🛠️ Toolbar (extensible);
- Markdown syntax highlighting; - ⌨️ Keyboard **shortcuts** (extensible);
- Scroll sync; - 📦 Supports **[150+ plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins)** thanks to remark;
- **SSR** compatible; - 🔀 Scroll sync;
- **Katex** support (plugin); - ✅ Accessibility friendly;
- **Slash** commands (plugin); - 🖥️ **SSR** compatible;
- **Emojis**, with included search (plugin); - ⚗️ **KaTeX** support (plugin);
- **Tikz** support(plugin); - 🔨 **Slash** commands (plugin);
- **Attachment** support(plugin); - 😄 **Emojis**, with included search (plugin);
- Code blocks **syntax highlighting** (plugin). - ✏️ **TikZ** support (plugin);
- 📂 **Attachment** support (plugin);
- ⚓ **Anchor** links in headings (plugin);
- 🌈 Code blocks **syntax highlighting** (plugin).
## Packages
| Package | Status | Docs |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [carta-md](https://www.npmjs.com/package/carta-md) | ![carta-md](https://img.shields.io/npm/v/carta-md) | [/](https://beartocode.github.io/carta/introduction) |
| [plugin-math](https://www.npmjs.com/package/@cartamd/plugin-math) | ![plugin-math](https://img.shields.io/npm/v/@cartamd/plugin-math) | [/plugins/math](https://beartocode.github.io/carta/plugins/math) |
| [plugin-code](https://www.npmjs.com/package/@cartamd/plugin-code) | ![plugin-code](https://img.shields.io/npm/v/@cartamd/plugin-code) | [/plugins/code](https://beartocode.github.io/carta/plugins/code) |
| [plugin-emoji](https://www.npmjs.com/package/@cartamd/plugin-emoji) | ![plugin-emoji](https://img.shields.io/npm/v/@cartamd/plugin-emoji) | [/plugins/emoji](https://beartocode.github.io/carta/plugins/emoji) |
| [plugin-slash](https://www.npmjs.com/package/@cartamd/plugin-slash) | ![plugin-slash](https://img.shields.io/npm/v/@cartamd/plugin-slash) | [/plugins/slash](https://beartocode.github.io/carta/plugins/slash) |
| [plugin-tikz](https://www.npmjs.com/package/@cartamd/plugin-tikz) | ![plugin-tikz](https://img.shields.io/npm/v/@cartamd/plugin-tikz) | [/plugins/tikz](https://beartocode.github.io/carta/plugins/tikz) |
| [plugin-attachment](https://www.npmjs.com/package/@cartamd/plugin-attachment) | ![plugin-attachment](https://img.shields.io/npm/v/@cartamd/plugin-attachment) | [/plugins/attachment](https://beartocode.github.io/carta/plugins/attachment) |
| [plugin-anchor](https://www.npmjs.com/package/@cartamd/plugin-anchor) | ![plugin-anchor](https://img.shields.io/npm/v/@cartamd/plugin-anchor) | [/plugins/anchor](https://beartocode.github.io/carta/plugins/anchor) |
## Community plugins
| Plugin | Description |
| ----------------------------------------------------------------------------- | ---------------------------------- |
| [carta-plugin-video](https://github.com/maisonsmd/carta-plugin-video) | Render online videos |
| [carta-plugin-imsize](https://github.com/maisonsmd/carta-plugin-imsize) | Render images in specific sizes |
| [carta-plugin-subscript](https://github.com/maisonsmd/carta-plugin-subscript) | Render subscripts and superscripts |
| [carta-plugin-ins-del](https://github.com/maisonsmd/carta-plugin-ins-del) | `<ins>` and `<del>` tags support |
# Getting started # Getting started
> **Warning** > **WARNING**
> Sanitization is not dealt with by Carta. You need to provide a `sanitizer` in the options. > Sanitization is not dealt with by Carta. You need to provide a `sanitizer` in the options.
> Common sanitizers are [isomorphic-dompurify](https://www.npmjs.com/package/isomorphic-dompurify) (suggested) and [sanitize-html](https://www.npmjs.com/package/sanitize-html). > Common sanitizers are [isomorphic-dompurify](https://www.npmjs.com/package/isomorphic-dompurify) (suggested) and [sanitize-html](https://www.npmjs.com/package/sanitize-html).
> Checkout the documentation for an example.
## Installation
Core package:
```
npm i carta-md
```
Plugins:
```
npm i @cartamd/plugin-name
```
## Basic configuration ## Basic configuration
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
// Component default theme // Component default theme
import 'carta-md/default.css'; import 'carta-md/default.css';
// Markdown input theme (Speed Highlight)
import 'carta-md/light.css';
const carta = new Carta({ const carta = new Carta({
// Remember to use a sanitizer to prevent XSS attacks // Remember to use a sanitizer to prevent XSS attacks
@ -73,13 +110,14 @@ Differently from most editors, Carta includes neither ProseMirror nor CodeMirror
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
<style> <style>
/* Or in global stylesheet */ /* Or in global stylesheet */
/* Set your custom monospace font */ /* Set your custom monospace font */
:global(.carta-font-code) { :global(.carta-font-code) {
font-family: '...', monospace; font-family: '...', monospace;
font-size: 1.1rem;
} }
</style> </style>
``` ```
@ -99,7 +137,33 @@ For the full documentation, examples, guides and more checkout the [website](htt
- [Slash](https://beartocode.github.io/carta/plugins/slash) - [Slash](https://beartocode.github.io/carta/plugins/slash)
- [TikZ](https://beartocode.github.io/carta/plugins/tikz) - [TikZ](https://beartocode.github.io/carta/plugins/tikz)
- [Attachment](https://beartocode.github.io/carta/plugins/attachment) - [Attachment](https://beartocode.github.io/carta/plugins/attachment)
- [Anchor](https://beartocode.github.io/carta/plugins/anchor)
- API: - API:
- [Utilities](https://beartocode.github.io/carta/api/utilities) - [Utilities](https://beartocode.github.io/carta/api/utilities)
- [Core](https://beartocode.github.io/carta/api/core) - [Core](https://beartocode.github.io/carta/api/core)
- [Extension](https://beartocode.github.io/carta/api/extension) - [Extension](https://beartocode.github.io/carta/api/extension)
# Contributing & Development
Every contribution is well accepted. If you have a feature request you can open a new issue.
This package uses a [pnpm workspace](https://pnpm.io/workspaces), so pnpm is required to download and put everything together properly.
### Committing
This repository is [commitizen](https://github.com/commitizen/cz-cli) friendly. To commit use:
```
npm run commit
# or, if you have commitizen installed globally
git cz
```
### Running docs
If you want to preview the docs:
```
cd docs
npm run dev
```

View file

@ -14,10 +14,7 @@
"svelte": "./dist/index.js", "svelte": "./dist/index.js",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./default.css": "./dist/default.css", "./default.css": "./dist/default.css"
"./default-theme.css": "./dist/default.css",
"./light.css": "./dist/light.css",
"./dark.css": "./dist/dark.css"
}, },
"version": "3.0.0", "version": "3.0.0",
"scripts": { "scripts": {
@ -38,8 +35,12 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@speed-highlight/core": "1.2.2", "rehype-stringify": "^10.0.0",
"marked": "^9.1.5" "remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"shiki": "^1.4.0",
"unified": "^11.0.4"
}, },
"peerDependencies": { "peerDependencies": {
"svelte": "^3.54.0 || ^4.0.0" "svelte": "^3.54.0 || ^4.0.0"

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Carta } from './'; import { loadNestedLanguages, type Carta } from '.';
export let carta: Carta; export let carta: Carta;
export let value: string; export let value: string;
@ -12,7 +12,12 @@
let rendered = carta.renderSSR(value); let rendered = carta.renderSSR(value);
onMount(async () => { onMount(async () => {
carta.$setRenderer(elem); carta.$setRenderer(elem);
// Add code syntax highlighting (if plugin is present) once loaded on the client.
// Load highlighting languages
const highlighter = await carta.highlighter();
await loadNestedLanguages(highlighter, value);
// Render using asynchronous renderer
rendered = await carta.render(value); rendered = await carta.render(value);
mounted = true; mounted = true;

View file

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Carta } from './internal/carta'; import type { Carta } from './internal/carta';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import CartaRenderer from './internal/components/CartaRenderer.svelte'; import Renderer from './internal/components/Renderer.svelte';
import MarkdownInput from './internal/components/MarkdownInput.svelte'; import Input from './internal/components/Input.svelte';
import { debounce } from './internal/utils'; import { debounce } from './internal/utils';
import type { TextAreaProps } from './internal/textarea-props'; import type { TextAreaProps } from './internal/textarea-props';
import { DefaultCartaLabels, type CartaLabels } from './internal/labels'; import { defaultLabels, type Labels } from './internal/labels';
import Toolbar from './internal/components/Toolbar.svelte'; import Toolbar from './internal/components/Toolbar.svelte';
export let carta: Carta; export let carta: Carta;
@ -17,10 +17,10 @@
export let placeholder = ''; export let placeholder = '';
export let textarea: TextAreaProps = {}; export let textarea: TextAreaProps = {};
let userLabels: Partial<CartaLabels> = {}; let userLabels: Partial<Labels> = {};
export { userLabels as labels }; export { userLabels as labels };
const labels: CartaLabels = { const labels: Labels = {
...DefaultCartaLabels, ...defaultLabels,
...userLabels ...userLabels
}; };
@ -104,7 +104,7 @@
<div class="carta-wrapper"> <div class="carta-wrapper">
<div class="carta-container mode-{windowMode}"> <div class="carta-container mode-{windowMode}">
{#if windowMode == 'split' || selectedTab == 'write'} {#if windowMode == 'split' || selectedTab == 'write'}
<MarkdownInput <Input
{carta} {carta}
{placeholder} {placeholder}
{handleScroll} {handleScroll}
@ -121,10 +121,10 @@
<svelte:component this={component} {carta} {...props} /> <svelte:component this={component} {carta} {...props} />
{/each} {/each}
{/if} {/if}
</MarkdownInput> </Input>
{/if} {/if}
{#if windowMode == 'split' || selectedTab == 'preview'} {#if windowMode == 'split' || selectedTab == 'preview'}
<CartaRenderer {carta} {handleScroll} bind:value bind:elem={rendererElem}> <Renderer {carta} {handleScroll} bind:value bind:elem={rendererElem}>
<!-- Renderer extensions components --> <!-- Renderer extensions components -->
{#if mounted} {#if mounted}
{#each carta.components.filter(({ parent }) => [parent] {#each carta.components.filter(({ parent }) => [parent]
@ -133,7 +133,7 @@
<svelte:component this={component} {carta} {...props} /> <svelte:component this={component} {carta} {...props} />
{/each} {/each}
{/if} {/if}
</CartaRenderer> </Renderer>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,41 +0,0 @@
@import 'light.css';
[class*='shj-lang-'] {
color: #f8f8f2;
background: #1a1a1c;
}
[class*='shj-lang-']:before {
color: #6f9aff;
}
.shj-syn-deleted,
.shj-syn-err,
.shj-syn-var {
color: #ff5261;
}
.shj-syn-section,
.shj-syn-kwd {
color: #ff7cc6;
}
.shj-syn-class {
color: #eab07c;
}
.shj-numbers,
.shj-syn-cmnt {
color: #7d828b;
}
.shj-syn-insert,
.shj-syn-type,
.shj-syn-func,
.shj-syn-bool {
color: #71d58a;
}
.shj-syn-num {
color: #b581fd;
}
.shj-syn-oper {
color: #80c6ff;
}
.shj-syn-str {
color: #4dacfa;
}

View file

@ -3,6 +3,15 @@
--selection-color: #b5f0ff3d; --selection-color: #b5f0ff3d;
--focus-outline: #76bbf3; --focus-outline: #76bbf3;
--hover-color: #e9e9e9; --hover-color: #e9e9e9;
--caret-color: #161616;
--text-color: #1a1a1a;
--border-color-dark: #4d4d4c;
--selection-color-dark: #b5f0ff3d;
--focus-outline-dark: #76bbf3;
--hover-color-dark: #4d4d4c;
--caret-color-dark: #ffffff;
--text-color-dark: #f1f1f1;
} }
.carta-theme__default.carta-editor { .carta-theme__default.carta-editor {
@ -29,10 +38,14 @@
/* Text settings */ /* Text settings */
.carta-theme__default .carta-input { .carta-theme__default .carta-input {
caret-color: #4d4d4c; caret-color: var(--caret-color);
font-size: 0.95rem; font-size: 0.95rem;
} }
.carta-theme__default .carta-input ::placeholder {
color: var(--text-color);
}
/* Splitter */ /* Splitter */
.carta-theme__default .mode-split.carta-container::after { .carta-theme__default .mode-split.carta-container::after {
content: ''; content: '';
@ -62,6 +75,10 @@
align-items: flex-end; align-items: flex-end;
} }
.carta-theme__default button {
color: var(--text-color);
}
/* Markdown input and renderer */ /* Markdown input and renderer */
.carta-theme__default .carta-input, .carta-theme__default .carta-input,
.carta-theme__default .carta-renderer { .carta-theme__default .carta-renderer {

View file

@ -1,7 +1,7 @@
export { default as CartaEditor } from '$lib/CartaEditor.svelte'; export { default as MarkdownEditor } from '$lib/MarkdownEditor.svelte';
export { default as CartaViewer } from '$lib/CartaViewer.svelte'; export { default as Markdown } from '$lib/Markdown.svelte';
export type { CartaInput, TextSelection } from '$lib/internal/input'; export type { InputEnhancer, TextSelection } from '$lib/internal/input';
export type { CartaIcon } from '$lib/internal/icons'; export type { Icon } from '$lib/internal/icons';
export type { KeyboardShortcut } from '$lib/internal/shortcuts'; export type { KeyboardShortcut } from '$lib/internal/shortcuts';
export type { Prefix } from '$lib/internal/prefixes'; export type { Prefix } from '$lib/internal/prefixes';
export * from '$lib/internal/carta'; export * from '$lib/internal/carta';
@ -9,4 +9,7 @@ export * from '$lib/internal/highlight';
export * from '$lib/internal/textarea-props'; export * from '$lib/internal/textarea-props';
export * from '$lib/internal/labels'; export * from '$lib/internal/labels';
export * from './default.css?inline'; export * from './default.css?inline';
export * from './light.css?inline';
// Legacy
export { default as CartaEditor } from '$lib/MarkdownEditor.svelte';
export { default as CartaViewer } from '$lib/Markdown.svelte';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,330 @@
import { type ThemeInput } from 'shiki';
const theme = {
displayName: 'Carta Dark' as const,
name: 'carta-dark' as const,
semanticHighlighting: true,
fg: '#f8f8f2',
bg: 'transparent',
tokenColors: [
{
scope: ['comment', 'punctuation.definition.comment', 'string.comment'],
settings: {
foreground: '#6a737d'
}
},
{
scope: ['variable.other.constant', 'variable.other.enummember', 'variable.language'],
settings: {
foreground: '#fff'
}
},
{
scope: ['constant', 'entity.name.constant'],
settings: {
foreground: '#71d58a'
}
},
{
scope: ['entity', 'entity.name'],
settings: {
foreground: '#b392f0'
}
},
{
scope: 'variable.parameter.function',
settings: {
foreground: '#e1e4e8'
}
},
{
scope: 'entity.name.tag',
settings: {
foreground: '#85e89d'
}
},
{
scope: ['keyword', 'punctuation.definition.template-expression'],
settings: {
foreground: '#ff7cc6'
}
},
{
scope: ['storage', 'storage.type'],
settings: {
foreground: '#ff7cc6'
}
},
{
scope: ['storage.modifier.package', 'storage.modifier.import', 'storage.type.java'],
settings: {
foreground: '#e1e4e8'
}
},
{
scope: [
'string',
'punctuation.definition.string',
'string punctuation.section.embedded source'
],
settings: {
foreground: '#4dacfa'
}
},
{
scope: 'support',
settings: {
foreground: '#71d58a'
}
},
{
scope: 'meta.property-name',
settings: {
foreground: '#71d58a'
}
},
{
scope: 'variable',
settings: {
foreground: '#b581fd'
}
},
{
scope: 'variable.other',
settings: {
foreground: '#e1e4e8'
}
},
{
scope: 'invalid.broken',
settings: {
fontStyle: 'italic',
foreground: '#fdaeb7'
}
},
{
scope: 'invalid.deprecated',
settings: {
fontStyle: 'italic',
foreground: '#fdaeb7'
}
},
{
scope: 'invalid.illegal',
settings: {
fontStyle: 'italic',
foreground: '#fdaeb7'
}
},
{
scope: 'invalid.unimplemented',
settings: {
fontStyle: 'italic',
foreground: '#fdaeb7'
}
},
{
scope: 'carriage-return',
settings: {
background: '#ff7cc6',
fontStyle: 'italic underline',
foreground: '#24292e'
}
},
{
scope: 'message.error',
settings: {
foreground: '#fdaeb7'
}
},
{
scope: 'string variable',
settings: {
foreground: '#71d58a'
}
},
{
scope: ['source.regexp', 'string.regexp'],
settings: {
foreground: '#4dacfa'
}
},
{
scope: [
'string.regexp.character-class',
'string.regexp constant.character.escape',
'string.regexp source.ruby.embedded',
'string.regexp string.regexp.arbitrary-repitition'
],
settings: {
foreground: '#4dacfa'
}
},
{
scope: 'string.regexp constant.character.escape',
settings: {
fontStyle: 'bold',
foreground: '#85e89d'
}
},
{
scope: 'support.constant',
settings: {
foreground: '#71d58a'
}
},
{
scope: 'support.variable',
settings: {
foreground: '#71d58a'
}
},
{
scope: 'meta.module-reference',
settings: {
foreground: '#71d58a'
}
},
{
scope: 'punctuation.definition.list.begin.markdown',
settings: {
foreground: '#ff7cc6'
}
},
{
scope: ['markup.heading', 'markup.heading entity.name'],
settings: {
fontStyle: 'bold',
foreground: '#e8e8e8'
}
},
{
scope: 'markup.quote',
settings: {
foreground: '#7d828b'
}
},
{
scope: 'markup.italic',
settings: {
fontStyle: 'italic',
foreground: '#ff7cc6'
}
},
{
scope: 'markup.bold',
settings: {
foreground: '#b581fd'
}
},
{
scope: ['markup.underline'],
settings: {
foreground: '#71d58a',
fontStyle: 'underline'
}
},
{
scope: ['markup.strikethrough'],
settings: {
foreground: '#ff5261',
fontStyle: 'strikethrough'
}
},
{
scope: 'markup.inline.raw',
settings: {
foreground: '#4dacfa'
}
},
{
scope: ['markup.deleted', 'meta.diff.header.from-file', 'punctuation.definition.deleted'],
settings: {
background: '#86181d',
foreground: '#fdaeb7'
}
},
{
scope: ['markup.inserted', 'meta.diff.header.to-file', 'punctuation.definition.inserted'],
settings: {
background: '#144620',
foreground: '#85e89d'
}
},
{
scope: ['markup.changed', 'punctuation.definition.changed'],
settings: {
background: '#c24e00',
foreground: '#b581fd'
}
},
{
scope: ['markup.ignored', 'markup.untracked'],
settings: {
background: '#71d58a',
foreground: '#2f363d'
}
},
{
scope: 'meta.diff.range',
settings: {
fontStyle: 'bold',
foreground: '#b392f0'
}
},
{
scope: 'meta.diff.header',
settings: {
foreground: '#71d58a'
}
},
{
scope: 'meta.separator',
settings: {
fontStyle: 'bold',
foreground: '#71d58a'
}
},
{
scope: 'meta.output',
settings: {
foreground: '#71d58a'
}
},
{
scope: [
'brackethighlighter.tag',
'brackethighlighter.curly',
'brackethighlighter.round',
'brackethighlighter.square',
'brackethighlighter.angle',
'brackethighlighter.quote'
],
settings: {
foreground: '#d1d5da'
}
},
{
scope: 'brackethighlighter.unmatched',
settings: {
foreground: '#fdaeb7'
}
},
{
scope: ['constant.other.reference.link', 'string.other.link'],
settings: {
fontStyle: 'underline',
foreground: '#4dacfa'
}
},
{
scope: ['punctuation.definition.markdown', 'fenced_code.block.language'],
settings: {
foreground: '#ff7cc6'
}
}
],
type: 'light'
} satisfies ThemeInput;
export default theme;

View file

@ -0,0 +1,329 @@
import { type ThemeInput } from 'shiki';
const theme = {
displayName: 'Carta Light' as const,
name: 'carta-light' as const,
semanticHighlighting: true,
fg: '#333333',
bg: 'transparent',
tokenColors: [
{
scope: ['comment', 'punctuation.definition.comment', 'string.comment'],
settings: {
foreground: '#6a737d'
}
},
{
scope: ['variable.other.constant', 'variable.other.enummember', 'variable.language'],
settings: {
foreground: '#000'
}
},
{
scope: ['constant', 'entity.name.constant'],
settings: {
foreground: '#3bf'
}
},
{
scope: ['entity', 'entity.name'],
settings: {
foreground: '#6f42c1'
}
},
{
scope: 'variable.parameter.function',
settings: {
foreground: '#24292e'
}
},
{
scope: 'entity.name.tag',
settings: {
foreground: '#22863a'
}
},
{
scope: ['keyword', 'punctuation.definition.template-expression'],
settings: {
foreground: '#e16'
}
},
{
scope: ['storage', 'storage.type'],
settings: {
foreground: '#e16'
}
},
{
scope: ['storage.modifier.package', 'storage.modifier.import', 'storage.type.java'],
settings: {
foreground: '#24292e'
}
},
{
scope: [
'string',
'punctuation.definition.string',
'string punctuation.section.embedded source'
],
settings: {
foreground: '#7d8'
}
},
{
scope: 'support',
settings: {
foreground: '#3bf'
}
},
{
scope: 'meta.property-name',
settings: {
foreground: '#3bf'
}
},
{
scope: 'variable',
settings: {
foreground: '#f60'
}
},
{
scope: 'variable.other',
settings: {
foreground: '#24292e'
}
},
{
scope: 'invalid.broken',
settings: {
fontStyle: 'italic',
foreground: '#b31d28'
}
},
{
scope: 'invalid.deprecated',
settings: {
fontStyle: 'italic',
foreground: '#b31d28'
}
},
{
scope: 'invalid.illegal',
settings: {
fontStyle: 'italic',
foreground: '#b31d28'
}
},
{
scope: 'invalid.unimplemented',
settings: {
fontStyle: 'italic',
foreground: '#b31d28'
}
},
{
scope: 'carriage-return',
settings: {
background: '#e16',
fontStyle: 'italic underline',
foreground: '#fafbfc'
}
},
{
scope: 'message.error',
settings: {
foreground: '#b31d28'
}
},
{
scope: 'string variable',
settings: {
foreground: '#3bf'
}
},
{
scope: ['source.regexp', 'string.regexp'],
settings: {
foreground: '#7d8'
}
},
{
scope: [
'string.regexp.character-class',
'string.regexp constant.character.escape',
'string.regexp source.ruby.embedded',
'string.regexp string.regexp.arbitrary-repitition'
],
settings: {
foreground: '#7d8'
}
},
{
scope: 'string.regexp constant.character.escape',
settings: {
fontStyle: 'bold',
foreground: '#22863a'
}
},
{
scope: 'support.constant',
settings: {
foreground: '#3bf'
}
},
{
scope: 'support.variable',
settings: {
foreground: '#3bf'
}
},
{
scope: 'meta.module-reference',
settings: {
foreground: '#3bf'
}
},
{
scope: 'punctuation.definition.list.begin.markdown',
settings: {
foreground: '#e16'
}
},
{
scope: ['markup.heading', 'markup.heading entity.name'],
settings: {
fontStyle: 'bold',
foreground: '#212121'
}
},
{
scope: 'markup.quote',
settings: {
foreground: '#999'
}
},
{
scope: 'markup.italic',
settings: {
foreground: '#e16'
}
},
{
scope: 'markup.bold',
settings: {
foreground: '#f60'
}
},
{
scope: ['markup.underline'],
settings: {
foreground: '#84f',
fontStyle: 'underline'
}
},
{
scope: ['markup.strikethrough'],
settings: {
foreground: '#f44',
fontStyle: 'strikethrough'
}
},
{
scope: 'markup.inline.raw',
settings: {
foreground: '#5af'
}
},
{
scope: ['markup.deleted', 'meta.diff.header.from-file', 'punctuation.definition.deleted'],
settings: {
background: '#ffeef0',
foreground: '#b31d28'
}
},
{
scope: ['markup.inserted', 'meta.diff.header.to-file', 'punctuation.definition.inserted'],
settings: {
background: '#f0fff4',
foreground: '#22863a'
}
},
{
scope: ['markup.changed', 'punctuation.definition.changed'],
settings: {
background: '#ffebda',
foreground: '#f60'
}
},
{
scope: ['markup.ignored', 'markup.untracked'],
settings: {
background: '#3bf',
foreground: '#f6f8fa'
}
},
{
scope: 'meta.diff.range',
settings: {
fontStyle: 'bold',
foreground: '#6f42c1'
}
},
{
scope: 'meta.diff.header',
settings: {
foreground: '#3bf'
}
},
{
scope: 'meta.separator',
settings: {
fontStyle: 'bold',
foreground: '#3bf'
}
},
{
scope: 'meta.output',
settings: {
foreground: '#3bf'
}
},
{
scope: [
'brackethighlighter.tag',
'brackethighlighter.curly',
'brackethighlighter.round',
'brackethighlighter.square',
'brackethighlighter.angle',
'brackethighlighter.quote'
],
settings: {
foreground: '#586069'
}
},
{
scope: 'brackethighlighter.unmatched',
settings: {
foreground: '#b31d28'
}
},
{
scope: ['constant.other.reference.link', 'string.other.link'],
settings: {
fontStyle: 'underline',
foreground: '#5af'
}
},
{
scope: ['punctuation.definition.markdown', 'fenced_code.block.language'],
settings: {
foreground: '#e16'
}
}
],
type: 'light'
} satisfies ThemeInput;
export default theme;

View file

@ -1,53 +1,61 @@
import type { CartaHistoryOptions } from './history'; import type { SvelteComponent } from 'svelte';
import type { SvelteComponentTyped } from 'svelte'; import { unified, type Processor } from 'unified';
import type { ShjLanguageDefinition } from '@speed-highlight/core/index'; import remarkParse from 'remark-parse';
import { Marked, type MarkedExtension } from 'marked'; import remarkGfm, { type Options as GfmOptions } from 'remark-gfm';
import { CartaInput } from './input'; import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import type { TextAreaHistoryOptions } from './history';
import { InputEnhancer } from './input';
import { import {
type DefaultShortcutId, type DefaultShortcutId,
type KeyboardShortcut, type KeyboardShortcut,
defaultKeyboardShortcuts defaultKeyboardShortcuts
} from './shortcuts'; } from './shortcuts';
import { defaultIcons, type CartaIcon, type DefaultIconId } from './icons'; import { defaultIcons, type Icon, type DefaultIconId } from './icons';
import { defaultPrefixes, type DefaultPrefixId, type Prefix } from './prefixes'; import { defaultPrefixes, type DefaultPrefixId, type Prefix } from './prefixes';
import { CartaRenderer } from './renderer'; import { Renderer } from './renderer';
import { CustomEvent, type MaybeArray } from './utils';
import { import {
type HighlightFunctions, loadHighlighter,
loadCustomLanguage, loadDefaultTheme,
highlight, type Highlighter,
highlightAutodetect, type GrammarRule,
loadCustomMarkdown type ShikiOptions,
} from './highlight.js'; type DualTheme,
import { CustomEvent } from './utils'; type Theme,
type HighlightingRule
} from './highlight';
/** /**
* Carta-specific event with extra payload. * Carta-specific event with extra payload.
*/ */
export type CartaEvent = CustomEvent<{ carta: Carta }>; export type Event = CustomEvent<{ carta: Carta }>;
const cartaEvents = ['carta-render', 'carta-render-ssr'] as const; const cartaEvents = ['carta-render', 'carta-render-ssr'] as const;
type CartaEventType = (typeof cartaEvents)[number]; type CartaEventType = (typeof cartaEvents)[number];
export type CartaListener<K extends CartaEventType | keyof HTMLElementEventMap> = [ /**
* Custom listeners for the textarea element.
*/
export type Listener<K extends CartaEventType | keyof HTMLElementEventMap> = [
type: K, type: K,
listener: ( listener: (
this: HTMLTextAreaElement, this: HTMLTextAreaElement,
ev: K extends CartaEventType ev: K extends CartaEventType
? CartaEvent ? Event
: K extends keyof HTMLElementEventMap : K extends keyof HTMLElementEventMap
? HTMLElementEventMap[K] ? HTMLElementEventMap[K]
: Event : Event
) => unknown, ) => unknown,
options?: boolean | AddEventListenerOptions options?: boolean | AddEventListenerOptions
]; ];
// eslint-disable-next-line @typescript-eslint/no-explicit-any /**
type CartaListeners = CartaListener<any>[]; * Custom Svelte component for extensions.
*/
type MaybeArray<T> = T | Array<T>; export interface ExtensionComponent<T extends object | undefined> {
export interface CartaExtensionComponent<T extends object> {
/** /**
* Svelte components that exports `carta: Carta` and all the other properties specified in `props`. * Svelte components that exports `carta: Carta` and all the other properties specified in `props`.
*/ */
component: typeof SvelteComponentTyped<T & { carta: Carta }>; component: typeof SvelteComponent<T & { carta: Carta }>;
/** /**
* Properties that will be handed to the component. * Properties that will be handed to the component.
*/ */
@ -59,16 +67,22 @@ export interface CartaExtensionComponent<T extends object> {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type CartaExtensionComponents = Array<CartaExtensionComponent<any>>; type Listeners = Listener<any>[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExtensionComponents = Array<ExtensionComponent<any>>;
/** /**
* Carta editor options. * Carta editor options.
*/ */
export interface CartaOptions { export interface Options {
/**
* GitHub Flavored Markdown options.
*/
gfmOptions?: GfmOptions;
/** /**
* Editor/viewer extensions. * Editor/viewer extensions.
*/ */
extensions?: CartaExtension[]; extensions?: Plugin[];
/** /**
* Renderer debouncing timeout, in ms. * Renderer debouncing timeout, in ms.
* @defaults 300ms * @defaults 300ms
@ -89,21 +103,47 @@ export interface CartaOptions {
/** /**
* History (Undo/Redo) options. * History (Undo/Redo) options.
*/ */
historyOptions?: Partial<CartaHistoryOptions>; historyOptions?: TextAreaHistoryOptions;
/** /**
* HTML sanitizer. * HTML sanitizer.
*/ */
sanitizer?: (html: string) => string; sanitizer: ((html: string) => string) | false;
/**
* Highlighter options.
*/
shikiOptions?: ShikiOptions;
/**
* ShikiJS theme
* @default 'carta-light' for light mode and 'carta-dark' for dark mode.
*/
theme?: Theme | DualTheme;
} }
/**
* Unified transformers plugins.
*/
export type UnifiedTransformer<E extends 'sync' | 'async'> = {
execution: 'sync' | 'async';
type: 'remark' | 'rehype';
transform: ({
processor,
carta
}: {
processor: Processor;
carta: Carta;
}) => E extends 'sync' ? void : Promise<void>;
};
/** /**
* Carta editor extensions. * Carta editor extensions.
*/ */
export interface CartaExtension { export interface Plugin {
/** /**
* Marked extensions, more on that [here](https://marked.js.org/using_advanced). * Unified transformers plugins.
* @important If the plugin is async, it will not run in SSR rendering.
*/ */
markedExtensions?: MarkedExtension[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any
transformers?: UnifiedTransformer<'sync' | 'async'>[];
/** /**
* Additional keyboard shortcuts. * Additional keyboard shortcuts.
*/ */
@ -111,7 +151,7 @@ export interface CartaExtension {
/** /**
* Additional icons. * Additional icons.
*/ */
icons?: CartaIcon[]; icons?: Icon[];
/** /**
* Additional prefixes. * Additional prefixes.
*/ */
@ -119,58 +159,78 @@ export interface CartaExtension {
/** /**
* Textarea event listeners. * Textarea event listeners.
*/ */
listeners?: CartaListeners; listeners?: Listeners;
/** /**
* Additional components, that will be put after the editor. * Additional components, that will be put after the editor.
* All components are given a `carta: Carta` prop. * All components are given a `carta: Carta` prop.
* The editor has a `relative` position, so you can position * The editor has a `relative` position, so you can position
* elements absolutely. * elements absolutely.
*/ */
components?: CartaExtensionComponents; components?: ExtensionComponents;
/** /**
* Custom markdown highlight rules. See [Speed-Highlight Wiki](https://github.com/speed-highlight/core/wiki/Create-or-suggest-new-languages). * Custom markdown grammar highlight rules for ShiKi.
*/ */
highlightRules?: ShjLanguageDefinition; grammarRules?: GrammarRule[];
/**
* Custom markdown highlighting rules for ShiKi.
*/
highlightingRules?: HighlightingRule[];
/** /**
* Use this callback to execute code when one Carta instance loads the extension. * Use this callback to execute code when one Carta instance loads the extension.
* @param data General Carta related data. * @param data General Carta related data.
*/ */
onLoad?: (data: { carta: Carta; highlight: HighlightFunctions }) => void; onLoad?: (data: { carta: Carta }) => void;
/**
* This function can be used to access a reference to the `Carta` class immediately after initialization.
* @deprecated Use `onLoad` instead.
*/
cartaRef?: (carta: Carta) => void;
/**
* This function can be used to access a reference to all highlight functions immediately after initialization.
* @deprecated Use `onLoad` instead.
*/
shjRef?: (functions: HighlightFunctions) => void;
} }
export class Carta { export class Carta {
public readonly sanitizer?: (html: string) => string;
public readonly historyOptions?: TextAreaHistoryOptions;
public readonly theme?: Theme | DualTheme;
public readonly shikiOptions?: ShikiOptions;
public readonly rendererDebounce: number;
public readonly keyboardShortcuts: KeyboardShortcut[]; public readonly keyboardShortcuts: KeyboardShortcut[];
public readonly icons: CartaIcon[]; public readonly icons: Icon[];
public readonly prefixes: Prefix[]; public readonly prefixes: Prefix[];
public readonly highlightRules: ShjLanguageDefinition; public readonly grammarRules: GrammarRule[];
public readonly textareaListeners: CartaListeners; public readonly highlightingRules: HighlightingRule[];
public readonly cartaListeners: CartaListeners; public readonly textareaListeners: Listeners;
public readonly components: CartaExtensionComponents; public readonly cartaListeners: Listeners;
public readonly components: ExtensionComponents;
public readonly dispatcher = new EventTarget(); public readonly dispatcher = new EventTarget();
public readonly markedAsync = new Marked(); public readonly syncProcessor: Processor;
public readonly markedSync = new Marked(); public readonly asyncProcessor: Promise<Processor>;
private mElement: HTMLDivElement | undefined;
private mInput: InputEnhancer | undefined;
private mRenderer: Renderer | undefined;
private mHighlighter: Highlighter | Promise<Highlighter> | undefined;
private mSyncTransformers: UnifiedTransformer<'sync'>[] = [];
private mAsyncTransformers: UnifiedTransformer<'async'>[] = [];
private _element: HTMLDivElement | undefined;
private _input: CartaInput | undefined;
private _renderer: CartaRenderer | undefined;
public get element() { public get element() {
return this._element; return this.mElement;
} }
public get input() { public get input() {
return this._input; return this.mInput;
} }
public get renderer() { public get renderer() {
return this._renderer; return this.mRenderer;
}
public async highlighter(): Promise<Highlighter> {
if (!this.mHighlighter) {
const promise = async () => {
return loadHighlighter({
theme: this.theme ?? (await loadDefaultTheme()),
grammarRules: this.grammarRules,
highlightingRules: this.highlightingRules,
shiki: this.shikiOptions
});
};
this.mHighlighter = promise();
this.mHighlighter = await this.mHighlighter;
}
return this.mHighlighter;
} }
private elementsToBind: { private elementsToBind: {
@ -179,23 +239,31 @@ export class Carta {
callback: (() => void) | undefined; callback: (() => void) | undefined;
}[] = []; }[] = [];
public constructor(public readonly options?: CartaOptions) { public constructor(options?: Options) {
this.sanitizer = options?.sanitizer || undefined;
this.historyOptions = options?.historyOptions;
this.theme = options?.theme;
this.shikiOptions = options?.shikiOptions;
this.rendererDebounce = options?.rendererDebounce ?? 300;
// Load plugins
this.keyboardShortcuts = []; this.keyboardShortcuts = [];
this.icons = []; this.icons = [];
this.prefixes = []; this.prefixes = [];
this.textareaListeners = []; this.textareaListeners = [];
this.cartaListeners = []; this.cartaListeners = [];
this.components = []; this.components = [];
this.highlightRules = []; this.grammarRules = [];
this.highlightingRules = [];
const listeners = []; const listeners = [];
for (const ext of options?.extensions ?? []) { for (const ext of options?.extensions ?? []) {
this.keyboardShortcuts.push(...(ext.shortcuts ?? [])); this.keyboardShortcuts.push(...(ext.shortcuts ?? []));
this.icons.push(...(ext.icons ?? [])); this.icons.push(...(ext.icons ?? []));
this.prefixes.push(...(ext.prefixes ?? [])); this.prefixes.push(...(ext.prefixes ?? []));
this.components.push(...(ext.components ?? [])); this.components.push(...(ext.components ?? []));
this.highlightRules.push(...(ext.highlightRules ?? [])); this.grammarRules.push(...(ext.grammarRules ?? []));
this.highlightingRules.push(...(ext.highlightingRules ?? []));
listeners.push(...(ext.listeners ?? [])); listeners.push(...(ext.listeners ?? []));
} }
@ -232,41 +300,82 @@ export class Carta {
) )
); );
// Load marked extensions // Load unified extensions
const markedExtensions = this.options?.extensions this.mSyncTransformers = [];
?.flatMap((ext) => ext.markedExtensions) this.mAsyncTransformers = [];
.filter((ext) => ext != null) as MarkedExtension[] | undefined;
if (markedExtensions)
markedExtensions.forEach((ext) => {
this.useMarkedExtension(ext);
});
// Load highlight custom language for (const ext of options?.extensions ?? []) {
loadCustomMarkdown(this.options?.extensions); for (const transformer of ext.transformers ?? []) {
if (transformer.execution === 'sync') {
this.mSyncTransformers.push(transformer);
} else {
this.mAsyncTransformers.push(transformer);
}
}
}
for (const ext of this.options?.extensions ?? []) { this.syncProcessor = this.setupSynchronousProcessor({ gfmOptions: options?.gfmOptions });
ext.cartaRef && ext.cartaRef(this); this.asyncProcessor = this.setupAsynchronousProcessor({ gfmOptions: options?.gfmOptions });
ext.shjRef &&
ext.shjRef({ for (const ext of options?.extensions ?? []) {
highlight, if (ext.onLoad) {
highlightAutodetect,
loadCustomLanguage
});
ext.onLoad &&
ext.onLoad({ ext.onLoad({
carta: this, carta: this
highlight: {
highlight,
highlightAutodetect,
loadCustomLanguage
}
}); });
}
} }
} }
private useMarkedExtension(exts: MarkedExtension) { private setupSynchronousProcessor({ gfmOptions }: { gfmOptions?: GfmOptions }) {
this.markedAsync.use(exts); const syncProcessor = unified();
if (!exts.async) this.markedSync.use(exts);
const remarkPlugins = this.mSyncTransformers.filter((it) => it.type === 'remark');
const rehypePlugins = this.mSyncTransformers.filter((it) => it.type === 'rehype');
syncProcessor.use(remarkParse);
syncProcessor.use(remarkGfm, gfmOptions);
for (const plugin of remarkPlugins) {
plugin.transform({ processor: syncProcessor, carta: this });
}
syncProcessor.use(remarkRehype);
for (const plugin of rehypePlugins) {
plugin.transform({ processor: syncProcessor, carta: this });
}
syncProcessor.use(rehypeStringify);
return syncProcessor;
}
private async setupAsynchronousProcessor({ gfmOptions }: { gfmOptions?: GfmOptions }) {
const asyncProcessor = unified();
const remarkPlugins = [...this.mSyncTransformers, ...this.mAsyncTransformers].filter(
(it) => it.type === 'remark'
);
const rehypePlugins = [...this.mSyncTransformers, ...this.mAsyncTransformers].filter(
(it) => it.type === 'rehype'
);
asyncProcessor.use(remarkParse);
asyncProcessor.use(remarkGfm, gfmOptions);
for (const plugin of remarkPlugins) {
await plugin.transform({ processor: asyncProcessor, carta: this });
}
asyncProcessor.use(remarkRehype);
for (const plugin of rehypePlugins) {
await plugin.transform({ processor: asyncProcessor, carta: this });
}
asyncProcessor.use(rehypeStringify);
return asyncProcessor;
} }
/** /**
@ -275,12 +384,13 @@ export class Carta {
* @returns Rendered html. * @returns Rendered html.
*/ */
public async render(markdown: string): Promise<string> { public async render(markdown: string): Promise<string> {
const dirty = await this.markedAsync.parse(markdown, { async: true }); const processor = await this.asyncProcessor;
const dirty = String(await processor.process(markdown));
if (!dirty) return ''; if (!dirty) return '';
this.dispatcher.dispatchEvent( this.dispatcher.dispatchEvent(
new CustomEvent<{ carta: Carta }>('carta-render', { detail: { carta: this } }) new CustomEvent<{ carta: Carta }>('carta-render', { detail: { carta: this } })
); );
return (this.options?.sanitizer && this.options?.sanitizer(dirty)) ?? dirty; return (this.sanitizer && this.sanitizer(dirty)) ?? dirty;
} }
/** /**
@ -289,12 +399,12 @@ export class Carta {
* @returns Rendered html. * @returns Rendered html.
*/ */
public renderSSR(markdown: string): string { public renderSSR(markdown: string): string {
const dirty = this.markedSync.parse(markdown, { async: false }); const dirty = String(this.syncProcessor.processSync(markdown));
if (typeof dirty != 'string') return ''; if (typeof dirty != 'string') return '';
this.dispatcher.dispatchEvent( this.dispatcher.dispatchEvent(
new CustomEvent<{ carta: Carta }>('carta-render-ssr', { detail: { carta: this } }) new CustomEvent<{ carta: Carta }>('carta-render-ssr', { detail: { carta: this } })
); );
if (this.options?.sanitizer) return this.options.sanitizer(dirty); if (this.sanitizer) return this.sanitizer(dirty);
return dirty; return dirty;
} }
@ -303,7 +413,7 @@ export class Carta {
* @param element The editor element. * @param element The editor element.
*/ */
public $setElement(element: HTMLDivElement) { public $setElement(element: HTMLDivElement) {
this._element = element; this.mElement = element;
} }
/** /**
@ -315,19 +425,19 @@ export class Carta {
// Remove old listeners if any // Remove old listeners if any
const previousInput = this.input; const previousInput = this.input;
this._input = new CartaInput(textarea, container, { this.mInput = new InputEnhancer(textarea, container, {
shortcuts: this.keyboardShortcuts, shortcuts: this.keyboardShortcuts,
prefixes: this.prefixes, prefixes: this.prefixes,
listeners: this.textareaListeners, listeners: this.textareaListeners,
historyOpts: this.options?.historyOptions historyOpts: this.historyOptions
}); });
if (previousInput) { if (previousInput) {
previousInput.events.removeEventListener('update', callback); previousInput.events.removeEventListener('update', callback);
this._input.history = previousInput.history; this.mInput.history = previousInput.history;
} }
this._input.events.addEventListener('update', callback); this.mInput.events.addEventListener('update', callback);
// Bind elements // Bind elements
this.elementsToBind.forEach((it) => { this.elementsToBind.forEach((it) => {
@ -343,7 +453,7 @@ export class Carta {
* @param container Div container of the rendered element. * @param container Div container of the rendered element.
*/ */
public $setRenderer(container: HTMLDivElement) { public $setRenderer(container: HTMLDivElement) {
this._renderer = new CartaRenderer(container); this.mRenderer = new Renderer(container);
} }
/** /**
@ -380,14 +490,4 @@ export class Carta {
} }
}; };
} }
/**
* Highlight Markdown using Speed-Highlight and this Carta instance highlighting rules.
* @param text Text to highlight.
* @returns Highlighted html text.
*/
public async highlight(text: string) {
loadCustomMarkdown(this.options?.extensions);
return highlight(text, 'cartamd', true);
}
} }

View file

@ -2,6 +2,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Carta } from '../carta'; import type { Carta } from '../carta';
import type { TextAreaProps } from '../textarea-props'; import type { TextAreaProps } from '../textarea-props';
import { debounce } from '../utils';
import { isSingleTheme, loadNestedLanguages } from '../highlight';
export let carta: Carta; export let carta: Carta;
export let value = ''; export let value = '';
@ -11,7 +13,7 @@
export let props: TextAreaProps = {}; export let props: TextAreaProps = {};
let textarea: HTMLTextAreaElement; let textarea: HTMLTextAreaElement;
let highlighElem: HTMLPreElement; let highlighElem: HTMLDivElement;
let highlighted = value; let highlighted = value;
let mounted = false; let mounted = false;
@ -36,10 +38,44 @@
}); });
}; };
const highlight = async (text: string) => (highlighted = (await carta.highlight(text)) as string); const highlight = async (text: string) => {
$: highlight(value).then(resize); const highlighter = await carta.highlighter();
let html: string;
onMount(() => (mounted = true)); if (isSingleTheme(highlighter.theme)) {
// Single theme
html = highlighter.codeToHtml(text, {
lang: highlighter.lang,
theme: highlighter.theme
});
} else {
// Dual theme
html = highlighter.codeToHtml(text, {
lang: highlighter.lang,
themes: highlighter.theme
});
}
if (carta.sanitizer) {
highlighted = carta.sanitizer(html);
} else {
highlighted = html;
}
};
const highlightNestedLanguages = debounce(async (text: string) => {
const highlighter = await carta.highlighter();
const { updated } = await loadNestedLanguages(highlighter, text);
if (updated) highlight(text);
}, 300);
$: highlight(value).then(resize);
$: highlightNestedLanguages(value);
onMount(() => {
mounted = true;
requestAnimationFrame(resize);
});
onMount(setInput); onMount(setInput);
</script> </script>
@ -56,11 +92,14 @@
bind:this={elem} bind:this={elem}
> >
<div class="carta-input-wrapper"> <div class="carta-input-wrapper">
<pre <div
class="shj-lang-md carta-font-code" class="carta-highlight carta-font-code"
bind:this={highlighElem}
tabindex="-1" tabindex="-1"
aria-hidden="true"><!-- eslint-disable-line svelte/no-at-html-tags -->{@html highlighted}</pre> aria-hidden="true"
bind:this={highlighElem}
>
<!-- eslint-disable-line svelte/no-at-html-tags -->{@html highlighted}
</div>
<textarea <textarea
name="md" name="md"
@ -110,12 +149,11 @@
color: transparent; color: transparent;
background: transparent; background: transparent;
font-size: inherit;
outline: none; outline: none;
tab-size: 4;
} }
pre { .carta-highlight {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
@ -133,6 +171,21 @@
word-break: break-word; word-break: break-word;
} }
:global(.carta-highlight .shiki) {
margin: 0;
tab-size: 4;
background-color: transparent !important;
}
:global(.carta-highlight *) {
font-family: inherit;
font-size: inherit;
word-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
}
#editor-unfocus-suggestion { #editor-unfocus-suggestion {
position: absolute; position: absolute;
width: 1px; width: 1px;

View file

@ -13,7 +13,7 @@
const debouncedRenderer = debounce(() => { const debouncedRenderer = debounce(() => {
carta.render(value).then((rendered) => (renderedHtml = rendered)); carta.render(value).then((rendered) => (renderedHtml = rendered));
}, carta.options?.rendererDebounce ?? 300); }, carta.rendererDebounce ?? 300);
$: { $: {
// On value updates // On value updates

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { CartaLabels } from '../labels'; import type { Labels } from '../labels';
import { handleArrowKeysNavigation } from '../accessibility'; import { handleArrowKeysNavigation } from '../accessibility';
import type { Carta } from '../carta'; import type { Carta } from '../carta';
import MenuIcon from './icons/MenuIcon.svelte'; import MenuIcon from './icons/MenuIcon.svelte';
@ -9,7 +9,7 @@
export let carta: Carta; export let carta: Carta;
export let mode: 'tabs' | 'split'; export let mode: 'tabs' | 'split';
export let tab: 'write' | 'preview'; export let tab: 'write' | 'preview';
export let labels: CartaLabels; export let labels: Labels;
let toolbar: HTMLDivElement; let toolbar: HTMLDivElement;
let menu: HTMLDivElement; let menu: HTMLDivElement;

View file

@ -1,87 +1,268 @@
import { detectLanguage } from '@speed-highlight/core/detect.js';
import { import {
highlightText, getHighlighter,
loadLanguage, type BundledTheme,
type ShjLanguage, type ThemeInput,
type ShjLanguageDefinition type StringLiteralUnion,
} from '@speed-highlight/core'; type BundledLanguage,
import type { CartaExtension } from './carta'; type SpecialLanguage,
import cartaMarkdown from './shj'; type LanguageInput,
type LanguageRegistration,
type HighlighterGeneric,
bundledLanguages,
bundledThemes,
type ThemeRegistration
} from 'shiki';
import type { Intellisense } from './utils'; import type { Intellisense } from './utils';
type Lang = Intellisense<ShjLanguage>; /**
* Custom TextMate grammar rule for the highlighter.
*/
export type GrammarRule = {
name: string;
type: 'block' | 'inline';
definition: LanguageRegistration['repository'][string];
};
/** /**
* Highlight text using Speed-Highlight. May return null on error(usually if requested * Custom TextMate highlighting rule for the highlighter.
* language is not supported).
* @param text Text to highlight.
* @param lang Language to use, for example "js" or "c"
* @param hideLineNumbers Whether to hide line numbering.
* @returns Highlighted html text.
*/ */
export async function highlight( export type HighlightingRule = {
text: string, light: NonNullable<ThemeRegistration['tokenColors']>[number];
lang: Lang, dark: NonNullable<ThemeRegistration['tokenColors']>[number];
hideLineNumbers?: boolean };
): Promise<string | null> {
try { /**
return await highlightText(text, lang, true, { hideLineNumbers: hideLineNumbers ?? true }); * Shiki options for the highlighter.
} catch (_) { */
return null; export type ShikiOptions = {
themes?: Array<ThemeInput | StringLiteralUnion<BundledTheme>>;
langs?: (LanguageInput | StringLiteralUnion<BundledLanguage> | SpecialLanguage)[];
};
type CustomMarkdownLangName = Awaited<(typeof import('./assets/markdown'))['default']['name']>;
type DefaultLightThemeName = Awaited<(typeof import('./assets/theme-light'))['default']['name']>;
type DefaultDarkThemeName = Awaited<(typeof import('./assets/theme-dark'))['default']['name']>;
export const customMarkdownLangName: CustomMarkdownLangName = 'cartamd';
export const defaultLightThemeName: DefaultLightThemeName = 'carta-light';
export const defaultDarkThemeName: DefaultDarkThemeName = 'carta-dark';
export const loadDefaultTheme = async (): Promise<{
light: ThemeRegistration;
dark: ThemeRegistration;
}> => ({
light: structuredClone((await import('./assets/theme-light')).default),
dark: structuredClone((await import('./assets/theme-dark')).default)
});
/**
* Language for the highlighter.
*/
export type Language = Intellisense<BundledLanguage | CustomMarkdownLangName>;
/**
* Theme name for the highlighter.
*/
export type ThemeName = Intellisense<BundledTheme | DefaultLightThemeName | DefaultDarkThemeName>;
/**
* Theme for the highlighter.
*/
export type Theme = ThemeName | ThemeRegistration;
/**
* Dual theme for light and dark mode.
*/
export type DualTheme = {
light: Theme;
dark: Theme;
};
/**
* Options for the highlighter.
*/
export type HighlighterOptions = {
grammarRules: GrammarRule[];
highlightingRules: HighlightingRule[];
theme: Theme | DualTheme;
shiki?: ShikiOptions;
};
/**
* Loads the highlighter instance, with custom rules and options. Uses Shiki under the hood.
* @param rules Custom rules for the highlighter, from plugins.
* @param options Custom options for the highlighter.
* @returns The highlighter instance.
*/
export async function loadHighlighter({
grammarRules,
highlightingRules,
theme,
shiki
}: HighlighterOptions): Promise<Highlighter> {
// Inject rules into the custom markdown language
const injectGrammarRules = (
lang: Awaited<(typeof import('./assets/markdown'))['default']>,
rules: GrammarRule[]
) => {
lang.repository = {
...langDefinition.repository,
...Object.fromEntries(rules.map(({ name, definition }) => [name, definition]))
};
for (const rule of rules) {
if (rule.type === 'block') {
lang.repository.block.patterns.unshift({ include: `#${rule.name}` });
} else {
lang.repository.inline.patterns.unshift({ include: `#${rule.name}` });
}
}
};
const injectHighlightRules = (theme: ThemeRegistration, rules: HighlightingRule[]) => {
if (theme.type === 'light') {
theme.tokenColors ||= [];
theme.tokenColors.unshift(...rules.map(({ light }) => light));
} else {
theme.tokenColors ||= [];
theme.tokenColors.unshift(...rules.map(({ dark }) => dark));
}
};
// Additional themes and languages provided by the user
const themes = shiki?.themes ?? [];
const langs = shiki?.langs ?? [];
const highlighter: HighlighterGeneric<BundledLanguage, BundledTheme> = await getHighlighter({
themes,
langs
});
// Custom markdown language
const langDefinition = (await import('./assets/markdown')).default;
injectGrammarRules(langDefinition, grammarRules);
await highlighter.loadLanguage(langDefinition);
// Custom themes
if (isSingleTheme(theme)) {
let registration: ThemeRegistration;
if (isThemeRegistration(theme)) {
registration = theme;
} else {
registration = (await bundledThemes[theme as BundledTheme]()).default;
}
injectHighlightRules(registration, highlightingRules);
await highlighter.loadTheme(registration);
} else {
const { light, dark } = theme;
let lightRegistration: ThemeRegistration;
let darkRegistration: ThemeRegistration;
if (isThemeRegistration(light)) {
lightRegistration = light;
} else {
lightRegistration = (await bundledThemes[light as BundledTheme]()).default;
}
if (isThemeRegistration(dark)) {
darkRegistration = dark;
} else {
darkRegistration = (await bundledThemes[dark as BundledTheme]()).default;
}
injectHighlightRules(lightRegistration, highlightingRules);
injectHighlightRules(darkRegistration, highlightingRules);
await highlighter.loadTheme(lightRegistration);
await highlighter.loadTheme(darkRegistration);
} }
return {
theme,
lang: customMarkdownLangName,
...highlighter
};
}
export interface Highlighter extends HighlighterGeneric<BundledLanguage, BundledTheme> {
/**
* The language specified for the highlighter.
*/
theme: Theme | DualTheme;
/**
* The theme specified for the highlighter.
*/
lang: Language;
} }
/** /**
* Highlight text using Speed-Highlight with detected language. * Checks if a language is a bundled language.
* @param text Text to highlight. * @param lang The language to check.
* @param hideLineNumbers Whether to hide line numbering. * @returns Whether the language is a bundled language.
* @returns Highlighted html text.
*/ */
export async function highlightAutodetect(text: string, hideLineNumbers?: boolean) { export const isBundleLanguage = (lang: string): lang is BundledLanguage =>
const lang = await detectLanguage(text); Object.keys(bundledLanguages).includes(lang);
return await highlightText(text, lang, true, { hideLineNumbers: hideLineNumbers ?? true }); /**
} * Checks if a theme is a bundled theme.
* @param theme The theme to check.
* @returns Whether the theme is a bundled theme.
*/
export const isBundleTheme = (theme: string): theme is BundledTheme =>
Object.keys(bundledThemes).includes(theme);
/**
* Checks if a theme is a dual theme.
* @param theme The theme to check.
* @returns Whether the theme is a dual theme.
*/
export const isDualTheme = (theme: Theme | DualTheme): theme is DualTheme =>
typeof theme == 'object' && 'light' in theme && 'dark' in theme;
/**
* Checks if a theme is a single theme.
* @param theme The theme to check.
* @returns Whether the theme is a single theme.
*/
export const isSingleTheme = (theme: Theme | DualTheme): theme is Theme => !isDualTheme(theme);
/**
* Checks if a theme is a theme registration.
* @param theme The theme to check.
* @returns Whether the theme is a theme registration.
*/
export const isThemeRegistration = (theme: Theme): theme is ThemeRegistration =>
typeof theme == 'object';
/** /**
* Load a custom language for reference in highlight rules. * Find all nested languages in the markdown text and load them into the highlighter.
* @param id Id of the language. * @param text Markdown text to parse for nested languages.
* @param langModule A module that has the default export set to an array of HighlightRule. * @returns The set of nested languages found in the text.
* @example
* ```
* // language.ts
* import type { HighlightLanguage } from 'carta-md';
*
* export default [
* {
* match: /helloworld/g,
* type: 'kwd'
* }
* ] satisfies HighlightLanguage;
* ```
* And in another file:
* ```
* import("./path/to/language")
* .then(module => Carta.loadCustomLanguage("lang-name", module));
* ```
*/ */
export function loadCustomLanguage(id: string, langModule: { default: ShjLanguageDefinition }) { const findNestedLanguages = (text: string) => {
return loadLanguage(id, langModule); const languages = new Set<string>();
}
export interface HighlightFunctions { const regex = /```([a-z]+)\n([\s\S]+?)\n```/g;
highlight: typeof highlight; let match: RegExpExecArray | null;
highlightAutodetect: typeof highlightAutodetect; while ((match = regex.exec(text))) {
loadCustomLanguage: typeof loadCustomLanguage; languages.add(match[1]);
} }
return languages;
};
/** /**
* Load custom markdown syntax highlighting rules. * Load all nested languages found in the markdown text into the highlighter.
* Automatically called when a Carta instance is created. * @param highlighter The highlighter instance.
* @param extensions Additional extensions used in Carta. * @param text The text to parse for nested languages.
* @returns Whether the highlighter was updated with new languages.
*/ */
export function loadCustomMarkdown(extensions: CartaExtension[] = []) { export const loadNestedLanguages = async (highlighter: Highlighter, text: string) => {
const highlightRules = extensions.map((ext) => ext.highlightRules ?? []).flat(); text = text.replaceAll('\r\n', '\n'); // Normalize line endings
const lang = [];
lang.push(...cartaMarkdown, ...highlightRules); const languages = findNestedLanguages(text);
loadCustomLanguage('cartamd', { default: lang }); const loadedLanguages = highlighter.getLoadedLanguages();
} let updated = false;
for (const lang of languages) {
if (isBundleLanguage(lang) && !loadedLanguages.includes(lang)) {
await highlighter.loadLanguage(lang);
loadedLanguages.push(lang);
updated = true;
}
}
return {
updated
};
};

View file

@ -6,20 +6,20 @@ interface HistoryState {
cursor: number; cursor: number;
} }
export interface CartaHistoryOptions { export interface TextAreaHistoryOptions {
/** /**
* Minimum interval between save states in ms. * Minimum interval between save states in ms.
* @default 300ms * @default 300ms
*/ */
minInterval: number; minInterval?: number;
/** /**
* Maximum history size in bytes. * Maximum history size in bytes.
* @default 1MB * @default 1MB
*/ */
maxSize: number; maxSize?: number;
} }
const defaultHistoryOptions: CartaHistoryOptions = { const defaultHistoryOptions: TextAreaHistoryOptions = {
minInterval: 300, minInterval: 300,
maxSize: 1_000_000 maxSize: 1_000_000
}; };
@ -27,11 +27,11 @@ const defaultHistoryOptions: CartaHistoryOptions = {
/** /**
* Input undo/redo functionality. * Input undo/redo functionality.
*/ */
export class CartaHistory { export class TextAreaHistory {
private states: HistoryState[] = []; private states: HistoryState[] = [];
private currentIndex = -1; // Only <= 0 numbers private currentIndex = -1; // Only <= 0 numbers
private readonly options: CartaHistoryOptions; private readonly options: TextAreaHistoryOptions;
constructor(options?: Partial<CartaHistoryOptions>) { constructor(options?: Partial<TextAreaHistoryOptions>) {
this.options = mergeDefaultInterface(options, defaultHistoryOptions); this.options = mergeDefaultInterface(options, defaultHistoryOptions);
} }
@ -79,7 +79,7 @@ export class CartaHistory {
} }
this.currentIndex = -1; this.currentIndex = -1;
if (latest && Date.now() - latest.timestamp.getTime() <= this.options.minInterval) { if (latest && Date.now() - latest.timestamp.getTime() <= (this.options.minInterval ?? 300)) {
this.states.pop(); this.states.pop();
} }
@ -94,7 +94,7 @@ export class CartaHistory {
// every char is 2 bytes // every char is 2 bytes
size += value.length * 2; size += value.length * 2;
while (size > this.options.maxSize) { while (size > (this.options.maxSize ?? 1_000_000)) {
const removed = this.states.shift(); const removed = this.states.shift();
if (!removed) break; // This should never happen if (!removed) break; // This should never happen
size -= removed.value.length * 2; size -= removed.value.length * 2;

View file

@ -1,5 +1,5 @@
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
import type { CartaInput } from './input'; import type { InputEnhancer } from './input';
import HeadingIcon from './components/icons/HeadingIcon.svelte'; import HeadingIcon from './components/icons/HeadingIcon.svelte';
import ItalicIcon from './components/icons/ItalicIcon.svelte'; import ItalicIcon from './components/icons/ItalicIcon.svelte';
import BoldIcon from './components/icons/BoldIcon.svelte'; import BoldIcon from './components/icons/BoldIcon.svelte';
@ -14,16 +14,16 @@ import StrikethroughIcon from './components/icons/StrikethroughIcon.svelte';
/** /**
* Editor toolbar icon information. * Editor toolbar icon information.
*/ */
export interface CartaIcon { export interface Icon {
/** /**
* The icon's unique identifier. * The icon's unique identifier.
*/ */
id: string; id: string;
/** /**
* Callback function to execute when the icon is clicked. * Callback function to execute when the icon is clicked.
* @param input CartaInput instance * @param input InputEnhancer instance
*/ */
action: (input: CartaInput) => void; action: (input: InputEnhancer) => void;
/** /**
* The icon's component. * The icon's component.
*/ */
@ -100,6 +100,6 @@ export const defaultIcons = [
component: ListTaskIcon, component: ListTaskIcon,
label: 'Task list' label: 'Task list'
} }
] as const satisfies readonly CartaIcon[]; ] as const satisfies readonly Icon[];
export type DefaultIconId = (typeof defaultIcons)[number]['id'] | 'menu'; export type DefaultIconId = (typeof defaultIcons)[number]['id'] | 'menu';

View file

@ -1,7 +1,7 @@
import type { CartaListener } from './carta'; import type { Listener } from './carta';
import type { Prefix } from './prefixes'; import type { Prefix } from './prefixes';
import type { KeyboardShortcut } from './shortcuts'; import type { KeyboardShortcut } from './shortcuts';
import { CartaHistory, type CartaHistoryOptions } from './history'; import { TextAreaHistory as TextAreaHistory, type TextAreaHistoryOptions } from './history';
import { areEqualSets } from './utils'; import { areEqualSets } from './utils';
/** /**
@ -21,17 +21,17 @@ export interface InputSettings {
readonly shortcuts: KeyboardShortcut[]; readonly shortcuts: KeyboardShortcut[];
readonly prefixes: Prefix[]; readonly prefixes: Prefix[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly listeners: CartaListener<any>[]; readonly listeners: Listener<any>[];
readonly historyOpts?: Partial<CartaHistoryOptions>; readonly historyOpts?: Partial<TextAreaHistoryOptions>;
} }
export class CartaInput { export class InputEnhancer {
private pressedKeys: Set<string>; private pressedKeys: Set<string>;
private escapePressed = false; private escapePressed = false;
// Used to detect keys that actually changed the textarea value // Used to detect keys that actually changed the textarea value
private onKeyDownValue: string | undefined; private onKeyDownValue: string | undefined;
public history: CartaHistory; public history: TextAreaHistory;
public readonly events = new EventTarget(); public readonly events = new EventTarget();
constructor( constructor(
@ -54,7 +54,7 @@ export class CartaInput {
textarea.addEventListener('mousedown', this.handleMouseDown.bind(this)); textarea.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.history = new CartaHistory(settings.historyOpts); this.history = new TextAreaHistory(settings.historyOpts);
// Save initial value // Save initial value
this.history.saveState(this.textarea.value, this.textarea.selectionStart); this.history.saveState(this.textarea.value, this.textarea.selectionStart);

View file

@ -6,13 +6,13 @@ type IconId = Intellisense<DefaultIconId>;
/** /**
* Labels that may appear in the editor. * Labels that may appear in the editor.
*/ */
export interface CartaLabels { export interface Labels {
writeTab: string; writeTab: string;
previewTab: string; previewTab: string;
iconsLabels: Partial<Record<IconId, string>>; iconsLabels: Partial<Record<IconId, string>>;
} }
export const DefaultCartaLabels: CartaLabels = { export const defaultLabels: Labels = {
writeTab: 'Write', writeTab: 'Write',
previewTab: 'Preview', previewTab: 'Preview',
iconsLabels: {} iconsLabels: {}

View file

@ -1,4 +1,4 @@
export class CartaRenderer { export class Renderer {
constructor(public readonly container: HTMLDivElement) {} constructor(public readonly container: HTMLDivElement) {}
// Reserved for future use // Reserved for future use
} }

View file

@ -1,54 +0,0 @@
import { detectLanguage } from '@speed-highlight/core/detect.js';
import type { ShjLanguageDefinition } from '@speed-highlight/core';
/**
* Markdown syntax highlighting rules.
*/
const cartaMarkdown: ShjLanguageDefinition = [
{
type: 'cmnt',
match: /^>.*|(=|-)\1+/gm
},
{
type: 'class',
match: /\*\*((?!\*\*).)*\*\*/g
},
{
match: /```((?!```)[^])*\n```/g,
sub: (code) => ({
type: 'kwd',
sub: [
{
match: /\n[^]*(?=```)/g,
sub: code.split('\n')[0].slice(3) || detectLanguage(code)
}
]
})
},
{
type: 'str',
match: /`[^`]*`/g
},
{
type: 'var',
match: /~~((?!~~).)*~~/g
},
{
type: 'kwd',
match: /_[^_]*_|\*[^*]*\*/g
},
{
type: 'kwd',
match: /^\s*(\*|\d+\.)\s/gm
},
{
type: 'oper',
match: /\[[^\]]*]/g
},
{
type: 'func',
match: /\([^)]*\)/g
}
];
export default cartaMarkdown;

View file

@ -1,4 +1,4 @@
import type { CartaInput } from './input'; import type { InputEnhancer } from './input';
/** /**
* Keyboard shortcut data. * Keyboard shortcut data.
@ -13,7 +13,7 @@ export interface KeyboardShortcut {
* Callback action. * Callback action.
* @param input Input helper. * @param input Input helper.
*/ */
action: (input: CartaInput) => void; action: (input: InputEnhancer) => void;
/** /**
* Prevent saving the current state in history. * Prevent saving the current state in history.
*/ */

View file

@ -4,6 +4,8 @@ interface Nothing {}
type Union<T, U> = T | (U & Nothing); type Union<T, U> = T | (U & Nothing);
export type Intellisense<T> = Union<T, string>; export type Intellisense<T> = Union<T, string>;
export type MaybeArray<T> = T | Array<T>;
export type NonNullable<T> = Exclude<T, null | undefined>;
/** /**
* Debounce the provided function. * Debounce the provided function.

View file

@ -1,82 +0,0 @@
.shj-inline {
margin: 0;
padding: 2px 5px;
display: inline-table;
border-radius: 5px;
}
.shj-numbers {
padding-left: 5px;
counter-reset: line;
}
.shj-numbers div {
padding-right: 5px;
}
.shj-numbers div::before {
color: #999;
display: block;
content: counter(line);
opacity: 0.5;
text-align: right;
margin-right: 5px;
counter-increment: line;
}
.shj-syn-cmnt {
font-style: italic;
}
.shj-syn-err,
.shj-syn-kwd {
color: #e16;
}
.shj-syn-num,
.shj-syn-class {
color: #f60;
}
.shj-numbers,
.shj-syn-cmnt {
color: #999;
}
.shj-syn-insert,
.shj-syn-str {
color: #7d8;
}
.shj-syn-bool {
color: #3bf;
}
.shj-syn-type,
.shj-syn-oper {
color: #5af;
}
.shj-syn-section,
.shj-syn-func {
color: #84f;
}
.shj-syn-deleted,
.shj-syn-var {
color: #f44;
}
.shj-oneline {
padding: 12px 10px;
}
.shj-lang-http.shj-oneline .shj-syn-kwd {
background: #25f;
color: #fff;
padding: 5px 7px;
border-radius: 5px;
}
.shj-multiline.shj-mode-header {
padding: 20px;
}
.shj-multiline.shj-mode-header:before {
content: attr(data-lang);
color: #58f;
display: block;
padding: 10px 20px;
background: #58f3;
border-radius: 5px;
margin-bottom: 20px;
}

View file

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { CartaEditor } from '$lib'; import { MarkdownEditor } from '$lib';
import { Carta } from '$lib/internal/carta'; import { Carta } from '$lib/internal/carta';
import ToggleTheme from './ToggleTheme.svelte';
import sampleText from './sample.md?raw';
import '$lib/default.css'; import '$lib/default.css';
import '$lib/light.css';
const carta = new Carta(); const carta = new Carta();
</script> </script>
@ -23,7 +23,8 @@
</svelte:head> </svelte:head>
<main> <main>
<CartaEditor placeholder="Some text..." mode="tabs" {carta} /> <ToggleTheme class="toggle-theme" />
<MarkdownEditor value={sampleText} placeholder="Some text..." mode="tabs" {carta} />
</main> </main>
<style> <style>
@ -33,9 +34,10 @@
min-height: 100vh; min-height: 100vh;
} }
:global(.carta-font-code, code) { :global(.carta-font-code) {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
font-variant-ligatures: normal; font-variant-ligatures: normal;
font-size: 1.1rem;
} }
:global(input, textarea, button) { :global(input, textarea, button) {
@ -43,9 +45,21 @@
} }
main { main {
position: relative;
max-width: 1536px; max-width: 1536px;
margin: 0 auto 0 auto; margin: 2rem auto 2rem auto;
padding: 2rem 0 2rem 0; }
:global(img) {
max-width: 50%;
}
:global(.toggle-theme) {
position: absolute;
top: 0;
left: -6px;
transform: translateX(-100%);
} }
/* Responsive main */ /* Responsive main */

View file

@ -0,0 +1,74 @@
<script lang="ts">
let className = '';
let theme: 'light' | 'dark' = 'light';
export { className as class };
</script>
<button
class="{className} {theme}"
on:click={() => {
if (theme === 'light') {
document.documentElement.classList.add('dark');
theme = 'dark';
} else {
document.documentElement.classList.remove('dark');
theme = 'light';
}
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<path
fill="currentColor"
d="M235.54 150.21a104.84 104.84 0 0 1-37 52.91A104 104 0 0 1 32 120a103.09 103.09 0 0 1 20.88-62.52a104.84 104.84 0 0 1 52.91-37a8 8 0 0 1 10 10a88.08 88.08 0 0 0 109.8 109.8a8 8 0 0 1 10 10Z"
/>
</svg>
</button>
<style>
button {
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
background: none;
border: 1px solid #b9b9b9;
border-radius: 4px;
font-size: 1.5rem;
cursor: pointer;
}
button.dark {
color: #fff;
border-color: #4d4d4c;
}
:global(html.dark) {
background: #1b1b1f;
}
:global(html.dark .markdown-body) {
color: #fff;
}
/* Editor dark mode */
:global(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 */
:global(html.dark .shiki),
:global(html.dark .shiki span) {
color: var(--shiki-dark) !important;
}
</style>

View file

@ -0,0 +1,79 @@
# Heading
## Sub-heading
Paragraphs are separated
by a blank line.
Two spaces at the end of a line
produce a line break.
Text attributes _italic_,
**bold**, `monospace`. Some `console.log(lst.filter(e => e == true))` implementations may use _single-asterisks_ for italic text.
Horizontal rule:
---
```js
function resolveAfter2Seconds(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, 2000);
});
}
// async function expression assigned to a variable
const add = async function (x) {
const a = await resolveAfter2Seconds(20);
const b = await resolveAfter2Seconds(30);
console?.log(`http://localhost:${PORT}/`.match(/:[0-9]{2,4}^/g));
return x + a + b;
};
add(10).then((v) => {
console.log(v); // prints 60 after 4 seconds.
});
// async function expression used as an IIFE
(async function (x) {
const p1 = resolveAfter2Seconds(20);
const p2 = resolveAfter2Seconds(30);
return x + (await p1) + (await p2);
})(10).then((v) => {
console.log(v); // prints 60 after 2 seconds.
});
```
```beurihiuerh
}
```
Strikethrough:
~~strikethrough~~
Bullet list:
- apples
- oranges
- pears
Numbered list:
1. lather
2. rinse
3. repeat
An [example](http://example.com).
![pic](pic.jpg)
> Markdown uses email-style
> characters for blockquoting.
> Multiple paragraphs need to be prepended individually.
| Item | Price | In stock |
| ------------ | -------- | -------- |
| Juicy Apples | 1.99 | _7_ |
| Bananas | **1.89** | 5234 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -20,7 +20,7 @@ import '@cartamd/plugin-anchor/default.css';
```svelte ```svelte
<script> <script>
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { anchor } from '@cartamd/plugin-anchor'; import { anchor } from '@cartamd/plugin-anchor';
const carta = new Carta({ const carta = new Carta({
@ -28,7 +28,7 @@ import '@cartamd/plugin-anchor/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Documentation ## Documentation

View file

@ -32,11 +32,11 @@
"!dist/**/*.spec.*" "!dist/**/*.spec.*"
], ],
"dependencies": { "dependencies": {
"slugify": "^1.6.6" "rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.1.0", "carta-md": "^4.0.0",
"marked": "^9.1.5",
"svelte": "^3.54.0 || ^4.0.0" "svelte": "^3.54.0 || ^4.0.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,13 +1,32 @@
.carta-renderer .anchor-link { .carta-viewer h1,
visibility: hidden; .carta-viewer h2,
opacity: 0.6; .carta-viewer h3,
.carta-viewer h4,
.carta-viewer h5,
.carta-viewer h6 {
position: relative;
} }
.carta-renderer h1:hover .anchor-link, .carta-viewer h1 .icon.icon-link,
.carta-renderer h2:hover .anchor-link, .carta-viewer h2 .icon.icon-link,
.carta-renderer h3:hover .anchor-link, .carta-viewer h3 .icon.icon-link,
.carta-renderer h4:hover .anchor-link, .carta-viewer h4 .icon.icon-link,
.carta-renderer h5:hover .anchor-link, .carta-viewer h5 .icon.icon-link,
.carta-renderer h6:hover .anchor-link { .carta-viewer h6 .icon.icon-link {
visibility: visible; opacity: 0;
content: url('./link.svg');
position: absolute;
right: 100%;
top: 50%;
padding-right: 4px;
transform: translateY(-50%);
}
.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;
} }

View file

@ -1,47 +1,30 @@
import type { CartaExtension } from 'carta-md'; import rehypeSlug, { type Options as SlugOptions } from 'rehype-slug';
import { generateUniqueSlug } from './slug'; import rehypeAutolinkHeadings, { type Options as AutolinkOptions } from 'rehype-autolink-headings';
import type { Plugin } from 'carta-md';
export * from './default.css?inline'; export * from './default.css?inline';
export interface AnchorExtensionOptions { export interface AnchorExtensionOptions {
/** /**
* Maximum depth of headers to generate anchors for. Defaults to 6. * rehype-slug options.
*/ */
maxDepth?: number; slug?: SlugOptions;
/**
* rehype-autolink-headings options.
*/
autolink?: AutolinkOptions;
} }
/** /**
* Carta anchor plugin. Adds support to render anchor links in header tags. * Carta anchor plugin. Adds support to render anchor links in header tags.
*/ */
export const anchor = (options?: AnchorExtensionOptions): CartaExtension => { export const anchor = (options?: AnchorExtensionOptions): Plugin => {
let slugs: string[] = [];
const maxDepth = options?.maxDepth ?? 6;
return { return {
// Reset the slug history after rendering completes, so the links persist after re-rendering transformers: [
listeners: [
['carta-render', () => (slugs = [])],
['carta-render-ssr', () => (slugs = [])]
],
markedExtensions: [
{ {
renderer: { execution: 'sync',
heading(text, level, raw) { type: 'rehype',
if (level > maxDepth) { transform({ processor }) {
return false; processor.use(rehypeSlug, options?.slug).use(rehypeAutolinkHeadings, options?.autolink);
}
const slug = generateUniqueSlug(raw, slugs);
return `
<h${level}>
<span>${text}</span>
<a id="${slug}" href="#${slug}" class="anchor-link">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" width="16" height="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>
</a>
</h${level}>`;
}
} }
} }
] ]

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd" d="M9.929 3.132a2.078 2.078 0 1 1 2.94 2.94l-.65.648a.75.75 0 0 0 1.061 1.06l.649-.648a3.579 3.579 0 0 0-5.06-5.06L6.218 4.72a3.58 3.58 0 0 0 0 5.06a.75.75 0 0 0 1.061-1.06a2.08 2.08 0 0 1 0-2.94zm-.15 3.086a.75.75 0 0 0-1.057 1.064c.816.81.818 2.13.004 2.942l-2.654 2.647a2.08 2.08 0 0 1-2.94-2.944l.647-.647a.75.75 0 0 0-1.06-1.06l-.648.647a3.58 3.58 0 0 0 5.06 5.066l2.654-2.647a3.575 3.575 0 0 0-.007-5.068Z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 575 B

View file

@ -1,23 +0,0 @@
import slugify from 'slugify';
function generateSlug(raw: string) {
const base = slugify(raw, {
lower: true,
remove: /[^a-zA-Z0-9_\- ]/g
});
return base;
}
export function generateUniqueSlug(raw: string, slugs: string[]) {
const base = generateSlug(raw);
let slug = base;
let i = 1;
// Add unique suffix to slug if it already exists
while (slugs.includes(slug)) {
slug = `${base}-${i}`;
i++;
}
slugs.push(slug);
return slug;
}

View file

@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { anchor } from '$lib'; import { anchor } from '$lib';
import 'carta-md/default.css'; import 'carta-md/default.css';
import '$lib/default.css'; import '$lib/default.css';
const carta = new Carta({ const carta = new Carta({
sanitizer: false,
extensions: [ extensions: [
anchor({ anchor({
maxDepth: 2 autolink: {}
}) })
] ]
}); });
@ -28,7 +29,7 @@
</svelte:head> </svelte:head>
<main> <main>
<CartaEditor {carta} {value} /> <MarkdownEditor {carta} {value} />
</main> </main>
<style> <style>
@ -38,9 +39,15 @@
min-height: 100vh; min-height: 100vh;
} }
:global(.carta-font-code, code) { :global(.carta-font-code) {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
font-variant-ligatures: normal; font-variant-ligatures: normal;
font-size: 1.1rem;
}
:global(.carta-renderer) {
/* Add some space to show icons */
padding-left: 2.5rem !important;
} }
:global(input, textarea, button) { :global(input, textarea, button) {

View file

@ -20,7 +20,7 @@ import '@cartamd/plugin-attachment/default.css';
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { attachment } from '@cartamd/plugin-attachment'; import { attachment } from '@cartamd/plugin-attachment';
const carta = new Carta({ const carta = new Carta({
@ -34,7 +34,7 @@ import '@cartamd/plugin-attachment/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Documentation ## Documentation

View file

@ -34,7 +34,7 @@
"!dist/**/*.spec.*" "!dist/**/*.spec.*"
], ],
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.4.0", "carta-md": "^4.0.0",
"marked": "^9.1.5", "marked": "^9.1.5",
"svelte": "^3.54.0 || ^4.0.0" "svelte": "^3.54.0 || ^4.0.0"
}, },

View file

@ -1,4 +1,4 @@
import type { Carta, CartaExtension, CartaListener } from 'carta-md'; import type { Carta, Plugin, Listener } from 'carta-md';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import type { SvelteComponent } from 'svelte'; import type { SvelteComponent } from 'svelte';
import DropOverlay from './DropOverlay.svelte'; import DropOverlay from './DropOverlay.svelte';
@ -40,7 +40,7 @@ const ImageMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']
/** /**
* Carta attachment plugin. * Carta attachment plugin.
*/ */
export const attachment = (options: AttachmentExtensionOptions): CartaExtension => { export const attachment = (options: AttachmentExtensionOptions): Plugin => {
let carta: Carta | undefined; let carta: Carta | undefined;
const allowedMimeTypes = options.supportedMimeTypes || ImageMimeTypes; const allowedMimeTypes = options.supportedMimeTypes || ImageMimeTypes;
@ -122,11 +122,11 @@ export const attachment = (options: AttachmentExtensionOptions): CartaExtension
carta = c; carta = c;
}, },
listeners: [ listeners: [
['drop', handleDrop, false] satisfies CartaListener<'drop'>, ['drop', handleDrop, false] satisfies Listener<'drop'>,
['dragenter', () => draggingOverTextArea.set(true)] satisfies CartaListener<'dragenter'>, ['dragenter', () => draggingOverTextArea.set(true)] satisfies Listener<'dragenter'>,
['dragleave', () => draggingOverTextArea.set(false)] satisfies CartaListener<'dragleave'>, ['dragleave', () => draggingOverTextArea.set(false)] satisfies Listener<'dragleave'>,
['dragover', (e) => e.preventDefault()] satisfies CartaListener<'dragover'>, ['dragover', (e) => e.preventDefault()] satisfies Listener<'dragover'>,
['paste', handlePaste, false] satisfies CartaListener<'paste'> ['paste', handlePaste, false] satisfies Listener<'paste'>
], ],
components: [ components: [
{ {

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { attachment } from '$lib'; import { attachment } from '$lib';
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import 'carta-md/default.css'; import 'carta-md/default.css';
import '$lib/default.css'; import '$lib/default.css';
@ -30,7 +30,7 @@
</svelte:head> </svelte:head>
<main> <main>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
</main> </main>
<style> <style>

View file

@ -1,13 +1,11 @@
# Carta Code Plugin # 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 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 ## Setup
### Styles ### Styles
@ -20,28 +18,31 @@ import '@cartamd/plugin-code/default.css';
### Using the default highlighter ### 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. 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`.
The theme is the same as the one used in the main carta package (`carta-md/light.css` or `carta-md/dark.css`).
[Here](https://github.com/speed-highlight/core/tree/main/src/themes) you can find other themes.
### Using a custom highlighter
You can also provide a custom highlighter, that can be either sync or async.
```ts ```ts
code({ const carta = new Carta({
customHighlight: { // ...
highlighter: (code, lang) => myCustomHighlighter(code, lang), extensions: [
langPrefix: 'my-highlighter-' code({
theme: 'ayu-light'
})
],
shikiOptions: {
themes: ['ayu-light']
} }
}); });
``` ```
### Using a custom highlighter
It is no longer possible to specify a custom highlighter in this plugin. However, there are many different [Remark plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins) that provide syntax highlighting.
### Extension ### Extension
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { code } from '@cartamd/plugin-code'; import { code } from '@cartamd/plugin-code';
const carta = new Carta({ const carta = new Carta({
@ -49,7 +50,7 @@ code({
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Documentation ## Documentation

View file

@ -16,21 +16,22 @@
"build": "tsc && tscp" "build": "tsc && tscp"
}, },
"devDependencies": { "devDependencies": {
"@shikijs/rehype": "^1.4.0",
"@types/node": "^18.16.3", "@types/node": "^18.16.3",
"carta-md": "workspace:*", "carta-md": "workspace:*",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"typescript-cp": "^0.1.8", "typescript-cp": "^0.1.8"
"marked": "^9.1.5"
}, },
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.0.0" "carta-md": "^4.0.0"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"version": "3.0.0", "version": "4.0.0",
"dependencies": { "dependencies": {
"marked-highlight": "^2.0.6" "@shikijs/rehype": "^1.4.0",
"unified": "^11.0.4"
}, },
"keywords": [ "keywords": [
"carta", "carta",

View file

@ -1,70 +1,52 @@
import type { CartaExtension, HighlightFunctions } from 'carta-md'; import type { DualTheme, Theme, Plugin } from 'carta-md';
import { markedHighlight } from 'marked-highlight'; import type { RehypeShikiOptions } from '@shikijs/rehype';
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
interface CodeExtensionOptions { export type CodeExtensionOptions = Omit<RehypeShikiOptions, 'theme' | 'themes'> & {
/** theme?: Theme | DualTheme;
* Default language when none is provided. };
*/
defaultLanguage?: string;
/**
* Whether to autodetect a language when none is provided.
* Overwritten by `defaultLanguage`.
*/
autoDetect?: string;
/**
* Line numbering.
* @defaults false.
*/
lineNumbering?: boolean;
/** // FIXME: find a better solution then copy-pasting these functions in next version.
* Options for custom syntax highlighting. // However, when importing from carta-md, this causes a MODULE_NOT_FOUND error
*/ // for some reason.
customHighlight?: { /**
/** * Checks if a theme is a dual theme.
* Custom highlight function. Beware that you'll have to provide your own styles. * @param theme The theme to check.
* This function needs to convert a string of code into html. * @returns Whether the theme is a dual theme.
*/ */
highlighter: (code: string, lang: string) => string | Promise<string>; export const isDualTheme = (theme: Theme | DualTheme): theme is DualTheme =>
/** typeof theme == 'object' && 'light' in theme && 'dark' in theme;
* The language tag found immediately after the code block opening marker is /**
* appended to this to form the class attribute added to the `<code>` element. * Checks if a theme is a single theme.
*/ * @param theme The theme to check.
langPrefix: string; * @returns Whether the theme is a single theme.
}; */
} export const isSingleTheme = (theme: Theme | DualTheme): theme is Theme => !isDualTheme(theme);
let shj: HighlightFunctions;
/** /**
* Carta code highlighting plugin. Themes available on [GitHub](https://github.com/speed-highlight/core/tree/main/dist/themes). * Carta code highlighting plugin. Themes available on [GitHub](https://github.com/speed-highlight/core/tree/main/dist/themes).
*/ */
export const code = (options?: CodeExtensionOptions): CartaExtension => { export const code = (options?: CodeExtensionOptions): Plugin => {
return { return {
onLoad: ({ highlight }) => (shj = highlight), transformers: [
markedExtensions: [ {
markedHighlight({ execution: 'async',
langPrefix: options?.customHighlight?.langPrefix ?? 'shj-lang-', type: 'rehype',
async: true, async transform({ processor, carta }) {
async highlight(code, lang) { let theme = options?.theme;
if (options?.customHighlight) {
return await options.customHighlight.highlighter(code, lang); const highlighter = await carta.highlighter();
if (!theme) {
theme = highlighter.theme; // Use the theme specified in the highlighter
} }
const { highlight, highlightAutodetect } = shj; if (isSingleTheme(theme)) {
processor.use(rehypeShikiFromHighlighter, highlighter, { ...options, theme });
lang ||= options?.defaultLanguage ?? ''; } else {
let highlighted: string | null = null; processor.use(rehypeShikiFromHighlighter, highlighter, { ...options, themes: theme });
}
if (lang) highlighted = await highlight(code, lang, !(options?.lineNumbering ?? false));
if (highlighted) return highlighted;
if (options?.autoDetect ?? true)
return await highlightAutodetect(code, !(options?.lineNumbering ?? false));
return (await highlight(code, 'plain', !(options?.lineNumbering ?? false))) as string;
} }
}) }
] ]
}; };
}; };

View file

@ -20,7 +20,7 @@ import '@cartamd/plugin-emoji/default.css';
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { emoji } from '@cartamd/plugin-emoji'; import { emoji } from '@cartamd/plugin-emoji';
const carta = new Carta({ const carta = new Carta({
@ -28,7 +28,7 @@ import '@cartamd/plugin-emoji/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Documentation ## Documentation

View file

@ -1,6 +1,6 @@
{ {
"name": "@cartamd/plugin-emoji", "name": "@cartamd/plugin-emoji",
"version": "3.0.0", "version": "4.0.0",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@ -33,11 +33,11 @@
], ],
"dependencies": { "dependencies": {
"bezier-easing": "^2.1.0", "bezier-easing": "^2.1.0",
"node-emoji": "^1.11.0" "node-emoji": "^1.11.0",
"remark-gemoji": "^8.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.1.0", "carta-md": "^4.0.0",
"marked": "^9.1.5",
"svelte": "^3.54.0 || ^4.0.0" "svelte": "^3.54.0 || ^4.0.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,7 +1,6 @@
import type { CartaExtension, CartaExtensionComponent } from 'carta-md'; import type { Plugin, ExtensionComponent, GrammarRule, HighlightingRule } from 'carta-md';
import type { TokenizerAndRendererExtension } from 'marked'; import remarkGemoji from 'remark-gemoji';
import { fade, scale, type TransitionConfig } from 'svelte/transition'; import { fade, scale, type TransitionConfig } from 'svelte/transition';
import nodeEmoji from 'node-emoji';
import Emoji from './Emoji.svelte'; import Emoji from './Emoji.svelte';
import BezierEasing from 'bezier-easing'; import BezierEasing from 'bezier-easing';
export * from './default.css?inline'; export * from './default.css?inline';
@ -25,7 +24,7 @@ interface ComponentProps {
/** /**
* Carta emoji plugin. Adds support to render emojis as well as an emojis snippet. * Carta emoji plugin. Adds support to render emojis as well as an emojis snippet.
*/ */
export const emoji = (options?: EmojiExtensionOptions): CartaExtension => { export const emoji = (options?: EmojiExtensionOptions): Plugin => {
const inTransition = const inTransition =
options?.inTransition ?? options?.inTransition ??
((node: Element) => ((node: Element) =>
@ -40,7 +39,7 @@ export const emoji = (options?: EmojiExtensionOptions): CartaExtension => {
duration: 100 duration: 100
})); }));
const emojiComponent: CartaExtensionComponent<ComponentProps> = { const emojiComponent: ExtensionComponent<ComponentProps> = {
component: Emoji, component: Emoji,
parent: 'input', parent: 'input',
props: { props: {
@ -49,39 +48,42 @@ export const emoji = (options?: EmojiExtensionOptions): CartaExtension => {
} }
}; };
const grammar = {
name: 'emoji',
type: 'inline',
definition: {
match: ':[a-zA-Z_]+:',
name: 'markup.emoji.markdown'
}
} satisfies GrammarRule;
const highlighting = {
light: {
scope: 'markup.emoji',
settings: {
foreground: '#3bf'
}
},
dark: {
scope: 'markup.emoji',
settings: {
foreground: '#4dacfa'
}
}
} satisfies HighlightingRule;
return { return {
markedExtensions: [ transformers: [
{ {
extensions: [emojiTokenizerAndRenderer()] execution: 'sync',
type: 'remark',
transform({ processor }) {
processor.use(remarkGemoji);
}
} }
], ],
components: [emojiComponent], components: [emojiComponent],
highlightRules: [ grammarRules: [grammar],
{ highlightingRules: [highlighting]
type: 'oper',
match: /:[a-z0-9_]+:/g
}
]
}; };
}; };
function emojiTokenizerAndRenderer(): TokenizerAndRendererExtension {
return {
name: 'emoji',
level: 'inline',
start: (src) => src.indexOf(':'),
tokenizer: (src) => {
const match = src.match(/^:.*?:/)?.at(0);
if (!match) return undefined;
const emoji = nodeEmoji.find(match)?.emoji;
if (emoji) {
return {
type: 'emoji',
raw: match,
emoji
};
}
},
renderer: (token) => token.emoji
};
}

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { emoji } from '$lib'; import { emoji } from '$lib';
import 'carta-md/default.css'; import 'carta-md/default.css';
import '$lib/default.css'; import '$lib/default.css';
@ -22,7 +22,7 @@
</svelte:head> </svelte:head>
<main> <main>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
</main> </main>
<style> <style>
@ -32,9 +32,10 @@
min-height: 100vh; min-height: 100vh;
} }
:global(.carta-font-code, code) { :global(.carta-font-code) {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
font-variant-ligatures: normal; font-variant-ligatures: normal;
font-size: 1.1rem;
} }
:global(input, textarea, button) { :global(input, textarea, button) {

View file

@ -38,7 +38,7 @@ or by using a content delivery network:
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { math } from '@cartamd/plugin-math'; import { math } from '@cartamd/plugin-math';
const carta = new Carta({ const carta = new Carta({
@ -46,7 +46,7 @@ or by using a content delivery network:
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Usage ## Usage

View file

@ -17,23 +17,20 @@
"build": "tsc" "build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@types/katex": "^0.16.0",
"carta-md": "workspace:*", "carta-md": "workspace:*",
"marked": "^9.1.5",
"typescript": "^5.0.4" "typescript": "^5.0.4"
}, },
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.0.0", "carta-md": "^4.0.0"
"katex": "^0.16.10",
"marked": "^9.1.5"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"dependencies": { "dependencies": {
"katex": "^0.16.10" "rehype-katex": "^7.0.0",
"remark-math": "^6.0.0"
}, },
"version": "3.0.0", "version": "4.0.1",
"keywords": [ "keywords": [
"carta", "carta",
"markdown", "markdown",

View file

@ -1,13 +1,12 @@
import type { Carta, CartaExtension } from 'carta-md'; import type { Plugin } from 'carta-md';
import { TokenizerAndRendererExtension } from 'marked'; import remarkMath, { type Options as RemarkMathOptions } from 'remark-math';
import katex, { KatexOptions } from 'katex'; import rehypeKatex, { type Options as RehypeKatexOptions } from 'rehype-katex';
interface MathExtensionOptions { interface MathExtensionOptions {
/** /**
* Options for inline katex, eg: $a^2+b^2=c^2$ * Options for inline katex, eg: $a^2+b^2=c^2$
*/ */
inline?: { inline?: {
katexOptions?: KatexOptions;
/** /**
* @default control+m * @default control+m
*/ */
@ -20,51 +19,45 @@ interface MathExtensionOptions {
* $$ * $$
*/ */
block?: { block?: {
/**
* Tag the generated katex will be put into. Must have `display: block`.
*/
tag?: string;
/**
* Whether to center the generated expression.
* @default true
*/
center?: boolean;
/**
* Class for generated katex.
*/
class?: string;
/** /**
* @default ctrl+shift+m * @default ctrl+shift+m
*/ */
shortcut?: Set<string>; shortcut?: Set<string>;
katexOptions?: KatexOptions;
}; };
/**
* Options for remark-math
*/
remarkMath?: RemarkMathOptions;
/**
* Options for rehype-katex
*/
rehypeKatex?: RehypeKatexOptions;
} }
function safeRender(tex: string, options?: KatexOptions | undefined) {
try {
return katex.renderToString(tex, options);
} catch (_) {
return '';
}
}
let carta: Carta;
/** /**
* Carta math plugin. Code adapted from [marked-katex-extension](https://github.com/UziTech/marked-katex-extension). * Carta math plugin. Code adapted from [marked-katex-extension](https://github.com/UziTech/marked-katex-extension).
*/ */
export const math = (options?: MathExtensionOptions): CartaExtension => { export const math = (options?: MathExtensionOptions): Plugin => {
return { return {
onLoad: ({ carta: c, highlight: shj }) => { onLoad: async ({ carta }) => {
carta = c; const highlighter = await carta.highlighter();
import('./latex.js') await highlighter.loadLanguage('latex');
.then((module) => shj.loadCustomLanguage('latex', module)) carta.input?.update();
.then(() => carta.input?.update());
}, },
markedExtensions: [ transformers: [
{ {
extensions: [inlineKatex(options?.inline), blockKatex(options?.block)] execution: 'sync',
type: 'remark',
transform({ processor }) {
processor.use(remarkMath, options?.remarkMath);
}
},
{
execution: 'sync',
type: 'rehype',
transform({ processor }) {
processor.use(rehypeKatex, options?.rehypeKatex);
}
} }
], ],
shortcuts: [ shortcuts: [
@ -79,64 +72,57 @@ export const math = (options?: MathExtensionOptions): CartaExtension => {
action: (input) => input.toggleSelectionSurrounding(['$$\n', '\n$$']) action: (input) => input.toggleSelectionSurrounding(['$$\n', '\n$$'])
} }
], ],
highlightRules: [ grammarRules: [
{ {
match: /\$[{}[\]a-zA-Z0-9.+-_=*/\\ ]+\$/g, name: 'inline_math',
sub: 'latex' type: 'inline',
definition: {
match: '(\\$+)((?:[^\\$]|(?!(?<!\\$)\\1(?!\\$))\\$)*+)(\\1)',
name: 'markup.inline.math.markdown',
captures: {
'1': { name: 'punctuation.definition.latex.inline' },
'2': { name: 'meta.embedded.block.latex', patterns: [{ include: 'text.tex.latex' }] },
'3': { name: 'punctuation.definition.latex.inline' }
}
}
}, },
{ {
match: /^\$\$+\n([^$]+?)\n\$\$+\n/gm, name: 'block_math',
sub: 'latex' type: 'block',
definition: {
begin: '(^|\\G)(\\s*)(\\${2,})\\s*(?=([^$]*)?$)',
beginCaptures: {
'3': { name: 'punctuation.definition.latex.block' }
},
endCaptures: { '3': { name: 'punctuation.definition.latex.block' } },
end: '(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$',
name: 'markup.block.math.markdown',
patterns: [
{
begin: '(^|\\G)(\\s*)(.*)',
contentName: 'meta.embedded.block.latex',
patterns: [{ include: 'text.tex.latex' }],
while: '(^|\\G)(?!\\s*([$]{2,})\\s*$)'
}
]
}
}
],
highlightingRules: [
{
light: {
scope: 'punctuation.definition.latex',
settings: {
foreground: '#5AF'
}
},
dark: {
scope: 'punctuation.definition.latex',
settings: {
foreground: '#4DACFA'
}
}
} }
] ]
}; };
}; };
const inlineKatex = (options?: MathExtensionOptions['inline']): TokenizerAndRendererExtension => {
return {
name: 'inlineKatex',
level: 'inline',
start: (src) => src.indexOf('$'),
tokenizer: (src) => {
const match = src.match(/^\$+([^$\n]+?)\$+/);
if (match) {
return {
type: 'inlineKatex',
raw: match[0],
text: match[1].trim()
};
}
},
renderer: (token) => safeRender(token.text, options?.katexOptions)
};
};
const blockKatex = (options?: MathExtensionOptions['block']): TokenizerAndRendererExtension => {
return {
name: 'blockKatex',
level: 'block',
start: (src) => src.indexOf('\n$$'),
tokenizer: (src) => {
const match = src.match(/^\$\$+\n([^$]+?)\n\$\$+\n/);
if (match) {
return {
type: 'blockKatex',
raw: match[0],
text: match[1].trim()
};
}
},
renderer: (token) => {
const tag = options?.tag ?? 'p';
const center = options?.center ?? true;
const katexOptions = options?.katexOptions ?? {};
if (katexOptions?.displayMode === undefined) katexOptions.displayMode = true;
return `
<${tag}
class="${options?.class ?? ''}"
${center ? 'align="center"' : ''}
>${safeRender(token.text, katexOptions)}
</${tag}>`;
}
};
};

View file

@ -1,38 +0,0 @@
export default [
{
match:
/\\(frac|tfrac|dfrac|sqrt|over|above|cfrac|binom|dbinom|brace|choose|tbinom|brack)(?![a-zA-Z0-9])/g,
type: 'str'
},
{
match:
/\\(amalg|circledast|ldotp|rtimes&&|And|circledcirc|lor|setminus|ast|circleddash|lessdot|smallsetminus|barwedge|Cup|lhd|sqcap|bigcirc|cup|ltimes|sqcupmodmod|bmod|curlyveexmodaxmodax|moda|times|boxdot|curlywedge|mp|unlhd|boxminus|div|odot|unrhd|boxplus|divideontimes|ominus|uplus|boxtimes|dotplus|oplus|vee|bullet|doublebarwedge|otimes|veebar|Cap|doublecap|oslash|wedge|cap|doublecup|pmor|plusmn|wr|centerdot|land|rhd|circ|leftthreetimes|rightthreetimes|cdot|gtrdot|pmod|cdotp|intercal|pod)(?![a-zA-Z0-9])/g,
type: 'class'
},
{
match:
/\\(mathscr|mathrm|mathbf|mathit|mathnormal|textbf|textit|textrm|bf|it|rm|bold|textup|textnormal|boldsymbol{Ab}|Bbb|text|bm|mathbb|mathsf|textmd|frak|textsf|mathtt|mathfrak|sf|texttt|mathcal|tt|cal)(?![a-zA-Z0-9])/g,
type: 'insert'
},
{
match:
/\\(sum|prod|bigotimes|bigvee|int|coprod|bigoplus|bigwedge|iint|intop|bigodot|bigcap|iiint|smallint|biguplus|bigcup|oint|oiint|oiiint|bigsqcup)(?![a-zA-Z0-9])/g,
type: 'func'
},
{
match: /\\[a-zA-Z0-9]+/g,
type: 'oper'
},
{
match: /(\(|\)|\{|\}|\[|\])/g,
type: 'esc'
},
{
match: /[a-zA-Z]+/g,
type: 'var'
},
{
match: /[0-9]+/g,
type: 'num'
}
];

View file

@ -20,7 +20,7 @@ import '@cartamd/plugin-slash/default.css';
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { slash } from '@cartamd/plugin-slash'; import { slash } from '@cartamd/plugin-slash';
const carta = new Carta({ const carta = new Carta({
@ -28,7 +28,7 @@ import '@cartamd/plugin-slash/default.css';
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Documentation ## Documentation

View file

@ -1,6 +1,6 @@
{ {
"name": "@cartamd/plugin-slash", "name": "@cartamd/plugin-slash",
"version": "3.0.0", "version": "4.0.1",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@ -35,7 +35,7 @@
"bezier-easing": "^2.1.0" "bezier-easing": "^2.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.1.0", "carta-md": "^4.0.0",
"svelte": "^3.54.0 || ^4.0.0" "svelte": "^3.54.0 || ^4.0.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,6 @@
import { fade, scale, type TransitionConfig } from 'svelte/transition'; import { fade, scale, type TransitionConfig } from 'svelte/transition';
import SlashComponent from './Slash.svelte'; import SlashComponent from './Slash.svelte';
import type { CartaExtension, CartaExtensionComponent } from 'carta-md'; import type { Plugin, ExtensionComponent } from 'carta-md';
import BezierEasing from 'bezier-easing'; import BezierEasing from 'bezier-easing';
import { defaultSnippets, type DefaultSnippetId, type SlashSnippet } from './snippets'; import { defaultSnippets, type DefaultSnippetId, type SlashSnippet } from './snippets';
export * from './default.css?inline'; export * from './default.css?inline';
@ -35,7 +35,7 @@ interface ComponentProps {
* @param options Extension options. * @param options Extension options.
* @returns The slash extension. * @returns The slash extension.
*/ */
export const slash = (options?: SlashExtensionOptions): CartaExtension => { export const slash = (options?: SlashExtensionOptions): Plugin => {
const snippets: SlashSnippet[] = defaultSnippets.filter((snippet) => const snippets: SlashSnippet[] = defaultSnippets.filter((snippet) =>
options?.disableDefaultSnippets === true options?.disableDefaultSnippets === true
? false ? false
@ -56,7 +56,7 @@ export const slash = (options?: SlashExtensionOptions): CartaExtension => {
fade(node, { fade(node, {
duration: 100 duration: 100
})); }));
const slashComponent: CartaExtensionComponent<ComponentProps> = { const slashComponent: ExtensionComponent<ComponentProps> = {
component: SlashComponent, component: SlashComponent,
props: { props: {
snippets, snippets,

View file

@ -1,4 +1,4 @@
import type { CartaInput } from 'carta-md'; import type { InputEnhancer } from 'carta-md';
export interface SlashSnippet { export interface SlashSnippet {
/** /**
@ -12,10 +12,10 @@ export interface SlashSnippet {
* Snippet callback. * Snippet callback.
* @param input Carta input. * @param input Carta input.
*/ */
action: (input: CartaInput) => void; action: (input: InputEnhancer) => void;
} }
function insertLine(input: CartaInput, string: string) { function insertLine(input: InputEnhancer, string: string) {
const line = input.getLine(); const line = input.getLine();
if (line.value !== '') { if (line.value !== '') {
input.insertAt(line.end, `\n${string}`); input.insertAt(line.end, `\n${string}`);

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { slash } from '$lib'; import { slash } from '$lib';
import 'carta-md/default.css'; import 'carta-md/default.css';
import '$lib/default.css'; import '$lib/default.css';
@ -21,7 +21,7 @@
</svelte:head> </svelte:head>
<main> <main>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
</main> </main>
<style> <style>

View file

@ -16,7 +16,7 @@ npm i @cartamd/plugin-tikz
```svelte ```svelte
<script lang="ts"> <script lang="ts">
import { Carta, CartaEditor } from 'carta-md'; import { Carta, MarkdownEditor } from 'carta-md';
import { tikz } from '@cartamd/plugin-tikz'; import { tikz } from '@cartamd/plugin-tikz';
import '@cartamd/plugin-tikz/fonts.css'; import '@cartamd/plugin-tikz/fonts.css';
@ -26,7 +26,7 @@ npm i @cartamd/plugin-tikz
}); });
</script> </script>
<CartaEditor {carta} /> <MarkdownEditor {carta} />
``` ```
## Documentation ## Documentation

View file

@ -18,23 +18,23 @@
"build": "vite build" "build": "vite build"
}, },
"devDependencies": { "devDependencies": {
"@types/hast": "^3.0.4",
"@types/md5": "^2.3.2", "@types/md5": "^2.3.2",
"carta-md": "workspace:*", "carta-md": "workspace:*",
"marked": "^9.1.5",
"md5": "^2.3.0", "md5": "^2.3.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"unified": "^11.0.4",
"vite": "^5.1.6", "vite": "^5.1.6",
"vite-plugin-dts": "^3.7.3", "vite-plugin-dts": "^3.7.3",
"vite-raw-plugin": "^1.0.2" "vite-raw-plugin": "^1.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"carta-md": "^3.0.0", "carta-md": "^4.0.0"
"marked": "^9.1.5"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"version": "3.0.0", "version": "4.0.0",
"keywords": [ "keywords": [
"carta", "carta",
"markdown", "markdown",
@ -46,5 +46,9 @@
"syntax highlighting", "syntax highlighting",
"emoji", "emoji",
"katex" "katex"
] ],
"dependencies": {
"hast-util-from-dom": "^5.0.0",
"unist-util-visit": "^5.0.0"
}
} }

View file

@ -1,5 +1,8 @@
import type { Carta, CartaEvent, CartaExtension } from 'carta-md'; import type { Carta, Event, Plugin } from 'carta-md';
import { TokenizerAndRendererExtension } from 'marked'; import type { Plugin as UnifiedPlugin } from 'unified';
import { visit, SKIP } from 'unist-util-visit';
import { fromDom } from 'hast-util-from-dom';
import type * as hast from 'hast';
import md5 from 'md5'; import md5 from 'md5';
interface TikzExtensionOptions { interface TikzExtensionOptions {
@ -18,102 +21,124 @@ interface TikzExtensionOptions {
center?: boolean; center?: boolean;
/** /**
* Post processing function for html. * Post processing function for html.
* This also runs on stored html, differently * This also runs on stored html.
* from `postProcess`, which only runs when
* the element is first created.
*/ */
postProcessing?: (html: string) => string; postProcessing?: (html: string) => string;
/**
* Post processing function for rendered SVGs Elem.
* @deprecated Use `postProcessing` instead.
*/
postProcess?: (elem: SVGElement) => void;
} }
let carta: Carta;
/** /**
* TikzJax extension for Carta. * TikzJax extension for Carta.
* @param options Tikz options. * @param options Tikz options.
*/ */
export const tikz = (options?: TikzExtensionOptions): CartaExtension => { export const tikz = (options?: TikzExtensionOptions): Plugin => {
let carta: Carta;
return { return {
cartaRef: (c) => (carta = c), onLoad: async ({ carta: c }) => {
shjRef: (shj) => { carta = c;
import('./tikz')
.then((module) => shj.loadCustomLanguage('tikz', module)) const highlighter = await carta.highlighter();
.then(() => carta.input?.update()); await highlighter.loadLanguage('latex');
carta.input?.update();
}, },
markedExtensions: [ transformers: [
{ {
async: true, execution: 'async',
extensions: [tikzTokenizer(options)] type: 'rehype',
transform({ carta, processor }) {
processor.use(tikzTransformer, { carta, options });
}
} }
], ],
listeners: [['carta-render', (e) => generateTikzImages(e, options)]] listeners: [['carta-render', (e) => generateTikzImages(e, options)]],
grammarRules: [
{
name: 'tikz',
type: 'block',
definition: {
begin: '(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(tikz)((\\s+|:|,|\\{|\\?)[^`]*)?$)',
beginCaptures: {
'3': { name: 'punctuation.definition.markdown' },
'4': { name: 'fenced_code.block.language.markdown' },
'5': { name: 'fenced_code.block.language.attributes.markdown' }
},
end: '(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$',
endCaptures: { '3': { name: 'punctuation.definition.markdown' } },
name: 'markup.fenced_code.block.markdown',
patterns: [
{
begin: '(^|\\G)(\\s*)(.*)',
contentName: 'meta.embedded.block.latex',
patterns: [{ include: 'text.tex.latex' }],
while: '(^|\\G)(?!\\s*([`~]{3,})\\s*$)'
}
]
}
}
]
}; };
}; };
// Keeps track of tikz generation to remove previous items // Keeps track of tikz generation to remove previous items
let currentGeneration = 0; let currentGeneration = 0;
const tikzTokenizer = (options?: TikzExtensionOptions): TokenizerAndRendererExtension => { const tikzTransformer: UnifiedPlugin<
return { [{ carta: Carta; options: TikzExtensionOptions | undefined }],
name: 'tikz', hast.Root
level: 'block', > = ({ carta, options }) => {
start: (src) => src.indexOf('\n```tikz'), return async function (tree) {
tokenizer: (src) => { visit(tree, (pre, index, parent) => {
const match = src.match(/^```tikz+\n([^`]+?)\n```+\n/);
if (match) {
return {
type: 'tikz',
raw: match[0],
text: match[1].trim()
};
}
},
renderer: (token) => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
// Cannot run outside the browser // Cannot run outside the browser
return ``; return;
} }
const template = document.createElement('div'); 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;
// Element is a TikZ code block
const source = tidyTikzSource((element.children[0] as hast.Text).value as string);
const container = document.createElement('div');
const template = document.createElement('div');
const text = document.createTextNode(source);
container.classList.add('tikz-generated');
container.setAttribute('tikz-generation', currentGeneration.toString());
if (options?.center ?? true) container.setAttribute('align', 'center');
if (options?.class) container.classList.add(...options.class.split(' '));
const center = options?.center ?? true;
template.setAttribute('type', 'tikzjax'); template.setAttribute('type', 'tikzjax');
if (options?.debug) template.setAttribute('data-show-console', 'true'); if (options?.debug) template.setAttribute('data-show-console', 'true');
const text = document.createTextNode(
tidyTikzSource(token.raw.slice(8, token.raw.length - 4))
);
template.appendChild(text); template.appendChild(text);
// Try accessing cached HTML const hash = md5(JSON.stringify(template.dataset) + text.nodeValue);
const hash = md5(JSON.stringify(template.dataset) + template.childNodes[0].nodeValue); let savedSvg = window.localStorage.getItem(hash);
const savedSvg = window.localStorage.getItem(hash);
let html: string;
if (savedSvg) { if (savedSvg) {
html = savedSvg; if (options?.postProcessing) savedSvg = options.postProcessing(savedSvg);
if (options?.postProcessing) html = options.postProcessing(html); container.innerHTML = savedSvg;
} else { } else {
html = template.outerHTML; container.appendChild(template);
} }
const sanitizer = carta.options?.sanitizer; if (carta.sanitizer) {
if (sanitizer) html = sanitizer(html); container.innerHTML = carta.sanitizer(container.innerHTML);
}
return ` const hastNode = fromDom(container) as hast.Element;
<div
${center ? 'align="center"' : ''} parent?.children.splice(index!, 1, hastNode);
class="tikz-generated ${options?.class ?? ''}"
tikz-generation="${currentGeneration}" return [SKIP, index!];
> });
${html}
</div>
`;
}
}; };
}; };
@ -123,7 +148,7 @@ declare global {
} }
} }
function generateTikzImages(e: CartaEvent, options?: TikzExtensionOptions) { function generateTikzImages(e: Event, options?: TikzExtensionOptions) {
const carta = e.detail.carta; const carta = e.detail.carta;
const container = carta.renderer?.container; const container = carta.renderer?.container;
if (!container) { if (!container) {
@ -150,7 +175,7 @@ async function loadTikz(options?: TikzExtensionOptions) {
// @ts-ignore // @ts-ignore
const tikzjax = (await import('./assets/tikzjax.js')).default; const tikzjax = (await import('./assets/tikzjax.js')).default;
const script = `<script type="text/javascript" id="tikzjax">${tikzjax}</script>`; const script = /* html */ `<script type="text/javascript" id="tikzjax">${tikzjax}</script>`;
// Simply appending the element does not work as the script is not executed // Simply appending the element does not work as the script is not executed
// By doing the following we ensure that it is run. // By doing the following we ensure that it is run.
@ -161,9 +186,6 @@ async function loadTikz(options?: TikzExtensionOptions) {
document.addEventListener('tikzjax-load-finished', (e) => { document.addEventListener('tikzjax-load-finished', (e) => {
const elem = e.target as SVGElement; const elem = e.target as SVGElement;
// Support old version
options?.postProcess && options.postProcess(elem);
if (options?.postProcessing) elem.outerHTML = options.postProcessing(elem.outerHTML); if (options?.postProcessing) elem.outerHTML = options.postProcessing(elem.outerHTML);
}); });
} }

View file

@ -1,26 +0,0 @@
export default [
{
match: /\\(usepackage|input|usemodule)(?![a-zA-Z0-9])/g,
type: 'str'
},
{
match: /\\(begin|end|node)(?![a-zA-Z0-9])/g,
type: 'class'
},
{
match: /\\[a-zA-Z0-9]+/g,
type: 'oper'
},
{
match: /%.+$/gm,
type: 'cmnt'
},
{
match: /(\(|\)|\{|\}|\[|\])/g,
type: 'esc'
},
{
match: /[0-9]+[a-z]{0,3}/g,
type: 'num'
}
];

904
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more