markdown.ts 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. import { NamedError } from "@opencode-ai/util/error"
  2. import matter from "gray-matter"
  3. import { z } from "zod"
  4. import { Filesystem } from "../util/filesystem"
  5. export namespace ConfigMarkdown {
  6. export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
  7. export const SHELL_REGEX = /!`([^`]+)`/g
  8. export function files(template: string) {
  9. return Array.from(template.matchAll(FILE_REGEX))
  10. }
  11. export function shell(template: string) {
  12. return Array.from(template.matchAll(SHELL_REGEX))
  13. }
  14. // other coding agents like claude code allow invalid yaml in their
  15. // frontmatter, we need to fallback to a more permissive parser for those cases
  16. export function fallbackSanitization(content: string): string {
  17. const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
  18. if (!match) return content
  19. const frontmatter = match[1]
  20. const lines = frontmatter.split(/\r?\n/)
  21. const result: string[] = []
  22. for (const line of lines) {
  23. // skip comments and empty lines
  24. if (line.trim().startsWith("#") || line.trim() === "") {
  25. result.push(line)
  26. continue
  27. }
  28. // skip lines that are continuations (indented)
  29. if (line.match(/^\s+/)) {
  30. result.push(line)
  31. continue
  32. }
  33. // match key: value pattern
  34. const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
  35. if (!kvMatch) {
  36. result.push(line)
  37. continue
  38. }
  39. const key = kvMatch[1]
  40. const value = kvMatch[2].trim()
  41. // skip if value is empty, already quoted, or uses block scalar
  42. if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
  43. result.push(line)
  44. continue
  45. }
  46. // if value contains a colon, convert to block scalar
  47. if (value.includes(":")) {
  48. result.push(`${key}: |-`)
  49. result.push(` ${value}`)
  50. continue
  51. }
  52. result.push(line)
  53. }
  54. const processed = result.join("\n")
  55. return content.replace(frontmatter, () => processed)
  56. }
  57. export async function parse(filePath: string) {
  58. const template = await Filesystem.readText(filePath)
  59. try {
  60. const md = matter(template)
  61. return md
  62. } catch {
  63. try {
  64. return matter(fallbackSanitization(template))
  65. } catch (err) {
  66. throw new FrontmatterError(
  67. {
  68. path: filePath,
  69. message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
  70. },
  71. { cause: err },
  72. )
  73. }
  74. }
  75. }
  76. export const FrontmatterError = NamedError.create(
  77. "ConfigFrontmatterError",
  78. z.object({
  79. path: z.string(),
  80. message: z.string(),
  81. }),
  82. )
  83. }