@brett_lamy/docstream-editor 0.5.8 → 0.6.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/README.md CHANGED
@@ -10,7 +10,7 @@ TipTap editor components for Docstream GitBook-style markdown documents.
10
10
  - TipTap extensions for common writing flows.
11
11
  - Conversion helpers between the Docstream AST and TipTap JSON.
12
12
  - Slash menu for inserting supported blocks.
13
- - Code block support through `lowlight`.
13
+ - Code block syntax highlighting through [`gpu-lexer`](https://gpu-lexer.vercel.app/) (language-agnostic, WebGPU; plain text where WebGPU is unavailable).
14
14
  - Table, task list, heading, quote, and inline formatting support.
15
15
  - Preserves Docstream/GitBook block semantics when converting back to markdown.
16
16
  - Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -34,12 +34,12 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@brett_lamy/docstream": "0.5.7",
37
- "lowlight": "^3.3.0",
37
+ "gpu-lexer": "0.0.2",
38
38
  "lucide-react": "^1.17.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@tiptap/core": "^3.26.1",
42
- "@tiptap/extension-code-block-lowlight": "^3.26.1",
42
+ "@tiptap/extension-code-block": "^3.26.1",
43
43
  "@tiptap/extension-list": "^3.26.1",
44
44
  "@tiptap/extension-placeholder": "^3.26.1",
45
45
  "@tiptap/extension-table": "^3.26.1",
@@ -56,7 +56,7 @@
56
56
  "devDependencies": {
57
57
  "@agent-wasm/core": "^0.4.0",
58
58
  "@tiptap/core": "^3.26.1",
59
- "@tiptap/extension-code-block-lowlight": "^3.26.1",
59
+ "@tiptap/extension-code-block": "^3.26.1",
60
60
  "@tiptap/extension-list": "^3.26.1",
61
61
  "@tiptap/extension-placeholder": "^3.26.1",
62
62
  "@tiptap/extension-table": "^3.26.1",
@@ -0,0 +1,139 @@
1
+ import type { Node as PmNode } from "@tiptap/pm/model"
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state"
3
+ import { Decoration, DecorationSet } from "@tiptap/pm/view"
4
+
5
+ /**
6
+ * Code-block syntax highlighting through gpu-lexer — the same language-agnostic WebGPU
7
+ * lexer docstream's renderer uses, so edit and preview agree. gpu-lexer is async, so
8
+ * spans are cached by block text: decorations are built synchronously from the cache,
9
+ * missing blocks are tokenized in the background, and a meta transaction re-decorates
10
+ * when results land. Without WebGPU `parse` rejects and blocks stay plain.
11
+ */
12
+
13
+ type SpanType =
14
+ | "plain"
15
+ | "comment"
16
+ | "string"
17
+ | "number"
18
+ | "keyword"
19
+ | "type"
20
+ | "function"
21
+ | "constant"
22
+ | "operator"
23
+
24
+ interface Span {
25
+ type: SpanType
26
+ start: number
27
+ end: number
28
+ }
29
+
30
+ const MAX_CODE_UNITS = 128 * 1024
31
+ const CACHE_LIMIT = 256
32
+
33
+ let lexer: Promise<(code: string) => Promise<Span[]>> | undefined
34
+ const cache = new Map<string, Span[] | null>()
35
+ const pending = new Map<string, Promise<void>>()
36
+
37
+ function remember(code: string, spans: Span[] | null) {
38
+ cache.set(code, spans)
39
+ if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value as string)
40
+ }
41
+
42
+ function tokenize(code: string): Promise<void> {
43
+ const inflight = pending.get(code)
44
+ if (inflight) return inflight
45
+ // Loaded on first code block so the model stays out of the editor's initial bundle.
46
+ lexer ??= import("gpu-lexer").then((module) => module.parse as (code: string) => Promise<Span[]>)
47
+ const run = lexer
48
+ .then((parse) => parse(code))
49
+ .then(
50
+ (spans) => remember(code, spans),
51
+ () => remember(code, null),
52
+ )
53
+ .finally(() => pending.delete(code))
54
+ pending.set(code, run)
55
+ return run
56
+ }
57
+
58
+ function highlightable(code: string) {
59
+ return code.length > 0 && code.length <= MAX_CODE_UNITS
60
+ }
61
+
62
+ /**
63
+ * Decorations from the span cache. A block whose new text is not tokenized yet keeps its
64
+ * previous decorations mapped through the edit, so typing never flickers to plain.
65
+ */
66
+ function decorationsFor(doc: PmNode, nodeName: string, previous?: DecorationSet): DecorationSet {
67
+ const decorations: Decoration[] = []
68
+ doc.descendants((node, pos) => {
69
+ if (node.type.name !== nodeName) return true
70
+ const from = pos + 1
71
+ const spans = cache.get(node.textContent)
72
+ if (!spans) {
73
+ if (previous) decorations.push(...previous.find(from, from + node.content.size))
74
+ return false
75
+ }
76
+ for (const span of spans) {
77
+ if (span.type === "plain" || span.end <= span.start) continue
78
+ decorations.push(
79
+ Decoration.inline(from + span.start, from + span.end, { class: `gb-syntax-${span.type}` }),
80
+ )
81
+ }
82
+ return false
83
+ })
84
+ return DecorationSet.create(doc, decorations)
85
+ }
86
+
87
+ function missingBlocks(doc: PmNode, nodeName: string): string[] {
88
+ const missing = new Set<string>()
89
+ doc.descendants((node) => {
90
+ if (node.type.name !== nodeName) return true
91
+ const code = node.textContent
92
+ if (highlightable(code) && !cache.has(code)) missing.add(code)
93
+ return false
94
+ })
95
+ return [...missing]
96
+ }
97
+
98
+ export function gpuHighlightPlugin(nodeName: string): Plugin {
99
+ const key = new PluginKey<DecorationSet>("gpuHighlight")
100
+ return new Plugin<DecorationSet>({
101
+ key,
102
+ state: {
103
+ init: (_, state) => decorationsFor(state.doc, nodeName),
104
+ apply: (tr, previous) =>
105
+ tr.docChanged || tr.getMeta(key)
106
+ ? decorationsFor(tr.doc, nodeName, previous.map(tr.mapping, tr.doc))
107
+ : previous,
108
+ },
109
+ props: {
110
+ decorations(state) {
111
+ return key.getState(state)
112
+ },
113
+ },
114
+ view(view) {
115
+ let destroyed = false
116
+ let timer: ReturnType<typeof setTimeout> | undefined
117
+ const request = () => {
118
+ for (const code of missingBlocks(view.state.doc, nodeName)) {
119
+ void tokenize(code).then(() => {
120
+ if (!destroyed) view.dispatch(view.state.tr.setMeta(key, true))
121
+ })
122
+ }
123
+ }
124
+ request()
125
+ return {
126
+ update: (_view, previous) => {
127
+ if (previous.doc.eq(view.state.doc)) return
128
+ // One tokenize per typing pause, not per keystroke.
129
+ clearTimeout(timer)
130
+ timer = setTimeout(request, 150)
131
+ },
132
+ destroy: () => {
133
+ destroyed = true
134
+ clearTimeout(timer)
135
+ },
136
+ }
137
+ },
138
+ })
139
+ }
@@ -1,6 +1,6 @@
1
1
  import { Node, mergeAttributes } from "@tiptap/core"
2
- import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"
3
- import { common, createLowlight } from "lowlight"
2
+ import { CodeBlock } from "@tiptap/extension-code-block"
3
+ import { gpuHighlightPlugin } from "./gpu-highlight"
4
4
  import { useEffect, useMemo, useState } from "react"
5
5
  import {
6
6
  NodeViewContent,
@@ -718,7 +718,7 @@ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
718
718
  )
719
719
  }
