1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
|
package preview
import "strings"
// prop is one frontmatter key and its flattened display value.
type prop struct {
key string
val string
}
// listSep joins the items of a YAML sequence into one display value.
const listSep = " · "
// splitFrontmatter peels a leading YAML frontmatter block (--- … ---) off the
// document, returning its properties and the remaining markdown. A block that
// doesn't start on the first line, or never closes, is left in the body: it's a
// horizontal rule, not metadata.
func splitFrontmatter(md string) ([]prop, string) {
if !strings.HasPrefix(md, "---\n") && !strings.HasPrefix(md, "---\r\n") {
return nil, md
}
lines := strings.Split(md[strings.IndexByte(md, '\n')+1:], "\n")
for i, ln := range lines {
if t := strings.TrimRight(ln, "\r"); t == "---" || t == "..." {
return parseProps(lines[:i]), strings.Join(lines[i+1:], "\n")
}
}
return nil, md
}
// parseProps reads the lines between the frontmatter fences. It handles the
// shapes Obsidian writes — scalars, inline arrays, and indented "- item"
// sequences — flattening each to a single line. Anything more exotic (nested
// maps, block scalars) degrades to its raw text rather than erroring.
func parseProps(lines []string) []prop {
var props []prop
var items []string
flush := func() {
if len(items) > 0 && len(props) > 0 {
props[len(props)-1].val = strings.Join(items, listSep)
}
items = nil
}
for _, raw := range lines {
ln := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(ln)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
// Indented or dashed lines belong to the property above.
if ln != t || strings.HasPrefix(t, "-") {
if strings.HasPrefix(t, "-") {
items = append(items, cleanScalar(strings.TrimSpace(t[1:])))
} else if len(props) > 0 {
items = append(items, cleanScalar(t))
}
continue
}
flush()
k, v, ok := strings.Cut(ln, ":")
if !ok {
continue
}
props = append(props, prop{strings.TrimSpace(k), cleanValue(strings.TrimSpace(v))})
}
flush()
return props
}
// cleanValue flattens one scalar or inline array ("[a, b]") to display text.
func cleanValue(v string) string {
if len(v) >= 2 && strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
// A wikilink ("[[Note]]") is a scalar, not an array.
if !strings.HasPrefix(v, "[[") {
var out []string
for _, item := range splitItems(v[1 : len(v)-1]) {
if s := cleanScalar(item); s != "" {
out = append(out, s)
}
}
return strings.Join(out, listSep)
}
}
return cleanScalar(v)
}
// splitItems splits an inline array's contents on commas outside quotes.
func splitItems(s string) []string {
var out []string
var quote rune
start := 0
for i, r := range s {
switch {
case quote != 0:
if r == quote {
quote = 0
}
case r == '"' || r == '\'':
quote = r
case r == ',':
out = append(out, s[start:i])
start = i + 1
}
}
return append(out, s[start:])
}
// cleanScalar trims a value and drops one layer of matching quotes.
func cleanScalar(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
if q := s[0]; (q == '"' || q == '\'') && s[len(s)-1] == q {
return s[1 : len(s)-1]
}
}
return s
}
// takeTitle pulls the "title" property out of props, so a doc titled in its
// frontmatter uses that name for the heading instead of repeating it below.
func takeTitle(props []prop) (string, []prop) {
for i, p := range props {
if p.key == "title" && p.val != "" {
return p.val, append(props[:i:i], props[i+1:]...)
}
}
return "", props
}
|