frontmatter.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import yaml from "js-yaml"
  2. export type FrontmatterValue = string | string[]
  3. export interface FrontmatterParseResult {
  4. frontmatter: Record<string, FrontmatterValue> | null
  5. body: string
  6. /**
  7. * The literal frontmatter block (opening `---`, YAML payload,
  8. * closing `---`, plus the newlines that separate it from the
  9. * body) as it appears in the input. Empty string when there is
  10. * no frontmatter. Callers that edit only the body — e.g. the
  11. * WikiEditor — write back `rawBlock + body` so user-managed YAML
  12. * survives untouched.
  13. */
  14. rawBlock: string
  15. }
  16. // Strict, anchored detector. Both fence lines must be on their own
  17. // line; content between is delegated to js-yaml. Used as the first
  18. // step before falling back to the locator below.
  19. const FM_BLOCK_STRICT_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/
  20. // Same shape as STRICT but unanchored — used only when STRICT
  21. // failed. LLM-generated pages often prepend an extra line or two
  22. // before the real frontmatter (a stray `\`\`\`yaml` wrapper line, a
  23. // `frontmatter:` key from a misformatted nested-document attempt,
  24. // etc.). Rather than enumerating every such prefix, we look for
  25. // the FIRST `---\n…\n---` block whose OPENING fence sits in the
  26. // top few lines. The closing fence can land anywhere — long
  27. // frontmatter lists are common — but capping the open-line means
  28. // a `---` horizontal rule used as a section divider deep in the
  29. // body can't be mistaken for frontmatter.
  30. const FM_BLOCK_ANYWHERE_RE = /\n---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/
  31. const MAX_PREFIX_LINES_BEFORE_FRONTMATTER = 6
  32. export function parseFrontmatter(content: string): FrontmatterParseResult {
  33. const located = locateFrontmatterBlock(content)
  34. if (!located) return { frontmatter: null, body: content, rawBlock: "" }
  35. const { yamlPayload, rawBlock, body } = located
  36. // Two-pass YAML parse: try the payload as-is first, then on
  37. // failure run a single round of "wikilink-list" repair (LLMs
  38. // sometimes emit `related: [[a]], [[b]], [[c]]` which is not
  39. // valid YAML — wrap each `[[…]]` in quotes so it parses as a
  40. // string list). This is the only fixup we apply; anything
  41. // beyond that is reported as no-frontmatter.
  42. let parsed: unknown
  43. try {
  44. parsed = yaml.load(yamlPayload, { schema: yaml.JSON_SCHEMA })
  45. } catch {
  46. try {
  47. parsed = yaml.load(repairWikilinkLists(yamlPayload), { schema: yaml.JSON_SCHEMA })
  48. } catch {
  49. return { frontmatter: null, body, rawBlock }
  50. }
  51. }
  52. return {
  53. frontmatter: normalize(parsed),
  54. body,
  55. rawBlock,
  56. }
  57. }
  58. /**
  59. * Find the first `---…---` frontmatter block. Strict (top-of-file)
  60. * match is preferred; if it fails we scan a small window for an
  61. * unanchored block, which lets us recover from common LLM-corrupted
  62. * pages that put a junk line or two before the real frontmatter
  63. * (e.g. wrapping the file in a code fence, or emitting
  64. * `frontmatter:\n---\n…\n---\n`). Returns null when neither finds
  65. * anything plausible.
  66. */
  67. function locateFrontmatterBlock(
  68. content: string,
  69. ): { yamlPayload: string; rawBlock: string; body: string } | null {
  70. const strict = content.match(FM_BLOCK_STRICT_RE)
  71. if (strict) {
  72. return {
  73. yamlPayload: strict[1],
  74. rawBlock: strict[0],
  75. body: content.slice(strict[0].length),
  76. }
  77. }
  78. // Scan the entire content (not just a head window) so a long
  79. // frontmatter list still resolves. The lazy match picks the
  80. // FIRST `---…---` pair, and we then guard against false
  81. // positives by checking that the OPENING `---` is within the
  82. // first few lines — that excludes section-divider HRs deep in
  83. // the body without limiting how long the frontmatter itself
  84. // can be.
  85. const fallback = content.match(FM_BLOCK_ANYWHERE_RE)
  86. if (!fallback || fallback.index === undefined) return null
  87. const openIdx = fallback.index + 1 // skip the leading `\n`
  88. if (lineNumberAt(content, openIdx) > MAX_PREFIX_LINES_BEFORE_FRONTMATTER) {
  89. return null
  90. }
  91. const rawBlock = content.slice(openIdx, openIdx + fallback[0].length - 1)
  92. const bodyAfterFm = content.slice(openIdx + rawBlock.length)
  93. // If the prefix that pushed us into the fallback is a ```yaml /
  94. // ```yml (or bare ```) code fence opener, strip the matching
  95. // CLOSING fence at the head of the body too. Without this, a
  96. // legacy LLM-corrupted page that wrapped its frontmatter in a
  97. // code fence renders correctly up top (the parser still
  98. // recovered the YAML) but the body opens with an orphan ```
  99. // that ReactMarkdown treats as a never-closed code block —
  100. // every heading / list / table below appears as raw source.
  101. const prefix = content.slice(0, openIdx)
  102. const prefixIsYamlFence = /^\s*```(?:yaml|yml)?\s*\r?\n$/i.test(prefix)
  103. if (prefixIsYamlFence) {
  104. const stripped = bodyAfterFm.replace(/^\s*```\s*(?:\r?\n|$)/, "")
  105. return {
  106. yamlPayload: fallback[1],
  107. rawBlock,
  108. body: stripped,
  109. }
  110. }
  111. return {
  112. yamlPayload: fallback[1],
  113. rawBlock,
  114. body: bodyAfterFm,
  115. }
  116. }
  117. /** 1-based line number that a given character index sits on. */
  118. function lineNumberAt(s: string, index: number): number {
  119. let line = 1
  120. for (let i = 0; i < index && i < s.length; i++) {
  121. if (s.charCodeAt(i) === 10) line++
  122. }
  123. return line
  124. }
  125. /**
  126. * Repair a YAML payload where the author wrote a list of Obsidian
  127. * wikilinks without the outer brackets:
  128. *
  129. * related: [[a]], [[b]], [[c]]
  130. *
  131. * which YAML rejects. We rewrite each line that matches that shape
  132. * into a quoted-string flow array so js-yaml can parse it:
  133. *
  134. * related: ["[[a]]", "[[b]]", "[[c]]"]
  135. *
  136. * Only touches lines that look exactly like that pattern; anything
  137. * else is passed through unchanged so a legitimate nested-array
  138. * value (`tags: [[red, blue], [green]]`) isn't mangled.
  139. */
  140. function repairWikilinkLists(payload: string): string {
  141. return payload
  142. .split("\n")
  143. .map((line) => {
  144. const m = line.match(/^(\s*[A-Za-z_][\w-]*\s*:\s*)(\[\[[^\]]+\]\](?:\s*,\s*\[\[[^\]]+\]\])+)\s*$/)
  145. if (!m) return line
  146. const prefix = m[1]
  147. const items = m[2]
  148. .split(",")
  149. .map((s) => s.trim())
  150. .filter(Boolean)
  151. .map((s) => `"${s}"`)
  152. .join(", ")
  153. return `${prefix}[${items}]`
  154. })
  155. .join("\n")
  156. }
  157. /**
  158. * Coerce js-yaml's output into the shape FrontmatterPanel consumes:
  159. * a flat `Record<string, string | string[]>`. Nested objects and
  160. * scalars that aren't strings are stringified so unusual YAML
  161. * still surfaces in the UI rather than silently disappearing.
  162. */
  163. function normalize(parsed: unknown): Record<string, FrontmatterValue> | null {
  164. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
  165. const out: Record<string, FrontmatterValue> = {}
  166. for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
  167. if (Array.isArray(value)) {
  168. out[key] = value.map((v) => stringifyScalar(v))
  169. continue
  170. }
  171. out[key] = stringifyScalar(value)
  172. }
  173. return out
  174. }
  175. function stringifyScalar(v: unknown): string {
  176. if (v === null || v === undefined) return ""
  177. if (typeof v === "string") return v
  178. if (typeof v === "number" || typeof v === "boolean") return String(v)
  179. if (v instanceof Date) return v.toISOString().slice(0, 10)
  180. // Object / nested array → JSON so the user still sees something.
  181. try {
  182. return JSON.stringify(v)
  183. } catch {
  184. return String(v)
  185. }
  186. }