720
720
 
721
- export const GbCodeBlock = CodeBlockLowlight.extend({
721
+ export const GbCodeBlock = CodeBlock.extend({
722
722
  addAttributes() {
723
723
  return {
724
724
  ...this.parent?.(),
@@ -731,7 +731,10 @@ export const GbCodeBlock = CodeBlockLowlight.extend({
731
731
  addNodeView() {
732
732
  return ReactNodeViewRenderer(CodeView)
733
733
  },
734
- }).configure({ lowlight: createLowlight(common) })
734
+ addProseMirrorPlugins() {
735
+ return [...(this.parent?.() ?? []), gpuHighlightPlugin(this.name)]
736
+ },
737
+ })
735
738
 
736
739
  // ---------- Inline image (GitHub badge style) ----------
737
740
 
package/src/styles.css CHANGED
@@ -582,31 +582,15 @@
582
582
  color: var(--gb-code-fg);
583
583
  }
584
584
  .gb-code > pre code { background: none; padding: 0; font-size: inherit; color: inherit; }
585
- .gb-code > pre code .hljs-comment { font-style: italic; }
586
- .gb-code .hljs-keyword,
587
- .gb-code .hljs-selector-tag,
588
- .gb-code .hljs-built_in,
589
- .gb-code .hljs-type,
590
- .gb-code .hljs-tag,
591
- .gb-code .hljs-name { color: #c792ea; }
592
- .gb-code .hljs-string,
593
- .gb-code .hljs-attr,
594
- .gb-code .hljs-symbol,
595
- .gb-code .hljs-bullet { color: #a5d6a7; }
596
- .gb-code .hljs-title,
597
- .gb-code .hljs-section,
598
- .gb-code .hljs-function .hljs-title { color: #82aaff; }
599
- .gb-code .hljs-number,
600
- .gb-code .hljs-literal,
601
- .gb-code .hljs-variable,
602
- .gb-code .hljs-template-variable { color: #f78c6c; }
603
- .gb-code .hljs-comment,
604
- .gb-code .hljs-quote,
605
- .gb-code .hljs-meta { color: #777784; }
606
- .gb-code .hljs-operator,
607
- .gb-code .hljs-punctuation { color: #89ddff; }
608
- .gb-code .hljs-deletion { color: #ff6b6b; }
609
- .gb-code .hljs-addition { color: #69db7c; }
585
+ /* gpu-lexer span classes (palette shared with docstream's HighlightedCode). */
586
+ .gb-code .gb-syntax-comment { color: #6b6b78; font-style: italic; }
587
+ .gb-code .gb-syntax-string { color: #a5d6a7; }
588
+ .gb-code .gb-syntax-number { color: #f78c6c; }
589
+ .gb-code .gb-syntax-keyword { color: #c792ea; }
590
+ .gb-code .gb-syntax-type { color: #ffcb6b; }
591
+ .gb-code .gb-syntax-function { color: #82aaff; }
592
+ .gb-code .gb-syntax-constant { color: #f07178; }
593
+ .gb-code .gb-syntax-operator { color: #89ddff; }
610
594
 
611
595
  /* ── Updates / changelog (mirrors .docs-updates) ────────── */
612
596
  .gb-updates { margin: 0.85em 0; border-top: 1px solid var(--gb-border); }