@brett_lamy/docstream 1.8.0 → 1.9.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.
Files changed (64) hide show
  1. package/README.md +18 -0
  2. package/dist/demo/DemoViewer.d.ts.map +1 -1
  3. package/dist/demo/DemoViewer.js +2 -2
  4. package/dist/demo/DemoViewer.js.map +1 -1
  5. package/dist/docs/DocsRenderer.d.ts +9 -2
  6. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  7. package/dist/docs/DocsRenderer.js +13 -6
  8. package/dist/docs/DocsRenderer.js.map +1 -1
  9. package/dist/docs/DocumentProperties.d.ts +11 -0
  10. package/dist/docs/DocumentProperties.d.ts.map +1 -0
  11. package/dist/docs/DocumentProperties.js +63 -0
  12. package/dist/docs/DocumentProperties.js.map +1 -0
  13. package/dist/docs/TaskCheckbox.d.ts +24 -0
  14. package/dist/docs/TaskCheckbox.d.ts.map +1 -0
  15. package/dist/docs/TaskCheckbox.js +103 -0
  16. package/dist/docs/TaskCheckbox.js.map +1 -0
  17. package/dist/docs/properties.css +28 -0
  18. package/dist/docs/tasks.css +13 -0
  19. package/dist/gitbook/ast.d.ts +14 -0
  20. package/dist/gitbook/ast.d.ts.map +1 -1
  21. package/dist/gitbook/ast.js.map +1 -1
  22. package/dist/gitbook/index.d.ts +3 -0
  23. package/dist/gitbook/index.d.ts.map +1 -1
  24. package/dist/gitbook/index.js +2 -0
  25. package/dist/gitbook/index.js.map +1 -1
  26. package/dist/gitbook/parse.d.ts.map +1 -1
  27. package/dist/gitbook/parse.js +17 -3
  28. package/dist/gitbook/parse.js.map +1 -1
  29. package/dist/gitbook/properties.d.ts +15 -0
  30. package/dist/gitbook/properties.d.ts.map +1 -0
  31. package/dist/gitbook/properties.js +45 -0
  32. package/dist/gitbook/properties.js.map +1 -0
  33. package/dist/gitbook/serialize.d.ts.map +1 -1
  34. package/dist/gitbook/serialize.js +5 -2
  35. package/dist/gitbook/serialize.js.map +1 -1
  36. package/dist/gitbook/tasks.d.ts +11 -0
  37. package/dist/gitbook/tasks.d.ts.map +1 -0
  38. package/dist/gitbook/tasks.js +21 -0
  39. package/dist/gitbook/tasks.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/streamdown.d.ts +1 -1
  45. package/dist/streamdown.d.ts.map +1 -1
  46. package/dist/streamdown.js +2 -2
  47. package/dist/streamdown.js.map +1 -1
  48. package/dist/styles.css +2 -0
  49. package/package.json +15 -1
  50. package/src/demo/DemoViewer.tsx +4 -2
  51. package/src/docs/DocsRenderer.tsx +22 -5
  52. package/src/docs/DocumentProperties.tsx +84 -0
  53. package/src/docs/TaskCheckbox.tsx +111 -0
  54. package/src/docs/properties.css +28 -0
  55. package/src/docs/tasks.css +13 -0
  56. package/src/gitbook/ast.ts +16 -0
  57. package/src/gitbook/index.ts +3 -0
  58. package/src/gitbook/parse.ts +15 -3
  59. package/src/gitbook/properties.ts +49 -0
  60. package/src/gitbook/serialize.ts +5 -2
  61. package/src/gitbook/tasks.ts +25 -0
  62. package/src/index.ts +7 -0
  63. package/src/streamdown.tsx +4 -0
  64. package/src/styles.css +2 -0
