@brett_lamy/docstream 0.5.6 → 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/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
5
8
  "type": "module",
6
9
  "scripts": {
7
10
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -71,9 +74,8 @@
71
74
  "dependencies": {
72
75
  "@brett_lamy/viz-engine": ">=0.2.0",
73
76
  "framer-motion": "^13.1.1",
74
- "hast-util-to-html": "^9.0.5",
75
77
  "katex": "^0.18.5",
76
- "lowlight": "^3.3.0",
78
+ "gpu-lexer": "^0.0.2",
77
79
  "lucide-react": "^1.17.0",
78
80
  "mermaid": "^11.15.0",
79
81
  "rrweb": "2.0.0-alpha.20",
@@ -1,71 +1,58 @@
1
- import { useMemo } from "react"
2
- import { common, createLowlight } from "lowlight"
3
- import { toHtml } from "hast-util-to-html"
1
+ import { useEffect, useMemo, useState } from "react"
2
+ import { parse } from "gpu-lexer"
4
3
 
5
- const lowlight = createLowlight(common)
4
+ type Span = {
5
+ type: "plain" | "comment" | "string" | "number" | "keyword" | "type" | "function" | "constant" | "operator"
6
+ start: number
7
+ end: number
8
+ }
6
9
 
7
- const LANGUAGE_ALIASES: Record<string, string> = {
8
- js: "javascript",
9
- jsx: "javascript",
10
- sh: "bash",
11
- shell: "bash",
12
- ts: "typescript",
13
- tsx: "typescript",
14
- yml: "yaml",
10
+ const COLORS: Record<Span["type"], string | undefined> = {
11
+ plain: undefined, comment: "#6B6B78", string: "#A5D6A7", number: "#F78C6C",
12
+ keyword: "#C792EA", type: "#FFCB6B", function: "#82AAFF", constant: "#F07178", operator: "#89DDFF",
15
13
  }
16
14
 
17
- function normalizeLanguage(language: string | null) {
18
- if (!language) return null
19
- const normalized = language.toLowerCase()
20
- return LANGUAGE_ALIASES[normalized] ?? normalized
15
+ function escapeHtml(value: string) {
16
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;")
21
17
  }
22
18
 
23
- function highlightedHtml(code: string, language: string | null) {
24
- const normalized = normalizeLanguage(language)
25
- if (normalized && lowlight.registered(normalized)) {
26
- try {
27
- return toHtml(lowlight.highlight(normalized, code))
28
- } catch {
29
- /* fall through to plain */
30
- }
31
- }
32
- return null
19
+ function spansToHtml(code: string, spans: Span[]) {
20
+ let cursor = 0
21
+ return spans.map((span) => {
22
+ const prefix = escapeHtml(code.slice(cursor, span.start))
23
+ const text = escapeHtml(code.slice(span.start, span.end))
24
+ cursor = span.end
25
+ const color = COLORS[span.type]
26
+ return `${prefix}${color ? `<span style="color:${color}">${text}</span>` : text}`
27
+ }).join("") + escapeHtml(code.slice(cursor))
33
28
  }
34
29
 
35
- export function HighlightedCode({
36
- code,
37
- language,
38
- lineNumbers = false,
39
- }: {
30
+ export function HighlightedCode({ code, language: _language, lineNumbers = false }: {
40
31
  code: string
41
32
  language: string | null
42
33
  lineNumbers?: boolean
43
34
  }) {
44
35
  const lines = useMemo(() => code.split("\n"), [code])
45
- const html = useMemo(() => {
46
- if (lineNumbers) {
47
- return lines.map((line) => highlightedHtml(line || " ", language))
48
- }
49
- return highlightedHtml(code, language)
50
- }, [code, language, lineNumbers, lines])
36
+ const [spans, setSpans] = useState<Span[] | null>(null)
37
+
38
+ useEffect(() => {
39
+ let cancelled = false
40
+ setSpans(null)
41
+ parse(code).then((result) => { if (!cancelled) setSpans(result) }).catch(() => { if (!cancelled) setSpans([]) })
42
+ return () => { cancelled = true }
43
+ }, [code])
51
44
 
52
- if (lineNumbers) {
53
- return (
54
- <code className="hljs">
55
- {(html as Array<string | null>).map((lineHtml, index) => (
56
- <span key={index} className="docs-code-line">
57
- <span className="docs-code-lineno">{index + 1}</span>
58
- {lineHtml === null ? (
59
- <span>{lines[index]}</span>
60
- ) : (
61
- <span dangerouslySetInnerHTML={{ __html: lineHtml }} />
62
- )}
63
- </span>
64
- ))}
65
- </code>
66
- )
67
- }
45
+ const html = useMemo(() => spans && spans.length ? spansToHtml(code, spans) : null, [code, spans])
46
+ if (!lineNumbers) return html === null ? <code>{code}</code> : <code dangerouslySetInnerHTML={{ __html: html }} />
68
47
 
69
- if (html === null) return <code>{code}</code>
70
- return <code className="hljs" dangerouslySetInnerHTML={{ __html: html as string }} />
48
+ return <code>{lines.map((line, index) => {
49
+ const start = lines.slice(0, index).reduce((total, value) => total + value.length + 1, 0)
50
+ const lineSpans = spans?.filter((span) => span.start >= start && span.end <= start + line.length)
51
+ .map((span) => ({ ...span, start: span.start - start, end: span.end - start }))
52
+ const lineHtml = lineSpans?.length ? spansToHtml(line, lineSpans) : null
53
+ return <span key={index} className="docs-code-line">
54
+ <span className="docs-code-lineno">{index + 1}</span>
55
+ {lineHtml === null ? <span>{line}</span> : <span dangerouslySetInnerHTML={{ __html: lineHtml }} />}
56
+ </span>
57
+ })}</code>
71
58
  }
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useId, useState, type ReactNode } from "react"
2
- import { ArrowUpRight, AtSign, Globe, Hash } from "lucide-react"
2
+ import { ArrowUpRight, AtSign, FolderGit2, Globe, Hash } from "lucide-react"
3
3
 
4
4
  import type { CitationDef, ReferenceNode } from "../gitbook/ast"
5
5
 
@@ -112,7 +112,7 @@ export function InlineReference({ node }: { node: ReferenceNode }) {
112
112
  )
113
113
  }
114
114
 
115
- const Icon = node.kind === "mention" ? AtSign : Hash
115
+ const Icon = node.kind === "mention" ? AtSign : node.kind === "codebase" ? FolderGit2 : Hash
116
116
  return (
117
117
  <button
118
118
  type="button"
@@ -23,7 +23,7 @@ export interface InlineImageNode {
23
23
  link?: string
24
24
  }
25
25
 
26
- export type ReferenceKind = "mention" | "tag" | "citation"
26
+ export type ReferenceKind = "mention" | "tag" | "codebase" | "citation"
27
27
 
28
28
  /**
29
29
  * Inline reference chip: `@mention`, `#tag`, or footnote-style citation `[^id]`.
@@ -10,8 +10,13 @@ export const refDefinitions = new Map<string, string>()
10
10
  // parseMarkdown and consumed here to resolve [^id] markers.
11
11
  export const footnoteDefinitions = new Map<string, { url: string; label?: string }>()
12
12
 
13
- // @mention / #tag body: letter/underscore start, then word chars, dots, dashes.
13
+ // @mention / #tag / $codebase body: letter/underscore start, then word chars, dots,
14
+ // dashes. Codebase ids may also carry `/` (`$org/repo`). A letter start keeps prices
15
+ // like $5 plain text.
14
16
  const REFERENCE_BODY_RE = /^[@#]([A-Za-z_][\w.-]*)/
17
+ const CODEBASE_BODY_RE = /^\$([A-Za-z_][\w.\/-]*)/
18
+ const SIGIL_KIND = { "@": "mention", "#": "tag", $: "codebase" } as const
19
+ const KIND_SIGIL = { mention: "@", tag: "#", codebase: "$" } as const
15
20
  // Chips are only recognized at start-of-input or after whitespace/open brackets,
16
21
  // so brett@replay.io and C# stay plain text.
17
22
  const isReferenceBoundary = (prev: string | undefined) =>
@@ -131,14 +136,14 @@ export function parseInline(src: string, marks: Marks = {}): Inline[] {
131
136
  continue
132
137
  }
133
138
 
134
- // @mention / #tag chips, only at a word boundary
135
- if ((rest[0] === "@" || rest[0] === "#") && isReferenceBoundary(src[i - 1])) {
136
- const m = rest.match(REFERENCE_BODY_RE)
139
+ // @mention / #tag / $codebase chips, only at a word boundary
140
+ if ((rest[0] === "@" || rest[0] === "#" || rest[0] === "$") && isReferenceBoundary(src[i - 1])) {
141
+ const m = rest.match(rest[0] === "$" ? CODEBASE_BODY_RE : REFERENCE_BODY_RE)
137
142
  if (m) {
138
- const id = m[1].replace(/[.-]+$/, "")
143
+ const id = m[1].replace(/[./-]+$/, "")
139
144
  if (id) {
140
145
  flush()
141
- out.push({ type: "reference", kind: rest[0] === "@" ? "mention" : "tag", id })
146
+ out.push({ type: "reference", kind: SIGIL_KIND[rest[0] as "@" | "#" | "$"], id })
142
147
  i += 1 + id.length
143
148
  continue
144
149
  }
@@ -200,14 +205,14 @@ export function parseInline(src: string, marks: Marks = {}): Inline[] {
200
205
  const escapeText = (t: string) =>
201
206
  t
202
207
  .replace(/([*_~`[\]\\])/g, "\\$1")
203
- // Escape @/# only at a chip boundary so emails/C# survive untouched.
208
+ // Escape @/#/$ only at a chip boundary so emails/C#/$5 survive untouched.
204
209
  // Note: node-local — a boundary formed across adjacent inline nodes
205
210
  // (previous node ending in whitespace) is not caught; rare, accepted.
206
- .replace(/(^|[\s([{])([@#])(?=[A-Za-z_])/g, "$1\\$2")
211
+ .replace(/(^|[\s([{])([@#$])(?=[A-Za-z_])/g, "$1\\$2")
207
212
 
208
213
  export function serializeReference(n: ReferenceNode): string {
209
214
  if (n.kind === "citation") return `[^${n.id}]`
210
- return `${n.kind === "mention" ? "@" : "#"}${n.id}`
215
+ return `${KIND_SIGIL[n.kind]}${n.id}`
211
216
  }
212
217
 
213
218
  function serializeImage(n: InlineImageNode): string {
package/src/styles.css CHANGED
@@ -1073,6 +1073,16 @@
1073
1073
  color: var(--gb-accent-foreground);
1074
1074
  }
1075
1075
 
1076
+ .docs-ref-codebase {
1077
+ border: 1px solid rgba(34, 197, 94, 0.45);
1078
+ background: rgba(34, 197, 94, 0.1);
1079
+ color: rgb(74, 222, 128);
1080
+ }
1081
+
1082
+ .docs-ref-codebase:hover {
1083
+ background: rgba(34, 197, 94, 0.18);
1084
+ }
1085
+
1076
1086
  .docs-cite-wrap {
1077
1087
  position: relative;
1078
1088
  display: inline-block;