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
129
130
131
132
133
134
135
136
137
138 |
// Package slug turns a content file's relative path into the slug the host
// site uses in its URLs. Each rule must match the host byte for byte, so every
// rule is pinned by a parity table generated from the host's own code.
package slug
import (
"fmt"
"path"
"strings"
"time"
"golang.org/x/text/unicode/norm"
)
// Rule computes a slug from a '/'-separated relative path.
type Rule func(relPath string) string
// Lookup returns the named rule.
func Lookup(name string) (Rule, error) {
switch name {
case "datestrip-slugify":
return DatestripSlugify, nil
case "path":
return Path, nil
}
return nil, fmt.Errorf("unknown slug rule %q (want datestrip-slugify or path)", name)
}
// Slugify mirrors the JavaScript chain
//
// value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
// .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
//
// Lowercasing can run after the ASCII filter because, once NFD has split
// compatibility singletons like the Kelvin sign, the only runes that
// lowercase into [a-z] are ASCII capitals.
func Slugify(s string) string {
var b strings.Builder
pendingDash := false
for _, r := range norm.NFD.String(s) {
if r >= 0x300 && r <= 0x36f {
continue
}
if r >= 'A' && r <= 'Z' {
r += 'a' - 'A'
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
if pendingDash && b.Len() > 0 {
b.WriteByte('-')
}
pendingDash = false
b.WriteRune(r)
continue
}
pendingDash = true
}
return b.String()
}
// DatestripSlugify is the moonflowersociety.org rule: drop the extension and
// a leading YYYY-MM-DD date, slugify, and fall back to the whole base name
// when nothing is left.
func DatestripSlugify(relPath string) string {
base := path.Base(relPath)
base = base[:len(base)-len(ext(base))]
if s := Slugify(stripDatePrefix(base)); s != "" {
return s
}
return Slugify(base)
}
// Path keeps the directory structure: every segment is slugified and the
// extension is dropped from the last one.
func Path(relPath string) string {
segs := strings.Split(relPath, "/")
last := segs[len(segs)-1]
segs[len(segs)-1] = last[:len(last)-len(ext(last))]
out := segs[:0]
for _, s := range segs {
if s = Slugify(s); s != "" {
out = append(out, s)
}
}
return strings.Join(out, "/")
}
// ext matches Node's path.extname: a leading dot does not start an extension.
func ext(base string) string {
i := strings.LastIndexByte(base, '.')
if i <= 0 {
return ""
}
return base[i:]
}
// stripDatePrefix matches /^(\d{4})-(\d{2})-(\d{2})[-_\s]+/ with JavaScript's
// definitions of \d (ASCII only) and \s (Unicode spaces plus U+FEFF).
func stripDatePrefix(s string) string {
const shape = "dddd-dd-dd"
if len(s) < len(shape) {
return s
}
for i := 0; i < len(shape); i++ {
c := s[i]
if shape[i] == 'd' && (c < '0' || c > '9') || shape[i] == '-' && c != '-' {
return s
}
}
rest := s[len(shape):]
trimmed := strings.TrimLeftFunc(rest, func(r rune) bool { return r == '-' || r == '_' || isJSSpace(r) })
if len(trimmed) == len(rest) {
return s
}
return trimmed
}
// DatePrefix returns the YYYY-MM-DD date a file name starts with, when the
// name follows the datestrip convention and the date is a real one.
func DatePrefix(relPath string) (string, bool) {
base := path.Base(relPath)
base = base[:len(base)-len(ext(base))]
if stripDatePrefix(base) == base {
return "", false
}
d := base[:10]
if _, err := time.Parse("2006-01-02", d); err != nil {
return "", false
}
return d, true
}
func isJSSpace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ', 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff:
return true
}
return r >= 0x2000 && r <= 0x200a
}
|