@@ -0,0 +1,84 @@
1
+ import { useEffect, useState } from "react"
2
+ import { ChevronRight, Plus, X } from "lucide-react"
3
+ import { parseDocument, stringify } from "yaml"
4
+ import type { DocumentPropertiesNode } from "../gitbook/ast"
5
+ import { readDocumentProperties, removeDocumentProperty, setDocumentProperty, type DocumentPropertyValue } from "../gitbook/properties"
6
+
7
+ export interface DocumentPropertiesProps {
8
+ properties: DocumentPropertiesNode
9
+ /** Omit for read-only properties. Edits retain the YAML document's comments and formatting. */
10
+ onChange?: (properties: DocumentPropertiesNode) => void
11
+ defaultOpen?: boolean
12
+ className?: string
13
+ }
14
+
15
+ function textValue(value: DocumentPropertyValue): string {
16
+ return value === null ? "" : typeof value === "object" ? stringify(value).trimEnd() : String(value)
17
+ }
18
+
19
+ function PropertyField({ name, value, onChange }: { name: string; value: DocumentPropertyValue; onChange: (value: DocumentPropertyValue) => void }) {
20
+ const rendered = textValue(value)
21
+ const [draft, setDraft] = useState(rendered)
22
+ const [error, setError] = useState<string>()
23
+ useEffect(() => { setDraft(rendered); setError(undefined) }, [rendered])
24
+ if (typeof value === "boolean") return <input type="checkbox" aria-label={name} checked={value} onChange={(event) => onChange(event.currentTarget.checked)} />
25
+ const complex = value !== null && typeof value === "object"
26
+ const multiline = complex || (typeof value === "string" && value.includes("\n"))
27
+ const change = (text: string) => {
28
+ setDraft(text)
29
+ if (!complex && typeof value !== "number") onChange(text)
30
+ else if (typeof value === "number" && text !== "" && Number.isFinite(Number(text))) onChange(Number(text))
31
+ }
32
+ const commit = () => {
33
+ if (!complex) return
34
+ try {
35
+ const yaml = parseDocument(draft)
36
+ if (yaml.errors.length) throw yaml.errors[0]
37
+ const next = yaml.toJS({ maxAliasCount: 100 }) as DocumentPropertyValue
38
+ JSON.stringify(next)
39
+ setError(undefined)
40
+ onChange(next)
41
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
42
+ }
43
+ return <div className="docs-property-field">
44
+ {multiline ? <textarea aria-label={name} value={draft} rows={Math.min(6, draft.split("\n").length)} aria-invalid={!!error || undefined}
45
+ onChange={(event) => change(event.currentTarget.value)} onBlur={commit} />
46
+ : <input type={typeof value === "number" ? "number" : "text"} aria-label={name} value={draft} placeholder="Empty"
47
+ onChange={(event) => change(event.currentTarget.value)} />}
48
+ {error ? <span role="alert" className="docs-properties-error">{error}</span> : null}
49
+ </div>
50
+ }
51
+
52
+ /** The shared, accessible property sheet used by the renderer and the editor. */
53
+ export function DocumentProperties({ properties, onChange, defaultOpen = false, className }: DocumentPropertiesProps) {
54
+ const { values, error } = readDocumentProperties(properties)
55
+ const [adding, setAdding] = useState(false)
56
+ const [name, setName] = useState("")
57
+ const keys = Object.keys(values)
58
+ return <details className={["docs-document-properties", className].filter(Boolean).join(" ")} data-document-properties="" open={defaultOpen || undefined}>
59
+ <summary><ChevronRight size={13} aria-hidden="true" /><span>Properties</span><span className="docs-properties-count">{keys.length}</span></summary>
60
+ {error ? <div className="docs-properties-error" role="alert">
61
+ <p>{error}</p>
62
+ {onChange ? <textarea aria-label="Document properties YAML" value={properties.yaml} onChange={(event) => onChange({ ...properties, yaml: event.currentTarget.value })} />
63
+ : <pre>{properties.yaml}</pre>}
64
+ </div> : <>
65
+ <dl className="docs-properties-list">
66
+ {keys.map((key) => <div key={key} className="docs-property-row">
67
+ <dt>{key}</dt>
68
+ <dd>{onChange ? <PropertyField name={key} value={values[key]} onChange={(value) => onChange(setDocumentProperty(properties, key, value))} />
69
+ : <span className="docs-property-value">{values[key] === null ? "Empty" : typeof values[key] === "boolean" ? String(values[key]) : textValue(values[key])}</span>}</dd>
70
+ {onChange ? <button type="button" className="docs-property-remove" aria-label={`Remove ${key} property`} onClick={() => onChange(removeDocumentProperty(properties, key))}><X size={13} /></button> : null}
71
+ </div>)}
72
+ </dl>
73
+ {onChange ? adding ? <form className="docs-property-add" onSubmit={(event) => {
74
+ event.preventDefault()
75
+ const key = name.trim()
76
+ if (!key || Object.hasOwn(values, key)) return
77
+ onChange(setDocumentProperty(properties, key, "")); setName(""); setAdding(false)
78
+ }}><input aria-label="Property name" placeholder="Property name" autoFocus value={name} onChange={(event) => setName(event.currentTarget.value)} />
79
+ <button type="submit" disabled={!name.trim() || Object.hasOwn(values, name.trim())}>Add</button>
80
+ <button type="button" onClick={() => { setAdding(false); setName("") }}>Cancel</button></form>
81
+ : <button type="button" className="docs-property-add" onClick={() => setAdding(true)}><Plus size={13} />Add property</button> : null}
82
+ </>}
83
+ </details>
84
+ }
@@ -0,0 +1,111 @@
1
+ import { createContext, useEffect, useId, useRef, useState, type CSSProperties } from "react"
2
+ import { createPortal } from "react-dom"
3
+ import { Ban, Check, Circle, CircleCheck, CircleHelp, CircleMinus, CirclePause, CircleX, Clock3 } from "lucide-react"
4
+ import type { ListItemNode, TaskState } from "../gitbook/ast"
5
+ import { TASK_STATES } from "../gitbook/tasks"
6
+
7
+ /** The icons used by the checkbox, tooltip and state picker. */
8
+ export const TASK_STATE_ICONS = {
9
+ pending: Circle,
10
+ "in-progress": Clock3,
11
+ implemented: CircleHelp,
12
+ blocked: Ban,
13
+ parked: CirclePause,
14
+ refuted: CircleX,
15
+ verified: CircleCheck,
16
+ cancelled: CircleMinus,
17
+ } as const
18
+
19
+ export type TaskStateChangeHandler = (item: ListItemNode, state: TaskState) => void
20
+ export const TaskStateChangeContext = createContext<TaskStateChangeHandler | undefined>(undefined)
21
+
22
+ export interface TaskCheckboxProps {
23
+ state: TaskState
24
+ /** A normal click clears any marked state; clicking pending sets verified. */
25
+ onChange?: (state: TaskState) => void
26
+ label?: string
27
+ className?: string
28
+ }
29
+
30
+ const TOKENS = ["--foreground", "--muted-foreground", "--popover", "--popover-foreground", "--border", "--primary", "--accent", "--accent-foreground"]
31
+
32
+ /** An accessible multi-state task checkbox. Right-click or Shift+F10 opens its keyboard-navigable state menu. */
33
+ export function TaskCheckbox({ state, onChange, label = "Task", className }: TaskCheckboxProps) {
34
+ const input = useRef<HTMLInputElement>(null)
35
+ const menu = useRef<HTMLDivElement>(null)
36
+ const tipId = useId()
37
+ const [popup, setPopup] = useState<{ x: number; y: number; style: CSSProperties }>()
38
+ const [tip, setTip] = useState<{ x: number; y: number }>()
39
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
40
+ const meta = TASK_STATES.find((item) => item.id === state) ?? TASK_STATES[0]
41
+ const StateIcon = TASK_STATE_ICONS[meta.id]
42
+ const mixed = state !== "pending" && state !== "verified"
43
+ useEffect(() => { if (input.current) input.current.indeterminate = mixed }, [mixed, state])
44
+ useEffect(() => () => clearTimeout(hoverTimer.current), [])
45
+ const hideTip = () => { clearTimeout(hoverTimer.current); setTip(undefined) }
46
+ const showTip = () => {
47
+ clearTimeout(hoverTimer.current)
48
+ hoverTimer.current = setTimeout(() => {
49
+ const box = input.current?.getBoundingClientRect()
50
+ if (box) setTip({ x: Math.max(80, Math.min(window.innerWidth - 80, box.x + box.width / 2)), y: box.top > 36 ? box.top - 6 : box.bottom + 30 })
51
+ }, 400)
52
+ }
53
+ const open = (x?: number, y?: number) => {
54
+ if (!onChange || !input.current) return
55
+ hideTip()
56
+ const box = input.current.getBoundingClientRect()
57
+ const computed = getComputedStyle(input.current)
58
+ const style = Object.fromEntries(TOKENS.map((token) => [token, computed.getPropertyValue(token)])) as CSSProperties
59
+ style.fontFamily = computed.fontFamily
60
+ style.colorScheme = computed.colorScheme
61
+ setPopup({ x: Math.max(8, Math.min(window.innerWidth - 292, x ?? box.left)), y: Math.max(8, Math.min(window.innerHeight - 330, y ?? box.bottom + 4)), style })
62
+ }
63
+ const close = (restoreFocus = false) => { setPopup(undefined); if (restoreFocus) input.current?.focus({ preventScroll: true }) }
64
+ useEffect(() => {
65
+ if (!popup) return
66
+ menu.current?.querySelector<HTMLButtonElement>(`[data-state="${state}"]`)?.focus()
67
+ const outside = (event: PointerEvent) => { if (event.target instanceof Node && !menu.current?.contains(event.target)) close() }
68
+ const scroll = (event: Event) => { if (!(event.target instanceof Node && menu.current?.contains(event.target))) close() }
69
+ document.addEventListener("pointerdown", outside, true)
70
+ window.addEventListener("resize", scroll)
71
+ window.addEventListener("scroll", scroll, true)
72
+ return () => { document.removeEventListener("pointerdown", outside, true); window.removeEventListener("resize", scroll); window.removeEventListener("scroll", scroll, true) }
73
+ }, [popup])
74
+ return <span className={["docs-task-control", className].filter(Boolean).join(" ")} data-task-state={state}>
75
+ <input ref={input} type="checkbox" className="docs-task-checkbox" aria-label={`${label}: ${meta.label}`} aria-describedby={tip ? tipId : undefined}
76
+ aria-checked={mixed ? "mixed" : state === "verified"} aria-readonly={!onChange || undefined} aria-haspopup={onChange ? "menu" : undefined}
77
+ aria-expanded={onChange ? !!popup : undefined} checked={state === "verified"} readOnly={!onChange}
78
+ onClick={(event) => { if (!onChange) event.preventDefault() }}
79
+ onChange={() => { hideTip(); onChange?.(state === "pending" ? "verified" : "pending") }}
80
+ onMouseDown={(event) => { if (event.button === 0) event.preventDefault() }}
81
+ onMouseEnter={showTip} onMouseLeave={hideTip} onFocus={showTip} onBlur={hideTip}
82
+ onContextMenu={(event) => { if (onChange) { event.preventDefault(); open(event.clientX, event.clientY) } }}
83
+ onKeyDown={(event) => {
84
+ if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { event.preventDefault(); open() }
85
+ }} />
86
+ <StateIcon className="docs-task-state-icon" size={20} strokeWidth={1.7} aria-hidden="true" data-state-icon={state} />
87
+ {tip && !popup ? createPortal(<div role="tooltip" id={tipId} className="docs-task-tooltip" style={{ left: tip.x, top: tip.y }}>{meta.label}</div>, document.body) : null}
88
+ {popup ? createPortal(<div ref={menu} role="menu" aria-label="Task state" className="docs-task-state-menu" style={{ ...popup.style, left: popup.x, top: popup.y }}
89
+ onContextMenu={(event) => event.preventDefault()}
90
+ onKeyDown={(event) => {
91
+ const items = [...(menu.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])]
92
+ const current = items.indexOf(document.activeElement as HTMLButtonElement)
93
+ let next: number | undefined
94
+ if (event.key === "ArrowDown") next = (current + 1) % items.length
95
+ if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length
96
+ if (event.key === "Home") next = 0
97
+ if (event.key === "End") next = items.length - 1
98
+ if (event.key === "Escape" || event.key === "Tab") { event.preventDefault(); close(true) }
99
+ if (next !== undefined) { event.preventDefault(); items[next]?.focus() }
100
+ }}>
101
+ {TASK_STATES.map((item) => {
102
+ const Icon = TASK_STATE_ICONS[item.id]
103
+ return <button type="button" role="menuitemradio" key={item.id} data-state={item.id} aria-checked={item.id === state}
104
+ onClick={() => { onChange?.(item.id); close(true) }}>
105
+ <Icon size={19} strokeWidth={1.7} aria-hidden="true" data-state-icon={item.id} />
106
+ <span>{item.label}</span>{item.id === state ? <Check size={14} aria-hidden="true" /> : <span />}
107
+ </button>
108
+ })}
109
+ </div>, document.body) : null}
110
+ </span>
111
+ }
@@ -0,0 +1,28 @@
1
+ .docs-document-properties {
2
+ color: var(--muted-foreground, #787878);
3
+ font: inherit;
4
+ font-size: 13px;
5
+ line-height: 1.5;
6
+ margin-block: 0 20px;
7
+ }
8
+ .docs-document-properties > summary { display: flex; align-items: center; gap: 6px; width: fit-content; padding-block: 5px; cursor: pointer; list-style: none; user-select: none; }
9
+ .docs-document-properties > summary::-webkit-details-marker { display: none; }
10
+ .docs-document-properties[open] > summary > svg { transform: rotate(90deg); }
11
+ .docs-properties-count { font-variant-numeric: tabular-nums; opacity: .65; margin-left: 2px; }
12
+ .docs-properties-list { margin: 6px 0; }
13
+ .docs-property-row { display: grid; grid-template-columns: minmax(80px, 130px) minmax(0, 1fr) 22px; align-items: start; gap: 10px; padding-block: 6px; border-bottom: 1px solid var(--border, #e8e8e8); }
14
+ .docs-property-row dt { padding-block: 3px; overflow-wrap: anywhere; }
15
+ .docs-property-row dd { margin: 0; min-width: 0; color: var(--foreground, #242424); }
16
+ .docs-property-value { display: block; white-space: pre-wrap; overflow-wrap: anywhere; padding-block: 3px; }
17
+ .docs-document-properties :is(input:not([type="checkbox"]), textarea) { box-sizing: border-box; width: 100%; min-width: 0; padding: 3px 5px; color: var(--foreground, #242424); font: inherit; line-height: inherit; background: transparent; border: 0; border-radius: 4px; }
18
+ .docs-document-properties textarea { resize: vertical; }
19
+ .docs-document-properties input[type="checkbox"] { accent-color: var(--primary, #a87900); margin: 6px 0; }
20
+ .docs-document-properties :is(input, textarea, button, summary):focus-visible { outline: 2px solid var(--primary, #a87900); outline-offset: 2px; }
21
+ .docs-document-properties button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; color: inherit; background: transparent; border: 0; border-radius: 4px; font: inherit; cursor: pointer; }
22
+ .docs-property-remove { padding: 6px 3px; opacity: 0; }
23
+ .docs-property-row:hover .docs-property-remove, .docs-property-remove:focus-visible { opacity: 1; }
24
+ .docs-property-add { display: flex; align-items: center; gap: 8px; margin-block: 8px 0; padding: 3px 0; }
25
+ .docs-property-add input { max-width: 240px; }
26
+ .docs-property-add button { padding: 3px 6px; }
27
+ .docs-properties-error { color: var(--destructive, #bc3030); font-size: 12px; overflow-wrap: anywhere; }
28
+ .docs-properties-error pre { white-space: pre-wrap; }
@@ -0,0 +1,13 @@
1
+ .docs-task-control { position: relative; display: inline-flex; width: 21px; height: 21px; flex: 0 0 21px; vertical-align: text-bottom; color: var(--muted-foreground, #7a7a7a); }
2
+ .docs-task-control .docs-task-checkbox { position: absolute; inset: 0; box-sizing: border-box; appearance: none; margin: 0; width: 100%; height: 100%; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; box-shadow: none; }
3
+ .docs-task-checkbox:focus-visible { outline: 2px solid var(--primary, #a87900); outline-offset: 3px; }
4
+ .docs-task-state-icon { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
5
+ .docs-task-control[data-task-state="verified"], .docs-task-control[data-task-state="in-progress"], .docs-task-control[data-task-state="implemented"] { color: var(--primary, #a87900); }
6
+ .docs-task-control[data-task-state="blocked"], .docs-task-control[data-task-state="refuted"] { color: var(--destructive, #bd3d36); }
7
+ .docs-task-state-menu { position: fixed; z-index: 10000; box-sizing: border-box; width: 284px; max-height: calc(100vh - 16px); overflow: auto; padding: 5px; border: 1px solid var(--border, #dedede); border-radius: 10px; background: var(--popover, #fff); color: var(--popover-foreground, #222); box-shadow: 0 8px 32px #0003, 0 1px 3px #0001; font-size: 13px; line-height: 1.4; }
8
+ .docs-task-state-menu > button { display: grid; grid-template-columns: 20px minmax(0, 1fr) 14px; align-items: center; gap: 9px; width: 100%; min-height: 35px; padding: 7px 8px; border: 0; border-radius: 5px; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; outline: none; }
9
+ .docs-task-state-menu > button:hover, .docs-task-state-menu > button:focus { color: var(--accent-foreground, #222); background: var(--accent, #eee); }
10
+ .docs-task-state-menu > button > svg:first-child { color: var(--muted-foreground, #777); }
11
+ .docs-task-state-menu > button[aria-checked="true"] > svg { color: var(--primary, #a87900); }
12
+ .docs-task-tooltip { position: fixed; z-index: 10001; transform: translate(-50%, -100%); border-radius: 5px; padding: 4px 8px; color: #fff; background: #303030; box-shadow: 0 2px 8px #0002; font: 12px/1.4 system-ui, sans-serif; white-space: nowrap; pointer-events: none; }
13
+ .docs-tasklist li > .docs-task-control { margin-top: 2px; }
@@ -433,6 +433,8 @@ export interface ListItemNode extends ContainerSpacing {
433
433
  type: "listItem"
434
434
  children: Block[]
435
435
  checked?: boolean // present only in task lists
436
+ /** Extended task state. Omitted for ordinary `[ ]` / `[x]` items for backwards compatibility. */
437
+ state?: TaskState
436
438
  /** The item's marker as written (`*`, `3.`), when it isn't the one its list's pattern gives it. */
437
439
  marker?: string
438
440
  /** Spaces between the marker and the content (default 1). */
@@ -443,6 +445,8 @@ export interface ListItemNode extends ContainerSpacing {
443
445
  checkMark?: "X"
444
446
  }
445
447
 
448
+ export type TaskState = "pending" | "in-progress" | "implemented" | "blocked" | "parked" | "refuted" | "verified" | "cancelled"
449
+
446
450
  export interface ListNode extends BlockSpacing {
447
451
  type: "list"
448
452
  ordered: boolean
@@ -563,9 +567,21 @@ export type Block =
563
567
  | DefinitionNode
564
568
  | RawNode
565
569
 
570
+ /** YAML frontmatter, kept separately from the document body. Comments, key order and quoting survive edits. */
571
+ export interface DocumentPropertiesNode {
572
+ /** YAML between the delimiters, using the document's normalized line endings. */
573
+ yaml: string
574
+ /** Opening delimiter as written (including an optional BOM). Defaults to `---`. */
575
+ opening?: string
576
+ /** Closing delimiter as written (`---` or `...`). Defaults to `---`. */
577
+ closing?: string
578
+ }
579
+
566
580
  export interface DocumentNode {
567
581
  type: "doc"
568
582
  children: Block[]
583
+ /** First-party document properties from YAML frontmatter. */
584
+ properties?: DocumentPropertiesNode
569
585
  /** Footnote citation definitions, in definition order. */
570
586
  citations?: CitationDef[]
571
587
  /** Blank lines before the trailing footnote definitions (default 1). */
@@ -1,6 +1,9 @@
1
1
  // Pure GitBook markdown engine (no React, no mermaid/streamdown). Safe to import
2
2
  // in non-DOM environments such as a server-side AI agent or a Cloudflare Worker.
3
3
  export type * from "./ast"
4
+ export { TASK_STATES, getTaskState, taskMarker, taskStateFromMarker } from "./tasks"
5
+ export { readDocumentProperties, removeDocumentProperty, setDocumentProperty } from "./properties"
6
+ export type { DocumentPropertyValue, ParsedDocumentProperties } from "./properties"
4
7
  export { escapeAttr, unescapeAttr } from "./attrs"
5
8
  export { closesFence, demoBody, fenceOpener, fenceTracker, parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
6
9
  export { fenceFor, serializeBlocks, serializeDemoFile, serializeMarkdown } from "./serialize"
@@ -36,6 +36,7 @@ import type {
36
36
  import { TAG_ATTRS, parseAttrs } from "./attrs"
37
37
  import { containerTags, defaultDelimiterRow, definitionLine, delimiterAlign, fenceInfo, figureMarkdown, serializeBlocks, type TaggedNode } from "./serialize"
38
38
  import { footnoteDefinitions, normalizeLabel, parseInline, plainText, refDefinitions } from "./inline"
39
+ import { taskStateFromMarker } from "./tasks"
39
40
 
40
41
  // Minimal HTML-inline → markdown-inline bridge for HTML table cells.
41
42
  function htmlToInlineMd(html: string): string {
@@ -256,7 +257,15 @@ export function footnoteLine(id: string, url: string, label?: string): string {
256
257
  export function parseMarkdown(src: string): DocumentNode {
257
258
  // Windows line endings throughout come back as such; mixed ones stay in the lines as written.
258
259
  const crlf = src.includes("\r\n") && !/(^|[^\r])\n/.test(src)
259
- const lines = src.split(crlf || !src.includes("\r\n") ? /\r?\n/ : "\n")
260
+ let lines = src.split(crlf || !src.includes("\r\n") ? /\r?\n/ : "\n")
261
+ let properties: DocumentNode["properties"]
262
+ if (/^\uFEFF?---[ \t]*$/.test(lines[0])) {
263
+ const closing = lines.findIndex((line, index) => index > 0 && /^(?:---|\.\.\.)[ \t]*$/.test(line))
264
+ if (closing > 0) {
265
+ properties = { yaml: lines.slice(1, closing).join("\n"), opening: lines[0], closing: lines[closing] }
266
+ lines = lines.slice(closing + 1)
267
+ }
268
+ }
260
269
  refDefinitions.clear()
261
270
  footnoteDefinitions.clear()
262
271
  const fences = fenceTracker()
@@ -289,6 +298,7 @@ export function parseMarkdown(src: string): DocumentNode {
289
298
  const trailing = group <= last
290
299
  const { blocks, end } = parseBody(trailing ? lines.slice(0, group) : lines)
291
300
  const doc: DocumentNode = { type: "doc", children: blocks }
301
+ if (properties) doc.properties = properties
292
302
  if (footnoteDefinitions.size) {
293
303
  doc.citations = [...footnoteDefinitions.entries()].map(([id, d]) => ({ id, ...d }))
294
304
  }
@@ -1117,10 +1127,10 @@ function parseList(lines: string[], start: number): ParsedList {
1117
1127
  let content = m[4]
1118
1128
  let checked: boolean | undefined
1119
1129
  let checkMark: string | undefined
1120
- const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/)
1130
+ const taskMatch = content.match(/^\[([ xX~?bd!\-])\]\s+(.*)$/)
1121
1131
  if (taskMatch) {
1122
1132
  list.task = true
1123
- checked = taskMatch[1] !== " "
1133
+ checked = /[xX]/.test(taskMatch[1])
1124
1134
  checkMark = taskMatch[1]
1125
1135
  content = taskMatch[2]
1126
1136
  }
@@ -1158,6 +1168,8 @@ function parseList(lines: string[], start: number): ParsedList {
1158
1168
  const para: Block = { type: "paragraph", children: parseInline(content) }
1159
1169
  const children: Block[] = body.length ? [para, ...parseBlocks(body)] : [para]
1160
1170
  const item: ListItemNode = checked === undefined ? { type: "listItem", children } : { type: "listItem", children, checked }
1171
+ const state = checkMark === undefined ? undefined : taskStateFromMarker(checkMark)
1172
+ if (state && state !== "pending" && state !== "verified") item.state = state
1161
1173
  const expected = ordered ? `${(list.start ?? 1) + items.length}${list.delimiter ?? "."}` : (list.bullet ?? "-")
1162
1174
  if (m[2] !== expected) item.marker = m[2]
1163
1175
  if (m[3].length !== 1) item.pad = m[3].length
@@ -0,0 +1,49 @@
1
+ import { isMap, isScalar, parseDocument } from "yaml"
2
+ import type { DocumentPropertiesNode } from "./ast"
3
+
4
+ export type DocumentPropertyValue = string | number | boolean | null | DocumentPropertyValue[] | { [key: string]: DocumentPropertyValue }
5
+ export interface ParsedDocumentProperties {
6
+ values: Record<string, DocumentPropertyValue>
7
+ error?: string
8
+ }
9
+
10
+ /** Read properties without throwing on an incomplete or invalid document. YAML is never executed. */
11
+ export function readDocumentProperties(properties: DocumentPropertiesNode): ParsedDocumentProperties {
12
+ try {
13
+ const doc = parseDocument(properties.yaml)
14
+ if (doc.errors.length) return { values: {}, error: doc.errors[0].message }
15
+ if (doc.contents && !isMap(doc.contents)) return { values: {}, error: "Document properties must be a YAML mapping." }
16
+ const values = (doc.toJS({ maxAliasCount: 100 }) ?? {}) as Record<string, DocumentPropertyValue>
17
+ // Cyclic aliases can be preserved as YAML, but cannot be represented by editable JSON-like values.
18
+ JSON.stringify(values)
19
+ return { values }
20
+ } catch (error) {
21
+ return { values: {}, error: error instanceof Error ? error.message : String(error) }
22
+ }
23
+ }
24
+
25
+ /** Change one property while retaining the other keys, comments, scalar styles and frontmatter delimiters. */
26
+ export function setDocumentProperty(properties: DocumentPropertiesNode, key: string, value: DocumentPropertyValue): DocumentPropertiesNode {
27
+ const doc = parseDocument(properties.yaml)
28
+ const parsed = readDocumentProperties(properties)
29
+ if (parsed.error) throw new Error(parsed.error)
30
+ const previous = doc.get(key, true)
31
+ const range = isScalar(previous) ? previous.range : undefined
32
+ doc.set(key, value)
33
+ const yaml = doc.toString().replace(/\n$/, "")
34
+ const next = parseDocument(yaml).get(key, true)
35
+ // A scalar edit is a source-range patch, leaving every other byte (including flow-list spacing) alone.
36
+ if (range && isScalar(next) && next.range) {
37
+ return { ...properties, yaml: properties.yaml.slice(0, range[0]) + yaml.slice(next.range[0], next.range[1]) + properties.yaml.slice(range[1]) }
38
+ }
39
+ return { ...properties, yaml }
40
+ }
41
+
42
+ /** Remove one property; the frontmatter remains available for adding more. */
43
+ export function removeDocumentProperty(properties: DocumentPropertiesNode, key: string): DocumentPropertiesNode {
44
+ const doc = parseDocument(properties.yaml)
45
+ const parsed = readDocumentProperties(properties)
46
+ if (parsed.error) throw new Error(parsed.error)
47
+ doc.delete(key)
48
+ return { ...properties, yaml: doc.toString().replace(/\n$/, "") }
49
+ }
@@ -27,6 +27,7 @@ import { attr } from "./attrs"
27
27
  import { normalizeLabel, serializeInline, serializeReference, withDocumentDefinitions } from "./inline"
28
28
  import { plainText } from "./inline"
29
29
  import { parseBlocks } from "./parse"
30
+ import { taskMarker } from "./tasks"
30
31
 
31
32
  /** A `{% tag %}…{% endtag %}` container, block or item. */
32
33
  export type TaggedNode = HintNode | TabsNode | TabNode | StepperNode | StepNode | ColumnsNode | ColumnNode | UpdatesNode | UpdateNode
@@ -114,7 +115,9 @@ export function serializeMarkdown(doc: DocumentNode): string {
114
115
  documentCitations = citations
115
116
  try {
116
117
  return withDocumentDefinitions(defs, () => {
117
- let out = doc.children.map((b, k) => `${blankLines(b, k ? 1 : 0)}${serializeBlock(b)}\n`).join("")
118
+ const properties = doc.properties
119
+ let out = properties ? `${properties.opening ?? "---"}\n${properties.yaml}${properties.yaml ? "\n" : ""}${properties.closing ?? "---"}\n` : ""
120
+ out += doc.children.map((b, k) => `${blankLines(b, k ? 1 : 0)}${serializeBlock(b)}\n`).join("")
118
121
  // Footnote definitions not placed in the document end it, as a group.
119
122
  const trailing = (doc.citations ?? []).filter((c) => !placed.has(c.id))
120
123
  if (trailing.length) {
@@ -537,7 +540,7 @@ function inlineToHtml(nodes: Inline[]): string {
537
540
 
538
541
  function serializeListItem(item: ListItemNode, list: ListNode, idx: number): string {
539
542
  const marker = item.marker ?? (list.ordered ? `${(list.start ?? 1) + idx}${list.delimiter ?? "."}` : (list.bullet ?? "-"))
540
- const check = list.task ? `[${item.checked ? (item.checkMark ?? "x") : " "}] ` : ""
543
+ const check = list.task ? `[${taskMarker(item)}] ` : ""
541
544
  const [first, ...rest] = item.children
542
545
  const firstText = first?.type === "paragraph" ? serializeInline(first.children) : first ? serializeBlock(first) : ""
543
546
  const restText = rest.length ? "\n" + indent(serializeBlocks(rest), " ".repeat(item.indent ?? 2)) : ""
@@ -0,0 +1,25 @@
1
+ import type { ListItemNode, TaskState } from "./ast"
2
+
3
+ export const TASK_STATES: ReadonlyArray<{ id: TaskState; marker: string; label: string }> = [
4
+ { id: "pending", marker: " ", label: "Pending" },
5
+ { id: "in-progress", marker: "~", label: "In progress" },
6
+ { id: "implemented", marker: "?", label: "Implemented · evidence needed" },
7
+ { id: "blocked", marker: "b", label: "Blocked" },
8
+ { id: "parked", marker: "d", label: "Parked · verification debt" },
9
+ { id: "refuted", marker: "!", label: "Refuted" },
10
+ { id: "verified", marker: "x", label: "Verified" },
11
+ { id: "cancelled", marker: "-", label: "Cancelled" },
12
+ ]
13
+
14
+ export function taskStateFromMarker(marker: string): TaskState | undefined {
15
+ return TASK_STATES.find((state) => state.marker === (marker === "X" ? "x" : marker))?.id
16
+ }
17
+
18
+ export function getTaskState(item: Pick<ListItemNode, "state" | "checked">): TaskState {
19
+ return item.state ?? (item.checked ? "verified" : "pending")
20
+ }
21
+
22
+ /** Explicit state wins; untouched uppercase `[X]` tasks retain their spelling. */
23
+ export function taskMarker(item: Pick<ListItemNode, "state" | "checked" | "checkMark">): string {
24
+ return item.state ? TASK_STATES.find((state) => state.id === item.state)?.marker ?? " " : item.checked ? item.checkMark ?? "x" : " "
25
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,13 @@
3
3
  export { GitbookStreamdown } from "./streamdown"
4
4
  export type { GitbookStreamdownProps } from "./streamdown"
5
5
  export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
6
+ export { TaskCheckbox, TASK_STATE_ICONS } from "./docs/TaskCheckbox"
7
+ export type { TaskCheckboxProps, TaskStateChangeHandler } from "./docs/TaskCheckbox"
8
+ export { TASK_STATES, getTaskState, taskMarker, taskStateFromMarker } from "./gitbook/tasks"
9
+ export { DocumentProperties } from "./docs/DocumentProperties"
10
+ export type { DocumentPropertiesProps } from "./docs/DocumentProperties"
11
+ export { readDocumentProperties, removeDocumentProperty, setDocumentProperty } from "./gitbook/properties"
12
+ export type { DocumentPropertyValue, ParsedDocumentProperties } from "./gitbook/properties"
6
13
  export type { DocRenderOptions, LivePreviewProps, LivePreviewRenderer, SourceReferenceRenderer } from "./docs/DocsRenderer"
7
14
  export { DocPageActions } from "./docs/PageActions"
8
15
  export type { DocPageAction, DocPageActionsOptions, DocPageActionsProps } from "./docs/PageActions"
@@ -35,6 +35,8 @@ export function GitbookStreamdown({
35
35
  demoDependencies,
36
36
  demoVFS,
37
37
  pageActions,
38
+ documentProperties,
39
+ onTaskStateChange,
38
40
  }: GitbookStreamdownProps) {
39
41
  const content = markdown ?? children ?? ""
40
42
  const gitbookDoc = useMemo(
@@ -59,6 +61,8 @@ export function GitbookStreamdown({
59
61
  demoDependencies={demoDependencies}
60
62
  demoVFS={demoVFS}
61
63
  pageActions={isStreaming ? false : pageActions}
64
+ documentProperties={documentProperties}
65
+ onTaskStateChange={onTaskStateChange}
62
66
  />
63
67
  </div>
64
68
  )
package/src/styles.css CHANGED
@@ -1,3 +1,5 @@
1
+ @import "./docs/properties.css";
2
+ @import "./docs/tasks.css";
1
3
 
2
4
  [data-docstream],
3
5
  .docs-article {