@ruckc/truss-markdown 0.0.0-stage → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Markdown.svelte +60 -0
- package/dist/Markdown.svelte.d.ts +23 -0
- package/dist/MarkdownBlocks.svelte +54 -0
- package/dist/MarkdownBlocks.svelte.d.ts +8 -0
- package/dist/MarkdownCode.svelte +69 -0
- package/dist/MarkdownCode.svelte.d.ts +7 -0
- package/dist/MarkdownInlines.svelte +12 -0
- package/dist/MarkdownInlines.svelte.d.ts +8 -0
- package/dist/MarkdownLink.svelte +10 -0
- package/dist/MarkdownLink.svelte.d.ts +8 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.js +4 -0
- package/dist/highlight.d.ts +22 -0
- package/dist/highlight.js +230 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/parse.d.ts +81 -0
- package/dist/parse.js +297 -0
- package/package.json +47 -5
- package/README.md +0 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { parseMarkdown } from './parse.js';
|
|
5
|
+
import MarkdownBlocks from './MarkdownBlocks.svelte';
|
|
6
|
+
import { provide } from './context.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
text,
|
|
10
|
+
headingOffset = 2,
|
|
11
|
+
code,
|
|
12
|
+
link,
|
|
13
|
+
class: klass = '',
|
|
14
|
+
...rest
|
|
15
|
+
}: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> & {
|
|
16
|
+
/** The Markdown source: paragraphs, headings, bold/italic/strikethrough, inline code, fenced code blocks, lists (with task items), quotes, tables, rules, and http/https/mailto links including bare URLs. Images show their alt text and a link. HTML in it is shown as text; nothing is injected as markup. */
|
|
17
|
+
text: string;
|
|
18
|
+
/** Added to the heading level, capped at 6: with `2`, `#` renders as `<h3>`, so rendered text never adds a page-level heading. */
|
|
19
|
+
headingOffset?: number;
|
|
20
|
+
/** Renders a fenced code block in place of the default `<pre><code>`, for syntax highlighting or a copy button. Receives the language (empty when none) and the code. */
|
|
21
|
+
code?: Snippet<[{ lang: string; text: string }]>;
|
|
22
|
+
/** Renders a link in place of the default `<a target="_blank" rel="noopener noreferrer">`, for client-side routing or a link preview. Only http, https and mailto links reach it; `children` is the link text. */
|
|
23
|
+
link?: Snippet<[{ href: string; children: Snippet }]>;
|
|
24
|
+
/** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
|
|
25
|
+
class?: string;
|
|
26
|
+
} = $props();
|
|
27
|
+
|
|
28
|
+
provide({ get headingOffset() { return headingOffset; }, get code() { return code; }, get link() { return link; } });
|
|
29
|
+
const nodes = $derived(parseMarkdown(text));
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div {...rest} class={['md', klass]}><MarkdownBlocks {nodes} /></div>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.md { min-width: 0; overflow-wrap: anywhere; line-height: 1.5; }
|
|
36
|
+
.md > :global(:first-child) { margin-top: 0; }
|
|
37
|
+
.md > :global(:last-child) { margin-bottom: 0; }
|
|
38
|
+
.md :global(p), .md :global(ul), .md :global(ol), .md :global(blockquote) { margin: 0 0 0.6em; }
|
|
39
|
+
.md :global(h3), .md :global(h4), .md :global(h5), .md :global(h6) { margin: 0.8em 0 0.3em; font-size: 1em; line-height: 1.3; }
|
|
40
|
+
.md :global(h3) { font-size: 1.15em; }
|
|
41
|
+
.md :global(h4) { font-size: 1.05em; }
|
|
42
|
+
.md :global(h1), .md :global(h2) { margin: 0.8em 0 0.3em; line-height: 1.25; }
|
|
43
|
+
.md :global(ul), .md :global(ol) { padding-inline-start: 1.5em; }
|
|
44
|
+
.md :global(li > ul), .md :global(li > ol) { margin: 0.2em 0 0; }
|
|
45
|
+
.md :global(a) { color: var(--tr-primary-text, var(--tr-primary)); text-decoration: underline; text-underline-offset: 2px; }
|
|
46
|
+
.md :global(code) { padding: 0.1em 0.35em; border-radius: var(--tr-radius-sm); background: var(--tr-surface-container-high); color: var(--tr-on-surface); font-family: var(--tr-font-mono); font-size: 0.9em; }
|
|
47
|
+
.md :global(blockquote) { padding-inline-start: var(--tr-space-3); border-inline-start: 3px solid var(--tr-outline); color: var(--tr-on-surface-variant); }
|
|
48
|
+
.md :global(.table) { max-width: 100%; margin: 0 0 0.6em; overflow-x: auto; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface); }
|
|
49
|
+
.md :global(.table:focus-visible) { outline: none; box-shadow: var(--tr-focus-ring); }
|
|
50
|
+
.md :global(table) { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--tr-text-md); }
|
|
51
|
+
.md :global(th), .md :global(td) { padding: var(--tr-space-1) var(--tr-space-2); text-align: start; vertical-align: top; overflow-wrap: normal; border-bottom: 1px solid var(--tr-outline-variant); }
|
|
52
|
+
.md :global(th) { background: var(--tr-surface-container); font-size: var(--tr-text-sm); font-weight: 700; letter-spacing: 0.02em; color: var(--tr-on-surface-variant); }
|
|
53
|
+
.md :global(tbody tr:last-child td) { border-bottom: 0; }
|
|
54
|
+
.md :global([data-align='left']) { text-align: left; }
|
|
55
|
+
.md :global([data-align='center']) { text-align: center; }
|
|
56
|
+
.md :global([data-align='right']) { text-align: right; }
|
|
57
|
+
.md :global(li.task) { list-style: none; margin-inline-start: -1.25em; }
|
|
58
|
+
.md :global(li.task input) { margin: 0 0.3em 0 0; vertical-align: middle; }
|
|
59
|
+
.md :global(hr) { margin: 0.8em 0; border: 0; border-top: 1px solid var(--tr-outline-variant); }
|
|
60
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> & {
|
|
4
|
+
/** The Markdown source: paragraphs, headings, bold/italic/strikethrough, inline code, fenced code blocks, lists (with task items), quotes, tables, rules, and http/https/mailto links including bare URLs. Images show their alt text and a link. HTML in it is shown as text; nothing is injected as markup. */
|
|
5
|
+
text: string;
|
|
6
|
+
/** Added to the heading level, capped at 6: with `2`, `#` renders as `<h3>`, so rendered text never adds a page-level heading. */
|
|
7
|
+
headingOffset?: number;
|
|
8
|
+
/** Renders a fenced code block in place of the default `<pre><code>`, for syntax highlighting or a copy button. Receives the language (empty when none) and the code. */
|
|
9
|
+
code?: Snippet<[{
|
|
10
|
+
lang: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}]>;
|
|
13
|
+
/** Renders a link in place of the default `<a target="_blank" rel="noopener noreferrer">`, for client-side routing or a link preview. Only http, https and mailto links reach it; `children` is the link text. */
|
|
14
|
+
link?: Snippet<[{
|
|
15
|
+
href: string;
|
|
16
|
+
children: Snippet;
|
|
17
|
+
}]>;
|
|
18
|
+
/** Other attributes (`data-testid`, `id`, ...) go to the root `<div>`. `class` is added to it. */
|
|
19
|
+
class?: string;
|
|
20
|
+
};
|
|
21
|
+
declare const Markdown: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
22
|
+
type Markdown = ReturnType<typeof Markdown>;
|
|
23
|
+
export default Markdown;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { BlockNode } from './parse.js';
|
|
3
|
+
import MarkdownBlocks from './MarkdownBlocks.svelte';
|
|
4
|
+
import MarkdownInlines from './MarkdownInlines.svelte';
|
|
5
|
+
import MarkdownCode from './MarkdownCode.svelte';
|
|
6
|
+
import { use } from './context.js';
|
|
7
|
+
|
|
8
|
+
/** Renders block nodes (paragraph, heading, list, code block, blockquote, rule) with plain elements. */
|
|
9
|
+
let { nodes }: { nodes: BlockNode[] } = $props();
|
|
10
|
+
const ctx = use();
|
|
11
|
+
|
|
12
|
+
/** A scrolling region must be reachable by keyboard so it can be scrolled with the arrow keys. */
|
|
13
|
+
const focusable = (node: HTMLElement) => void (node.tabIndex = 0);
|
|
14
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#each nodes as n}
|
|
18
|
+
{#if n.type === 'paragraph'}
|
|
19
|
+
<p><MarkdownInlines nodes={n.children} /></p>
|
|
20
|
+
{:else if n.type === 'heading'}
|
|
21
|
+
<svelte:element this={`h${Math.min(6, n.level + ctx.headingOffset)}`}><MarkdownInlines nodes={n.children} /></svelte:element>
|
|
22
|
+
{:else if n.type === 'list'}
|
|
23
|
+
<svelte:element this={n.ordered ? 'ol' : 'ul'} start={n.ordered && n.start !== 1 ? n.start : undefined}>
|
|
24
|
+
{#each n.items as it}
|
|
25
|
+
{#if it.checked === undefined}
|
|
26
|
+
<li><MarkdownInlines nodes={it.children} /><MarkdownBlocks nodes={it.blocks} /></li>
|
|
27
|
+
{:else}
|
|
28
|
+
<li class="task"><label><input type="checkbox" disabled checked={it.checked} /> <MarkdownInlines nodes={it.children} /></label><MarkdownBlocks nodes={it.blocks} /></li>
|
|
29
|
+
{/if}
|
|
30
|
+
{/each}
|
|
31
|
+
</svelte:element>
|
|
32
|
+
{:else if n.type === 'code'}
|
|
33
|
+
<MarkdownCode lang={n.lang} text={n.text} />
|
|
34
|
+
{:else if n.type === 'table'}
|
|
35
|
+
<div class="table" role="region" {@attach focusable} aria-label={`Table, ${plural(n.head.length, 'column')}, ${plural(n.rows.length, 'row')}`}>
|
|
36
|
+
<table>
|
|
37
|
+
<thead>
|
|
38
|
+
<tr>{#each n.head as cell, k}<th scope="col" data-align={n.align[k] ?? undefined}><MarkdownInlines nodes={cell} /></th>{/each}</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
{#if n.rows.length}
|
|
41
|
+
<tbody>
|
|
42
|
+
{#each n.rows as row}
|
|
43
|
+
<tr>{#each row as cell, k}<td data-align={n.align[k] ?? undefined}><MarkdownInlines nodes={cell} /></td>{/each}</tr>
|
|
44
|
+
{/each}
|
|
45
|
+
</tbody>
|
|
46
|
+
{/if}
|
|
47
|
+
</table>
|
|
48
|
+
</div>
|
|
49
|
+
{:else if n.type === 'blockquote'}
|
|
50
|
+
<blockquote><MarkdownBlocks nodes={n.children} /></blockquote>
|
|
51
|
+
{:else}
|
|
52
|
+
<hr />
|
|
53
|
+
{/if}
|
|
54
|
+
{/each}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BlockNode } from './parse.js';
|
|
2
|
+
import MarkdownBlocks from './MarkdownBlocks.svelte';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
nodes: BlockNode[];
|
|
5
|
+
};
|
|
6
|
+
declare const MarkdownBlocks: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type MarkdownBlocks = ReturnType<typeof MarkdownBlocks>;
|
|
8
|
+
export default MarkdownBlocks;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { Button, Tooltip } from '@ruckc/truss-ui';
|
|
4
|
+
import { highlight, resolveLanguage, type Token } from './highlight.js';
|
|
5
|
+
import { use } from './context.js';
|
|
6
|
+
|
|
7
|
+
/** A fenced code block: highlighted tokens inside a scrollable `<pre>`, a language label and a Copy button, or the caller's `code` snippet. */
|
|
8
|
+
let { lang, text }: { lang: string; text: string } = $props();
|
|
9
|
+
const ctx = use();
|
|
10
|
+
|
|
11
|
+
/** One flat run of tokens, with a plain "\n" token between lines. */
|
|
12
|
+
const tokens = $derived(highlight(text, lang).flatMap((line, i): Token[] => (i ? [{ type: 'plain', text: '\n' }, ...line] : line)));
|
|
13
|
+
const label = $derived(lang || 'text');
|
|
14
|
+
let status = $state('');
|
|
15
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
16
|
+
onDestroy(() => clearTimeout(timer));
|
|
17
|
+
|
|
18
|
+
/** A scrolling region must be reachable by keyboard so it can be scrolled with the arrow keys. */
|
|
19
|
+
const focusable = (node: HTMLElement) => void (node.tabIndex = 0);
|
|
20
|
+
|
|
21
|
+
async function copy() {
|
|
22
|
+
try {
|
|
23
|
+
await navigator.clipboard.writeText(text);
|
|
24
|
+
status = 'Copied';
|
|
25
|
+
} catch {
|
|
26
|
+
status = 'Copy failed';
|
|
27
|
+
}
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
timer = setTimeout(() => (status = ''), 2000);
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
{#if ctx.code}
|
|
34
|
+
{@render ctx.code({ lang, text })}
|
|
35
|
+
{:else}
|
|
36
|
+
<figure class="code" data-lang={resolveLanguage(lang) ?? 'plain'}>
|
|
37
|
+
<figcaption>
|
|
38
|
+
<span class="lang">{label}</span>
|
|
39
|
+
<Tooltip text="Copy code">
|
|
40
|
+
<Button variant="minimal" size="sm" icon={status === 'Copied' ? 'tick' : 'copy'} aria-label="Copy code" onclick={copy} />
|
|
41
|
+
</Tooltip>
|
|
42
|
+
<span class="tr-visually-hidden" role="status" aria-live="polite">{status}</span>
|
|
43
|
+
</figcaption>
|
|
44
|
+
<pre role="region" {@attach focusable} aria-label={`${label} code`}><code class={lang ? `language-${lang}` : undefined}
|
|
45
|
+
>{#each tokens as t}{#if t.type === 'plain'}{t.text}{:else}<span class="tok-{t.type}">{t.text}</span>{/if}{/each}</code></pre>
|
|
46
|
+
</figure>
|
|
47
|
+
{/if}
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.code { margin: 0 0 0.6em; max-width: 100%; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface-container); overflow: hidden; }
|
|
51
|
+
figcaption { display: flex; align-items: center; justify-content: space-between; gap: var(--tr-space-2); padding: 0 var(--tr-space-1) 0 var(--tr-space-3); border-bottom: 1px solid var(--tr-outline-variant); color: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); }
|
|
52
|
+
.lang { font-family: var(--tr-font-mono); }
|
|
53
|
+
pre { margin: 0; max-width: 100%; overflow-x: auto; padding: var(--tr-space-3); border: 0; border-radius: 0; background: none; color: var(--tr-on-surface); font-family: var(--tr-font-mono); font-size: 0.875em; line-height: 1.5; tab-size: 2; }
|
|
54
|
+
pre:focus-visible { outline: none; box-shadow: inset var(--tr-focus-ring); }
|
|
55
|
+
code { padding: 0; background: none; font: inherit; white-space: pre; }
|
|
56
|
+
.tok-keyword { color: var(--tr-syntax-keyword); }
|
|
57
|
+
.tok-string { color: var(--tr-syntax-string); }
|
|
58
|
+
.tok-number { color: var(--tr-syntax-number); }
|
|
59
|
+
.tok-comment { color: var(--tr-syntax-comment); font-style: italic; }
|
|
60
|
+
.tok-function { color: var(--tr-syntax-function); }
|
|
61
|
+
.tok-type { color: var(--tr-syntax-type); }
|
|
62
|
+
.tok-variable { color: var(--tr-syntax-variable); }
|
|
63
|
+
.tok-constant { color: var(--tr-syntax-constant); }
|
|
64
|
+
.tok-operator { color: var(--tr-syntax-operator); }
|
|
65
|
+
.tok-punctuation { color: var(--tr-syntax-punctuation); }
|
|
66
|
+
.tok-tag { color: var(--tr-syntax-tag); }
|
|
67
|
+
.tok-attr { color: var(--tr-syntax-attr); }
|
|
68
|
+
.tok-regexp { color: var(--tr-syntax-regexp); }
|
|
69
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { InlineNode } from './parse.js';
|
|
3
|
+
import MarkdownInlines from './MarkdownInlines.svelte';
|
|
4
|
+
import MarkdownLink from './MarkdownLink.svelte';
|
|
5
|
+
|
|
6
|
+
/** Renders inline nodes (text, strong, em, del, code, link, line break). Every string is a plain Svelte text expression, which Svelte escapes. */
|
|
7
|
+
let { nodes }: { nodes: InlineNode[] } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
{#each nodes as n}
|
|
11
|
+
{#if n.type === 'text'}{n.value}{:else if n.type === 'strong'}<strong><MarkdownInlines nodes={n.children} /></strong>{:else if n.type === 'em'}<em><MarkdownInlines nodes={n.children} /></em>{:else if n.type === 'del'}<del><MarkdownInlines nodes={n.children} /></del>{:else if n.type === 'code'}<code>{n.value}</code>{:else if n.type === 'br'}<br />{:else}<MarkdownLink href={n.href}><MarkdownInlines nodes={n.children} /></MarkdownLink>{/if}
|
|
12
|
+
{/each}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InlineNode } from './parse.js';
|
|
2
|
+
import MarkdownInlines from './MarkdownInlines.svelte';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
nodes: InlineNode[];
|
|
5
|
+
};
|
|
6
|
+
declare const MarkdownInlines: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type MarkdownInlines = ReturnType<typeof MarkdownInlines>;
|
|
8
|
+
export default MarkdownInlines;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { use } from './context.js';
|
|
4
|
+
|
|
5
|
+
/** A link node. `href` is already restricted to http, https and mailto by the parser. */
|
|
6
|
+
let { href, children }: { href: string; children: Snippet } = $props();
|
|
7
|
+
const ctx = use();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
{#if ctx.link}{@render ctx.link({ href, children })}{:else}<a {href} target="_blank" rel="noopener noreferrer">{@render children()}</a>{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
href: string;
|
|
4
|
+
children: Snippet;
|
|
5
|
+
};
|
|
6
|
+
declare const MarkdownLink: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type MarkdownLink = ReturnType<typeof MarkdownLink>;
|
|
8
|
+
export default MarkdownLink;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
/** What a `Markdown` root hands to the nodes below it, read lazily so the overrides stay reactive. */
|
|
3
|
+
export type MarkdownContext = {
|
|
4
|
+
readonly headingOffset: number;
|
|
5
|
+
readonly code: Snippet<[{
|
|
6
|
+
lang: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}]> | undefined;
|
|
9
|
+
readonly link: Snippet<[{
|
|
10
|
+
href: string;
|
|
11
|
+
children: Snippet;
|
|
12
|
+
}]> | undefined;
|
|
13
|
+
};
|
|
14
|
+
export declare const provide: (c: MarkdownContext) => MarkdownContext;
|
|
15
|
+
export declare const use: () => MarkdownContext;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-free syntax tokenizer for code blocks. Each language is a short list of sticky regular expressions
|
|
3
|
+
* tried in order at the current position, plus a word table for keywords, constants and types. The result is plain data,
|
|
4
|
+
* `{ type, text }[]` per line, that a component renders as text in `<span>`s: no HTML string is ever built.
|
|
5
|
+
*
|
|
6
|
+
* It highlights by shape, not by parsing, so it is forgiving: an unterminated string or comment runs to the end of its
|
|
7
|
+
* line (or the end of the code for block comments), and an unknown language is returned as plain text.
|
|
8
|
+
*/
|
|
9
|
+
export type TokenType = 'keyword' | 'string' | 'number' | 'comment' | 'function' | 'type' | 'variable' | 'constant' | 'operator' | 'punctuation' | 'tag' | 'attr' | 'regexp' | 'plain';
|
|
10
|
+
export type Token = {
|
|
11
|
+
type: TokenType;
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
/** The canonical language name for a fence label (`ts`, `TypeScript`, `yml`, ...), or `undefined` when it is not highlighted. */
|
|
15
|
+
export declare function resolveLanguage(lang: string | undefined): string | undefined;
|
|
16
|
+
/** The language names that are highlighted. */
|
|
17
|
+
export declare const languages: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Tokenizes `code` for `lang` (any alias, see `resolveLanguage`), one array of tokens per line. Joining every token's text with
|
|
20
|
+
* the line breaks gives back the code exactly. An unknown or missing language yields `plain` tokens.
|
|
21
|
+
*/
|
|
22
|
+
export declare function highlight(code: string, lang?: string): Token[][];
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-free syntax tokenizer for code blocks. Each language is a short list of sticky regular expressions
|
|
3
|
+
* tried in order at the current position, plus a word table for keywords, constants and types. The result is plain data,
|
|
4
|
+
* `{ type, text }[]` per line, that a component renders as text in `<span>`s: no HTML string is ever built.
|
|
5
|
+
*
|
|
6
|
+
* It highlights by shape, not by parsing, so it is forgiving: an unterminated string or comment runs to the end of its
|
|
7
|
+
* line (or the end of the code for block comments), and an unknown language is returned as plain text.
|
|
8
|
+
*/
|
|
9
|
+
const words = (type, list) => Object.fromEntries(list.split(' ').map((w) => [w, type]));
|
|
10
|
+
const rule = (type, source, extra = {}) => ({ type, re: new RegExp(source, 'y'), ...extra });
|
|
11
|
+
// shared pieces
|
|
12
|
+
const BLOCK = '/\\*[\\s\\S]*?(?:\\*/|$)';
|
|
13
|
+
const DQ = '"(?:[^"\\\\\\n]|\\\\.)*"?';
|
|
14
|
+
const SQ = "'(?:[^'\\\\\\n]|\\\\.)*'?";
|
|
15
|
+
const TPL = '`(?:[^`\\\\]|\\\\[\\s\\S])*`?';
|
|
16
|
+
const NUM = '(?:0[xX][\\da-fA-F_]+|\\d[\\d_]*(?:\\.\\d[\\d_]*)?(?:[eE][+-]?\\d+)?)[A-Za-z]*';
|
|
17
|
+
const OP = '[+\\-*/%=<>!&|^~?:@]+';
|
|
18
|
+
const PUNCT = '[{}()[\\];,.]';
|
|
19
|
+
const IDENT = '[A-Za-z_$][\\w$]*';
|
|
20
|
+
const comment = (source) => rule('comment', source);
|
|
21
|
+
const common = [rule('number', NUM), rule('operator', OP), rule('punctuation', PUNCT)];
|
|
22
|
+
const ident = rule('plain', IDENT, { word: true });
|
|
23
|
+
/** The character before the first non-blank one at or before `i`, to tell a regular expression from a division. */
|
|
24
|
+
const prevSignificant = (src, i) => src.slice(0, i).trimEnd().slice(-1);
|
|
25
|
+
const JS_WORDS = {
|
|
26
|
+
...words('keyword', 'as async await break case catch class const continue debugger default delete do else enum export extends finally for from function get if implements import in instanceof interface let new of package private protected public readonly return set static super switch throw try typeof var void while with yield abstract declare namespace type keyof satisfies'),
|
|
27
|
+
...words('constant', 'true false null undefined NaN Infinity this'),
|
|
28
|
+
...words('type', 'string number boolean any unknown never object symbol bigint void Array Promise Record Map Set')
|
|
29
|
+
};
|
|
30
|
+
const js = {
|
|
31
|
+
rules: [
|
|
32
|
+
comment('//[^\\n]*'), comment(BLOCK), rule('string', DQ), rule('string', SQ), rule('string', TPL),
|
|
33
|
+
rule('regexp', '/(?![*/])(?:[^/\\\\\\n[]|\\\\.|\\[(?:[^\\]\\\\\\n]|\\\\.)*\\])+/[dgimsuvy]*', { when: (_s, i, src) => src[i] === '/' && !/[\w$)\]]/.test(prevSignificant(src, i)) }),
|
|
34
|
+
ident, ...common
|
|
35
|
+
],
|
|
36
|
+
words: JS_WORDS,
|
|
37
|
+
caps: true
|
|
38
|
+
};
|
|
39
|
+
const jsx = {
|
|
40
|
+
...js,
|
|
41
|
+
rules: [
|
|
42
|
+
...js.rules.slice(0, 2),
|
|
43
|
+
rule('tag', '</?[A-Za-z][\\w.]*', { when: (_s, i, src) => src[i] === '<' && !/[\w$)\]]/.test(prevSignificant(src, i)) }),
|
|
44
|
+
rule('tag', '/>'),
|
|
45
|
+
...js.rules.slice(2)
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
const json = {
|
|
49
|
+
rules: [
|
|
50
|
+
comment('//[^\\n]*'), comment(BLOCK),
|
|
51
|
+
rule('attr', '"(?:[^"\\\\\\n]|\\\\.)*"(?=\\s*:)'), rule('string', DQ),
|
|
52
|
+
ident, rule('number', '-?' + NUM), rule('punctuation', '[{}[\\]:,]')
|
|
53
|
+
],
|
|
54
|
+
words: words('constant', 'true false null')
|
|
55
|
+
};
|
|
56
|
+
const python = {
|
|
57
|
+
rules: [
|
|
58
|
+
comment('#[^\\n]*'),
|
|
59
|
+
rule('string', '(?:[rRbBfFuU]{1,2})?(?:"""[\\s\\S]*?(?:"""|$)|\'\'\'[\\s\\S]*?(?:\'\'\'|$)|' + DQ + '|' + SQ + ')'),
|
|
60
|
+
rule('function', '@[\\w.]+'), ident, ...common
|
|
61
|
+
],
|
|
62
|
+
words: {
|
|
63
|
+
...words('keyword', 'and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case'),
|
|
64
|
+
...words('constant', 'True False None self cls'),
|
|
65
|
+
...words('type', 'int str float bool list dict set tuple bytes object type')
|
|
66
|
+
},
|
|
67
|
+
caps: true
|
|
68
|
+
};
|
|
69
|
+
const SQL_KEYWORDS = 'select from where and or not in is null like between join inner left right full outer cross on as group by order having limit offset union all distinct insert into values update set delete create alter drop table index view database primary key foreign references default unique check constraint case when then else end exists with returning asc desc begin commit rollback';
|
|
70
|
+
const sql = {
|
|
71
|
+
rules: [comment('--[^\\n]*'), comment(BLOCK), rule('string', SQ), rule('variable', '"(?:[^"\\\\\\n]|\\\\.)*"?|`[^`\\n]*`?'), ident, ...common],
|
|
72
|
+
words: { ...words('keyword', SQL_KEYWORDS), ...words('constant', 'true false null'), ...words('type', 'int integer bigint smallint serial text varchar char boolean bool date time timestamp numeric decimal float real json jsonb uuid') },
|
|
73
|
+
ci: true
|
|
74
|
+
};
|
|
75
|
+
const bash = {
|
|
76
|
+
rules: [
|
|
77
|
+
rule('comment', '#[^\\n]*', { when: (_s, i, src) => i === 0 || /\s|[;|&(]/.test(src[i - 1]) }),
|
|
78
|
+
rule('string', DQ), rule('string', "'[^']*'?"),
|
|
79
|
+
rule('variable', '\\$(?:\\{[^}\\n]*\\}?|[A-Za-z_]\\w*|[0-9@*#?$!-])'),
|
|
80
|
+
rule('attr', '--?[A-Za-z][\\w-]*(?:=)?', { when: (_s, i, src) => i === 0 || /\s/.test(src[i - 1]) }),
|
|
81
|
+
ident, rule('number', '\\d+'), rule('operator', '[|&<>=!;]+|\\$\\(|`'), rule('punctuation', '[{}()[\\],]')
|
|
82
|
+
],
|
|
83
|
+
words: {
|
|
84
|
+
...words('keyword', 'if then elif else fi for while until do done case esac in function select time return exit break continue local export readonly declare unset source alias'),
|
|
85
|
+
...words('function', 'echo cd ls cat grep sed awk git npm npx node curl sudo mkdir rm cp mv chmod printf test read eval exec set trap')
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const markup = (svelte) => ({
|
|
89
|
+
rules: [
|
|
90
|
+
comment('<!--[\\s\\S]*?(?:-->|$)'),
|
|
91
|
+
rule('tag', '</?[A-Za-z][\\w:.-]*', { on: (s) => { s.inTag = true; } }),
|
|
92
|
+
rule('tag', '/?>', { when: (s) => s.inTag, on: (s) => { s.inTag = false; } }),
|
|
93
|
+
rule('string', DQ, { when: (s) => s.inTag }), rule('string', SQ, { when: (s) => s.inTag }),
|
|
94
|
+
rule('attr', '[A-Za-z_:@][\\w:.@-]*', { when: (s) => s.inTag }),
|
|
95
|
+
rule('operator', '=', { when: (s) => s.inTag }),
|
|
96
|
+
rule('constant', '&#?\\w+;'),
|
|
97
|
+
...(svelte ? [rule('keyword', '\\{[#:/@](?:if|else|each|await|then|catch|key|snippet|render|html|const|debug)\\b'), rule('punctuation', '[{}]')] : [])
|
|
98
|
+
],
|
|
99
|
+
words: {}
|
|
100
|
+
});
|
|
101
|
+
const css = {
|
|
102
|
+
rules: [
|
|
103
|
+
comment(BLOCK), rule('string', DQ), rule('string', SQ),
|
|
104
|
+
rule('keyword', '@[\\w-]+'),
|
|
105
|
+
rule('number', '#[\\da-fA-F]{3,8}\\b', { when: (s) => s.depth > 0 }),
|
|
106
|
+
rule('variable', '--[\\w-]+'),
|
|
107
|
+
rule('attr', '[A-Za-z-]+(?=\\s*:)', { when: (s) => s.depth > 0 }),
|
|
108
|
+
rule('type', '[.#][\\w-]+(?=[^;{}]*\\{)'),
|
|
109
|
+
rule('number', NUM + '%?'),
|
|
110
|
+
rule('function', '[A-Za-z-]+(?=\\()'),
|
|
111
|
+
rule('punctuation', '[{]', { on: (s) => { s.depth++; } }),
|
|
112
|
+
rule('punctuation', '[}]', { on: (s) => { s.depth = Math.max(0, s.depth - 1); } }),
|
|
113
|
+
rule('punctuation', '[();,:[\\]]'), rule('operator', '[>+~*=!]+')
|
|
114
|
+
],
|
|
115
|
+
words: {}
|
|
116
|
+
};
|
|
117
|
+
const yaml = {
|
|
118
|
+
rules: [
|
|
119
|
+
comment('#[^\\n]*'), rule('string', DQ), rule('string', SQ),
|
|
120
|
+
rule('attr', '[\\w.-]+(?=[ \\t]*:(?:\\s|$))'),
|
|
121
|
+
rule('variable', '[&*][\\w-]+'),
|
|
122
|
+
rule('punctuation', '---|\\.\\.\\.|[-:,[\\]{}|>?]'),
|
|
123
|
+
ident, rule('number', '-?\\d[\\d_]*(?:\\.\\d+)?')
|
|
124
|
+
],
|
|
125
|
+
words: words('constant', 'true false null yes no on off True False Null')
|
|
126
|
+
};
|
|
127
|
+
const go = {
|
|
128
|
+
rules: [comment('//[^\\n]*'), comment(BLOCK), rule('string', DQ), rule('string', SQ), rule('string', '`[^`]*`?'), ident, ...common],
|
|
129
|
+
words: {
|
|
130
|
+
...words('keyword', 'break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var'),
|
|
131
|
+
...words('constant', 'true false nil iota'),
|
|
132
|
+
...words('type', 'int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr float32 float64 complex64 complex128 string bool byte rune error any')
|
|
133
|
+
},
|
|
134
|
+
caps: true
|
|
135
|
+
};
|
|
136
|
+
const rust = {
|
|
137
|
+
rules: [
|
|
138
|
+
comment('//[^\\n]*'), comment(BLOCK), rule('string', DQ), rule('string', "'(?:[^'\\\\\\n]|\\\\.)'"),
|
|
139
|
+
rule('variable', "'[A-Za-z_]\\w*"), rule('function', '[A-Za-z_]\\w*!(?=[(\\[{])'), ident, ...common
|
|
140
|
+
],
|
|
141
|
+
words: {
|
|
142
|
+
...words('keyword', 'as async await break const continue crate dyn else enum extern fn for if impl in let loop match mod move mut pub ref return static struct super trait type unsafe use where while'),
|
|
143
|
+
...words('constant', 'true false None Some Ok Err self Self'),
|
|
144
|
+
...words('type', 'i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String Vec Option Result Box')
|
|
145
|
+
},
|
|
146
|
+
caps: true
|
|
147
|
+
};
|
|
148
|
+
const ALIASES = {
|
|
149
|
+
js: 'js', javascript: 'js', mjs: 'js', cjs: 'js', ts: 'js', typescript: 'js', mts: 'js', cts: 'js',
|
|
150
|
+
jsx: 'jsx', tsx: 'jsx',
|
|
151
|
+
json: 'json', jsonc: 'json', json5: 'json',
|
|
152
|
+
py: 'python', python: 'python', python3: 'python',
|
|
153
|
+
sql: 'sql', postgres: 'sql', postgresql: 'sql', mysql: 'sql', sqlite: 'sql',
|
|
154
|
+
bash: 'bash', sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash',
|
|
155
|
+
html: 'html', htm: 'html', xml: 'html', svg: 'html', svelte: 'svelte', vue: 'svelte',
|
|
156
|
+
css: 'css',
|
|
157
|
+
yaml: 'yaml', yml: 'yaml',
|
|
158
|
+
diff: 'diff', patch: 'diff',
|
|
159
|
+
go: 'go', golang: 'go',
|
|
160
|
+
rust: 'rust', rs: 'rust'
|
|
161
|
+
};
|
|
162
|
+
const GRAMMARS = { js, jsx, json, python, sql, bash, html: markup(false), svelte: markup(true), css, yaml, go, rust };
|
|
163
|
+
/** The canonical language name for a fence label (`ts`, `TypeScript`, `yml`, ...), or `undefined` when it is not highlighted. */
|
|
164
|
+
export function resolveLanguage(lang) {
|
|
165
|
+
return ALIASES[(lang ?? '').trim().toLowerCase()];
|
|
166
|
+
}
|
|
167
|
+
/** The language names that are highlighted. */
|
|
168
|
+
export const languages = [...new Set(Object.values(ALIASES))];
|
|
169
|
+
function classify(g, word, after) {
|
|
170
|
+
const known = g.words[g.ci ? word.toLowerCase() : word];
|
|
171
|
+
if (known)
|
|
172
|
+
return known;
|
|
173
|
+
if (after === '(')
|
|
174
|
+
return 'function';
|
|
175
|
+
return g.caps && /^[A-Z]/.test(word) ? 'type' : 'plain';
|
|
176
|
+
}
|
|
177
|
+
function scan(src, g) {
|
|
178
|
+
const out = [];
|
|
179
|
+
const s = { inTag: false, depth: 0 };
|
|
180
|
+
const add = (type, text) => {
|
|
181
|
+
const last = out[out.length - 1];
|
|
182
|
+
if (last?.type === type)
|
|
183
|
+
last.text += text;
|
|
184
|
+
else
|
|
185
|
+
out.push({ type, text });
|
|
186
|
+
};
|
|
187
|
+
let i = 0;
|
|
188
|
+
outer: while (i < src.length) {
|
|
189
|
+
for (const r of g.rules) {
|
|
190
|
+
if (r.when && !r.when(s, i, src))
|
|
191
|
+
continue;
|
|
192
|
+
r.re.lastIndex = i;
|
|
193
|
+
const m = r.re.exec(src);
|
|
194
|
+
if (!m)
|
|
195
|
+
continue;
|
|
196
|
+
r.on?.(s, m[0]);
|
|
197
|
+
add(r.word ? classify(g, m[0], src[i + m[0].length]) : r.type, m[0]);
|
|
198
|
+
i += m[0].length;
|
|
199
|
+
continue outer;
|
|
200
|
+
}
|
|
201
|
+
add('plain', src[i++]);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/** Diffs are highlighted per line: additions, removals, hunk headers and file headers. */
|
|
206
|
+
function diff(src) {
|
|
207
|
+
return src.split('\n').map((line) => {
|
|
208
|
+
const type = /^(\+\+\+|---|diff |index )/.test(line) ? 'comment' : line.startsWith('+') ? 'string' : line.startsWith('-') ? 'tag' : line.startsWith('@@') ? 'function' : 'plain';
|
|
209
|
+
return line ? [{ type, text: line }] : [];
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Tokenizes `code` for `lang` (any alias, see `resolveLanguage`), one array of tokens per line. Joining every token's text with
|
|
214
|
+
* the line breaks gives back the code exactly. An unknown or missing language yields `plain` tokens.
|
|
215
|
+
*/
|
|
216
|
+
export function highlight(code, lang) {
|
|
217
|
+
const name = resolveLanguage(lang);
|
|
218
|
+
if (name === 'diff')
|
|
219
|
+
return diff(code);
|
|
220
|
+
const tokens = name ? scan(code, GRAMMARS[name]) : [{ type: 'plain', text: code }];
|
|
221
|
+
const lines = [[]];
|
|
222
|
+
for (const t of tokens)
|
|
223
|
+
t.text.split('\n').forEach((part, k) => {
|
|
224
|
+
if (k)
|
|
225
|
+
lines.push([]);
|
|
226
|
+
if (part)
|
|
227
|
+
lines[lines.length - 1].push({ type: t.type, text: part });
|
|
228
|
+
});
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small Markdown parser for chat messages. Pure and dependency-free: it turns text into a typed AST, which the
|
|
3
|
+
* `Markdown` component renders with plain Svelte elements. No HTML string is ever produced, so every piece of text
|
|
4
|
+
* reaches the DOM as a text node and raw HTML in the input is simply shown as text.
|
|
5
|
+
*
|
|
6
|
+
* Link targets must start with `http://`, `https://` or `mailto:`; any other link is kept as its plain label text.
|
|
7
|
+
*
|
|
8
|
+
* Supported: paragraphs, line breaks, headings, thematic breaks, `**bold**`, `*italic*`, `_italic_`, `~~strike~~`,
|
|
9
|
+
* `` `code` ``, fenced code blocks, ordered and unordered lists (nested by indentation, with `[ ]`/`[x]` task items),
|
|
10
|
+
* blockquotes, GFM tables, `[text](url)` links, bare `https://`, `http://` and `www.` autolinks, `<https://...>`
|
|
11
|
+
* autolinks, `` images (shown as the alt text and a link, never loaded) and backslash escapes.
|
|
12
|
+
* Anything else is plain text.
|
|
13
|
+
*/
|
|
14
|
+
export type InlineNode = {
|
|
15
|
+
type: 'text';
|
|
16
|
+
value: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'strong';
|
|
19
|
+
children: InlineNode[];
|
|
20
|
+
} | {
|
|
21
|
+
type: 'em';
|
|
22
|
+
children: InlineNode[];
|
|
23
|
+
} | {
|
|
24
|
+
type: 'del';
|
|
25
|
+
children: InlineNode[];
|
|
26
|
+
} | {
|
|
27
|
+
type: 'code';
|
|
28
|
+
value: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'link';
|
|
31
|
+
href: string;
|
|
32
|
+
children: InlineNode[];
|
|
33
|
+
} | {
|
|
34
|
+
type: 'br';
|
|
35
|
+
};
|
|
36
|
+
export type ListItemNode = {
|
|
37
|
+
type: 'listItem';
|
|
38
|
+
/** The item's own text. */
|
|
39
|
+
children: InlineNode[];
|
|
40
|
+
/** Nested lists and continuation blocks. */
|
|
41
|
+
blocks: BlockNode[];
|
|
42
|
+
/** Set on task list items (`- [ ] a`, `- [x] b`): whether the box is checked. */
|
|
43
|
+
checked?: boolean;
|
|
44
|
+
};
|
|
45
|
+
/** Column alignment of a table: `:---` left, `:---:` center, `---:` right, `---` none. */
|
|
46
|
+
export type TableAlign = 'left' | 'center' | 'right' | null;
|
|
47
|
+
export type BlockNode = {
|
|
48
|
+
type: 'paragraph';
|
|
49
|
+
children: InlineNode[];
|
|
50
|
+
} | {
|
|
51
|
+
type: 'heading';
|
|
52
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
53
|
+
children: InlineNode[];
|
|
54
|
+
} | {
|
|
55
|
+
type: 'list';
|
|
56
|
+
ordered: boolean;
|
|
57
|
+
start: number;
|
|
58
|
+
items: ListItemNode[];
|
|
59
|
+
} | {
|
|
60
|
+
type: 'code';
|
|
61
|
+
lang: string;
|
|
62
|
+
text: string;
|
|
63
|
+
} | {
|
|
64
|
+
type: 'blockquote';
|
|
65
|
+
children: BlockNode[];
|
|
66
|
+
}
|
|
67
|
+
/** A GFM table. `align`, `head` and every row have one entry per column. */
|
|
68
|
+
| {
|
|
69
|
+
type: 'table';
|
|
70
|
+
align: TableAlign[];
|
|
71
|
+
head: InlineNode[][];
|
|
72
|
+
rows: InlineNode[][][];
|
|
73
|
+
} | {
|
|
74
|
+
type: 'hr';
|
|
75
|
+
};
|
|
76
|
+
/** True when `url` may be used as a link `href`. */
|
|
77
|
+
export declare const isSafeUrl: (url: string) => boolean;
|
|
78
|
+
/** Parses inline markdown. */
|
|
79
|
+
export declare function parseInline(src: string): InlineNode[];
|
|
80
|
+
/** Parses markdown text into block nodes. */
|
|
81
|
+
export declare function parseMarkdown(text: string): BlockNode[];
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small Markdown parser for chat messages. Pure and dependency-free: it turns text into a typed AST, which the
|
|
3
|
+
* `Markdown` component renders with plain Svelte elements. No HTML string is ever produced, so every piece of text
|
|
4
|
+
* reaches the DOM as a text node and raw HTML in the input is simply shown as text.
|
|
5
|
+
*
|
|
6
|
+
* Link targets must start with `http://`, `https://` or `mailto:`; any other link is kept as its plain label text.
|
|
7
|
+
*
|
|
8
|
+
* Supported: paragraphs, line breaks, headings, thematic breaks, `**bold**`, `*italic*`, `_italic_`, `~~strike~~`,
|
|
9
|
+
* `` `code` ``, fenced code blocks, ordered and unordered lists (nested by indentation, with `[ ]`/`[x]` task items),
|
|
10
|
+
* blockquotes, GFM tables, `[text](url)` links, bare `https://`, `http://` and `www.` autolinks, `<https://...>`
|
|
11
|
+
* autolinks, `` images (shown as the alt text and a link, never loaded) and backslash escapes.
|
|
12
|
+
* Anything else is plain text.
|
|
13
|
+
*/
|
|
14
|
+
/** An allowed link target: http, https or mailto, with no whitespace or control characters. */
|
|
15
|
+
const SAFE_URL = /^(?:https?:\/\/|mailto:)[^\s\u0000-\u001f\u007f]+$/i;
|
|
16
|
+
/** True when `url` may be used as a link `href`. */
|
|
17
|
+
export const isSafeUrl = (url) => SAFE_URL.test(url);
|
|
18
|
+
const ESCAPE = /\\([\\`*_[\]()#>+.!~|<-])/y;
|
|
19
|
+
const CODE = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/y;
|
|
20
|
+
const LINK = /\[([^\]\n]*)\]\(\s*([^()\s]*)\s*\)/y;
|
|
21
|
+
const IMAGE = /!\[([^\]\n]*)\]\(\s*([^()\s]*)\s*\)/y;
|
|
22
|
+
const STRONG_EM = /\*\*\*(?=[^\s*])([^*]*?[^\s*])\*\*\*/y;
|
|
23
|
+
const STRONG = /\*\*(?=\S)([\s\S]*?\S)\*\*/y;
|
|
24
|
+
const EM = /\*(?=[^\s*])([^*]*?[^\s*])\*(?!\*)/y;
|
|
25
|
+
const DEL = /~~(?=[^\s~])([\s\S]*?[^\s~])~~(?!~)/y;
|
|
26
|
+
const STRONG_U = /__(?=\S)([\s\S]*?\S)__(?!\w)/y;
|
|
27
|
+
const EM_U = /_(?=[^\s_])([^_]*?[^\s_])_(?!\w)/y;
|
|
28
|
+
const ANGLE = /<((?:https?:\/\/|mailto:)[^\s<>]+)>/iy;
|
|
29
|
+
const BARE = /(?:https?:\/\/|www\.)[^\s<]+/iy;
|
|
30
|
+
const BARE_HOST = /^(?:https?:\/\/|www\.)[\p{L}\p{N}]/iu;
|
|
31
|
+
const OPENER = { ')': '(', ']': '[', '}': '{' };
|
|
32
|
+
/** Applies the GFM rules for where a bare URL ends: trailing punctuation, unbalanced closers and `&entity;` are not part of it. */
|
|
33
|
+
function trimUrl(url) {
|
|
34
|
+
let prev;
|
|
35
|
+
do {
|
|
36
|
+
prev = url;
|
|
37
|
+
url = url.replace(/[?!.,:*_~'"]+$/, '');
|
|
38
|
+
const last = url[url.length - 1];
|
|
39
|
+
const open = OPENER[last];
|
|
40
|
+
if (open) {
|
|
41
|
+
if (url.split(last).length > url.split(open).length)
|
|
42
|
+
url = url.slice(0, -1);
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
url = url.replace(/&[a-z0-9]+;$/i, '');
|
|
46
|
+
} while (url !== prev);
|
|
47
|
+
return url;
|
|
48
|
+
}
|
|
49
|
+
/** Parses inline markdown. */
|
|
50
|
+
export function parseInline(src) {
|
|
51
|
+
return inline(src, false);
|
|
52
|
+
}
|
|
53
|
+
/** `inLink` is set inside link text, where a nested link would be invalid. */
|
|
54
|
+
function inline(src, inLink) {
|
|
55
|
+
const out = [];
|
|
56
|
+
let text = '';
|
|
57
|
+
let i = 0;
|
|
58
|
+
const flush = () => {
|
|
59
|
+
if (text)
|
|
60
|
+
out.push({ type: 'text', value: text });
|
|
61
|
+
text = '';
|
|
62
|
+
};
|
|
63
|
+
const at = (re) => {
|
|
64
|
+
re.lastIndex = i;
|
|
65
|
+
return re.exec(src);
|
|
66
|
+
};
|
|
67
|
+
while (i < src.length) {
|
|
68
|
+
const c = src[i];
|
|
69
|
+
let m = null;
|
|
70
|
+
if (c === '\\' && (m = at(ESCAPE))) {
|
|
71
|
+
text += m[1];
|
|
72
|
+
i += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (c === '`' && (m = at(CODE))) {
|
|
76
|
+
flush();
|
|
77
|
+
out.push({ type: 'code', value: m[2].trim() });
|
|
78
|
+
}
|
|
79
|
+
else if (c === '[' && (m = at(LINK))) {
|
|
80
|
+
flush();
|
|
81
|
+
const children = inline(m[1], true);
|
|
82
|
+
if (isSafeUrl(m[2]))
|
|
83
|
+
out.push({ type: 'link', href: m[2], children });
|
|
84
|
+
else
|
|
85
|
+
out.push(...children);
|
|
86
|
+
}
|
|
87
|
+
else if (c === '!' && src[i + 1] === '[' && (m = at(IMAGE))) {
|
|
88
|
+
// an image is never loaded: it becomes its alt text, linked when the address is safe
|
|
89
|
+
flush();
|
|
90
|
+
const label = [{ type: 'text', value: m[1] || 'image' }];
|
|
91
|
+
if (isSafeUrl(m[2]))
|
|
92
|
+
out.push({ type: 'link', href: m[2], children: label });
|
|
93
|
+
else
|
|
94
|
+
out.push(...label);
|
|
95
|
+
}
|
|
96
|
+
else if (c === '~' && (m = at(DEL))) {
|
|
97
|
+
flush();
|
|
98
|
+
out.push({ type: 'del', children: inline(m[1], inLink) });
|
|
99
|
+
}
|
|
100
|
+
else if (c === '<' && !inLink && (m = at(ANGLE))) {
|
|
101
|
+
flush();
|
|
102
|
+
out.push({ type: 'link', href: m[1], children: [{ type: 'text', value: m[1] }] });
|
|
103
|
+
}
|
|
104
|
+
else if (/[hHwW]/.test(c) && !inLink && (i === 0 || /[\s*_~(]/.test(src[i - 1])) && (m = at(BARE))) {
|
|
105
|
+
const url = trimUrl(m[0]);
|
|
106
|
+
if (BARE_HOST.test(url)) {
|
|
107
|
+
flush();
|
|
108
|
+
out.push({ type: 'link', href: /^www\./i.test(url) ? `https://${url}` : url, children: [{ type: 'text', value: url }] });
|
|
109
|
+
i += url.length;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
m = null;
|
|
113
|
+
}
|
|
114
|
+
else if (c === '*' && (m = at(STRONG_EM))) {
|
|
115
|
+
flush();
|
|
116
|
+
out.push({ type: 'strong', children: [{ type: 'em', children: inline(m[1], inLink) }] });
|
|
117
|
+
}
|
|
118
|
+
else if (c === '*' && (m = at(STRONG))) {
|
|
119
|
+
flush();
|
|
120
|
+
out.push({ type: 'strong', children: inline(m[1], inLink) });
|
|
121
|
+
}
|
|
122
|
+
else if (c === '*' && (m = at(EM))) {
|
|
123
|
+
flush();
|
|
124
|
+
out.push({ type: 'em', children: inline(m[1], inLink) });
|
|
125
|
+
}
|
|
126
|
+
else if (c === '_' && (i === 0 || !/\w/.test(src[i - 1])) && (m = at(STRONG_U))) {
|
|
127
|
+
flush();
|
|
128
|
+
out.push({ type: 'strong', children: inline(m[1], inLink) });
|
|
129
|
+
}
|
|
130
|
+
else if (c === '_' && (i === 0 || !/\w/.test(src[i - 1])) && (m = at(EM_U))) {
|
|
131
|
+
flush();
|
|
132
|
+
out.push({ type: 'em', children: inline(m[1], inLink) });
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
m = null;
|
|
136
|
+
}
|
|
137
|
+
if (m)
|
|
138
|
+
i += m[0].length;
|
|
139
|
+
else
|
|
140
|
+
text += src[i++];
|
|
141
|
+
}
|
|
142
|
+
flush();
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)[^`]*$/;
|
|
146
|
+
const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
147
|
+
const QUOTE = /^ {0,3}>\s?(.*)$/;
|
|
148
|
+
const HR = /^ {0,3}([-*_])(?: *\1){2,} *$/;
|
|
149
|
+
const ITEM = /^( *)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
|
150
|
+
const TASK = /^\[([ xX])\](?:\s+(.*))?$/;
|
|
151
|
+
const DELIM_CELL = /^(:?)-+(:?)$/;
|
|
152
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
153
|
+
const startsBlock = (l) => FENCE.test(l) || HEADING.test(l) || QUOTE.test(l) || HR.test(l) || ITEM.test(l);
|
|
154
|
+
/** Splits a table row into trimmed cells. The outer pipes are optional and `\|` is a literal pipe. */
|
|
155
|
+
function splitRow(line) {
|
|
156
|
+
const row = line.trim();
|
|
157
|
+
const cells = [];
|
|
158
|
+
let cur = '';
|
|
159
|
+
for (let k = 0; k < row.length; k++) {
|
|
160
|
+
const c = row[k];
|
|
161
|
+
if (c === '\\' && k + 1 < row.length) {
|
|
162
|
+
cur += row[k + 1] === '|' ? '|' : c + row[k + 1];
|
|
163
|
+
k++;
|
|
164
|
+
}
|
|
165
|
+
else if (c === '|') {
|
|
166
|
+
cells.push(cur);
|
|
167
|
+
cur = '';
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
cur += c;
|
|
171
|
+
}
|
|
172
|
+
cells.push(cur);
|
|
173
|
+
// a leading pipe leaves an empty first cell; a trailing one leaves an empty last cell
|
|
174
|
+
if (row.startsWith('|'))
|
|
175
|
+
cells.shift();
|
|
176
|
+
if (row.endsWith('|') && !row.endsWith('\\|'))
|
|
177
|
+
cells.pop();
|
|
178
|
+
return cells.map((c) => c.trim());
|
|
179
|
+
}
|
|
180
|
+
/** The column alignments when `line` is a table delimiter row (`| :--- | :---: | ---: |`), otherwise null. */
|
|
181
|
+
function delimiter(line) {
|
|
182
|
+
if (!line.includes('|') || !/^[\s|:-]+$/.test(line))
|
|
183
|
+
return null;
|
|
184
|
+
const align = [];
|
|
185
|
+
for (const cell of splitRow(line)) {
|
|
186
|
+
const m = DELIM_CELL.exec(cell);
|
|
187
|
+
if (!m)
|
|
188
|
+
return null;
|
|
189
|
+
align.push(m[1] && m[2] ? 'center' : m[2] ? 'right' : m[1] ? 'left' : null);
|
|
190
|
+
}
|
|
191
|
+
return align;
|
|
192
|
+
}
|
|
193
|
+
/** Parses markdown text into block nodes. */
|
|
194
|
+
export function parseMarkdown(text) {
|
|
195
|
+
return blocks(text.replace(/\r\n?/g, '\n').split('\n'));
|
|
196
|
+
}
|
|
197
|
+
function blocks(lines) {
|
|
198
|
+
const out = [];
|
|
199
|
+
let i = 0;
|
|
200
|
+
while (i < lines.length) {
|
|
201
|
+
const line = lines[i];
|
|
202
|
+
if (!line.trim()) {
|
|
203
|
+
i++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const fence = FENCE.exec(line);
|
|
207
|
+
if (fence) {
|
|
208
|
+
const [, marker, info] = fence;
|
|
209
|
+
const body = [];
|
|
210
|
+
i++;
|
|
211
|
+
while (i < lines.length && !(lines[i].trim().startsWith(marker) && /^[`~]+$/.test(lines[i].trim())))
|
|
212
|
+
body.push(lines[i++]);
|
|
213
|
+
i++; // the closing fence (an unterminated block runs to the end)
|
|
214
|
+
out.push({ type: 'code', lang: info.replace(/[^\w+#.-]/g, ''), text: body.join('\n') });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const heading = HEADING.exec(line);
|
|
218
|
+
if (heading) {
|
|
219
|
+
out.push({ type: 'heading', level: heading[1].length, children: parseInline(heading[2]) });
|
|
220
|
+
i++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (HR.test(line)) {
|
|
224
|
+
out.push({ type: 'hr' });
|
|
225
|
+
i++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (QUOTE.test(line)) {
|
|
229
|
+
const inner = [];
|
|
230
|
+
while (i < lines.length && QUOTE.test(lines[i]))
|
|
231
|
+
inner.push(QUOTE.exec(lines[i++])[1]);
|
|
232
|
+
out.push({ type: 'blockquote', children: blocks(inner) });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const item = ITEM.exec(line);
|
|
236
|
+
if (item) {
|
|
237
|
+
const base = item[1].length;
|
|
238
|
+
const ordered = /\d/.test(item[2]);
|
|
239
|
+
const raw = [];
|
|
240
|
+
while (i < lines.length) {
|
|
241
|
+
const m = ITEM.exec(lines[i]);
|
|
242
|
+
if (m && m[1].length === base && /\d/.test(m[2]) === ordered) {
|
|
243
|
+
raw.push({ first: m[3], rest: [] });
|
|
244
|
+
i++;
|
|
245
|
+
}
|
|
246
|
+
else if (lines[i].trim() && indentOf(lines[i]) > base) {
|
|
247
|
+
raw[raw.length - 1].rest.push(lines[i++]); // a nested list or continuation line
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
out.push({
|
|
253
|
+
type: 'list',
|
|
254
|
+
ordered,
|
|
255
|
+
start: ordered ? parseInt(item[2], 10) : 1,
|
|
256
|
+
items: raw.map((r) => {
|
|
257
|
+
const task = TASK.exec(r.first);
|
|
258
|
+
const node = { type: 'listItem', children: parseInline(task ? (task[2] ?? '') : r.first), blocks: r.rest.length ? blocks(dedent(r.rest)) : [] };
|
|
259
|
+
if (task)
|
|
260
|
+
node.checked = task[1] !== ' ';
|
|
261
|
+
return node;
|
|
262
|
+
})
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// a table is a header row and a delimiter row with as many cells, then rows until a blank line or another block
|
|
267
|
+
const align = line.includes('|') && i + 1 < lines.length ? delimiter(lines[i + 1]) : null;
|
|
268
|
+
const head = align ? splitRow(line) : [];
|
|
269
|
+
if (align && head.length === align.length) {
|
|
270
|
+
const rows = [];
|
|
271
|
+
i += 2;
|
|
272
|
+
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i])) {
|
|
273
|
+
const cells = splitRow(lines[i++]);
|
|
274
|
+
rows.push(align.map((_, k) => parseInline(cells[k] ?? ''))); // pad short rows, drop extra cells
|
|
275
|
+
}
|
|
276
|
+
out.push({ type: 'table', align, head: head.map((c) => parseInline(c)), rows });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// a paragraph runs until a blank line or the start of another block; each line break is kept
|
|
280
|
+
const para = [line];
|
|
281
|
+
i++;
|
|
282
|
+
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i]))
|
|
283
|
+
para.push(lines[i++]);
|
|
284
|
+
const children = [];
|
|
285
|
+
para.forEach((l, k) => {
|
|
286
|
+
if (k)
|
|
287
|
+
children.push({ type: 'br' });
|
|
288
|
+
children.push(...parseInline(l.trim()));
|
|
289
|
+
});
|
|
290
|
+
out.push({ type: 'paragraph', children });
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
function dedent(lines) {
|
|
295
|
+
const min = Math.min(...lines.map(indentOf));
|
|
296
|
+
return lines.map((l) => l.slice(min));
|
|
297
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@ruckc/truss-markdown",
|
|
3
|
+
"version": "1.12.0",
|
|
4
|
+
"description": "A safe Markdown renderer for Truss UI: a typed AST rendered with Svelte components, never HTML strings",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"package": "svelte-package -i src/lib -o dist"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"**/*.css"
|
|
15
|
+
],
|
|
16
|
+
"svelte": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"svelte": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"svelte": "^5.20.0",
|
|
26
|
+
"@ruckc/truss-ui": "^1.0.0"
|
|
27
|
+
},
|
|
28
|
+
"author": "Curtis Ruck",
|
|
29
|
+
"homepage": "https://truss.ruck.io",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/ruckc/truss-ui/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/ruckc/truss-ui.git",
|
|
39
|
+
"directory": "packages/markdown"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"svelte",
|
|
43
|
+
"sveltekit",
|
|
44
|
+
"truss",
|
|
45
|
+
"markdown",
|
|
46
|
+
"chat"
|
|
47
|
+
]
|
|
48
|
+
}
|
package/README.md
DELETED