markdown-stream.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import { marked, type Tokens } from "marked"
  2. import remend from "remend"
  3. export type Block = {
  4. raw: string
  5. src: string
  6. mode: "full" | "live" | "code"
  7. language?: string
  8. complete?: boolean
  9. }
  10. export type Projection = {
  11. text: string
  12. blocks: Block[]
  13. }
  14. function refs(text: string) {
  15. if (!text.includes("]:")) return false
  16. return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text)
  17. }
  18. function language(value: string | undefined) {
  19. return value?.trim().split(/\s+/, 1)[0] || undefined
  20. }
  21. function openCode(raw: string) {
  22. const newline = raw.indexOf("\n")
  23. return newline < 0 ? "" : raw.slice(newline + 1)
  24. }
  25. function open(raw: string) {
  26. const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
  27. if (!match) return false
  28. const mark = match[1]
  29. if (!mark) return false
  30. const char = mark[0]
  31. const size = mark.length
  32. const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
  33. return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
  34. }
  35. function closesFence(raw: string, suffix: string) {
  36. const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1]
  37. if (!mark) return suffix.includes("```") || suffix.includes("~~~")
  38. return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark)
  39. }
  40. function heal(text: string) {
  41. return remend(text, { linkMode: "text-only" })
  42. }
  43. export function stream(text: string, live: boolean): Block[] {
  44. if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
  45. if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
  46. const tokens = marked.lexer(text)
  47. const tail = tokens.findLastIndex((token) => token.type !== "space")
  48. if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
  49. const last = tokens[tail]
  50. if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
  51. const result: Block[] = []
  52. for (let index = 0; index < tail; index++) {
  53. const token = tokens[index]
  54. if (!token || token.type === "space") continue
  55. let raw = token.raw
  56. while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw
  57. if (token.type === "code") {
  58. const code = token as Tokens.Code
  59. result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true })
  60. continue
  61. }
  62. result.push({ raw, src: raw, mode: "full" })
  63. }
  64. const raw = tokens
  65. .slice(tail)
  66. .map((token) => token.raw)
  67. .join("")
  68. if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
  69. const code = last as Tokens.Code
  70. if (!open(code.raw))
  71. return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
  72. return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
  73. }
  74. export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
  75. if (!current || current.mode !== next.mode) return false
  76. if (next.mode === "code") return next.raw.startsWith(current.raw)
  77. return current.raw === next.raw
  78. }
  79. export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
  80. if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
  81. const tail = previous.blocks.at(-1)
  82. const suffix = text.slice(previous.text.length)
  83. if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
  84. return { text, blocks: stream(text, live) }
  85. return {
  86. text,
  87. blocks: [
  88. ...previous.blocks.slice(0, -1),
  89. {
  90. ...tail,
  91. raw: tail.raw + suffix,
  92. src: tail.src + suffix,
  93. },
  94. ],
  95. }
  96. }