+325
Diff
round #2
+48
README.md
+48
README.md
···
1
+
# hugo-digital-garden
2
+
3
+
This is a preprocessor for Hugo to create a digital garden from an Obsidian Vault. The idea is, that this tool can be used to generate your Hugo ./content directory from an Obsidian Vault, containing only notes that are not explicitly marked as private.
4
+
5
+
What it will do
6
+
7
+
- filter out private notes
8
+
- create a content structure, mimicking the Obsidian Vault structure
9
+
- handle attachments like images, videos, audio files, etc.
10
+
- handle varying frontmatter
11
+
- add missing frontmatter
12
+
- merge hugo and existing frontmatter
13
+
- generate "last modified at" information to every note (optopnal)
14
+
15
+
Assumptions
16
+
17
+
- all note editing will take place in Obsidian
18
+
- no manual editing of Hugos `./content` directory
19
+
- general editing flow is
20
+
- edit note in Obsidian
21
+
- use hugo-digital-garden to generate content directory
22
+
- `hugo serve` ...
23
+
24
+
A private note is
25
+
26
+
- marked within frontmatter as `private: true`
27
+
- located in a folder named `private` - somewhere in the directory path
28
+
29
+
The structure of your `./content` directory will mimic the structure of your Obsidian Vault, with the following exceptions:
30
+
31
+
- Private notes are not included in the generated content directory.
32
+
- The `private` folder is not included in the generated content directory.
33
+
34
+
Additional notes that are excluded
35
+
36
+
- configured directories like
37
+
- Clippings
38
+
- Templates
39
+
- bases or files that contain bases (simply as it is unclear how to render them)
40
+
41
+
42
+
Questions:
43
+
44
+
- How to handle varying frontmatter?
45
+
- How to handle backlinks?
46
+
- Do we need something that does graph visualization?
47
+
- could be done via a json file and a hugo theme extension reading this file
48
+
- should we support Hugo archetypes?
+194
file.go
+194
file.go
···
1
1
package main
2
+
2
3
import (
3
4
"fmt"
5
+
"io"
6
+
"log"
7
+
"os"
4
8
"path/filepath"
9
+
"regexp"
5
10
"strings"
11
+
6
12
"github.com/google/uuid"
13
+
"github.com/yuin/goldmark"
14
+
"github.com/yuin/goldmark/ast"
15
+
"github.com/yuin/goldmark/text"
16
+
"go.abhg.dev/goldmark/frontmatter"
17
+
"go.abhg.dev/goldmark/wikilink"
7
18
)
19
+
8
20
type File struct {
9
21
Id string
10
22
Name string
···
21
33
22
34
Links []string
23
35
}
36
+
24
37
func InitNewFile(name string, obsidianPath string, hugoPath string) *File {
25
38
id := uuid.New().String()
26
39
relativePath, err := GetRelativePath(obsidianPath)
···
50
63
func HugoPath(contentDir, relativePath string) string {
51
64
return filepath.Join(contentDir, relativePath)
52
65
}
66
+
67
+
func (f *File) GetHugoPath() string {
68
+
return f.HugoPath
69
+
}
70
+
71
+
func (f *File) GetObsidianPath() string {
72
+
return f.ObsidianPath
73
+
}
74
+
75
+
func (f *File) ReadObsidianFile() error {
76
+
// Open the file with the extracted name
77
+
file, err := os.Open(f.ObsidianPath)
78
+
if err != nil {
79
+
return fmt.Errorf("failed to open file %s: %w", f.ObsidianPath, err)
80
+
}
81
+
defer file.Close()
82
+
83
+
// Read the file content
84
+
f.RawContent, err = io.ReadAll(file)
85
+
if err != nil {
86
+
return fmt.Errorf("failed to read file %s: %w", f.ObsidianPath, err)
87
+
}
88
+
89
+
// Parse the Markdown content
90
+
f.ObsidianMarkdown = parseMarkdown(f.RawContent)
91
+
f.HugoMarkdown = parseMarkdown(f.RawContent)
92
+
ensureBasicFrontmatter(f)
93
+
94
+
return nil
95
+
}
96
+
53
97
func getFileType(path string) string {
54
98
if strings.HasSuffix(path, "/") {
55
99
return "Directory"
···
90
134
return "Unknown"
91
135
}
92
136
}
137
+
138
+
func parseMarkdown(src []byte) *ast.Document {
139
+
md := goldmark.New(
140
+
goldmark.WithExtensions(
141
+
&frontmatter.Extender{
142
+
Mode: frontmatter.SetMetadata,
143
+
},
144
+
&wikilink.Extender{
145
+
// Resolver: ResolveWikilink,
146
+
}),
147
+
)
148
+
149
+
root := md.Parser().Parse(text.NewReader(src))
150
+
doc := root.OwnerDocument()
151
+
152
+
return doc
153
+
}
154
+
155
+
type myresolver struct{}
156
+
157
+
var _html = []byte(".html")
158
+
var _hash = []byte{'#'}
159
+
160
+
func (myresolver) ResolveWikilink(n *wikilink.Node) ([]byte, error) {
161
+
dest := make([]byte, len(n.Target)+len(_html)+len(_hash)+len(n.Fragment))
162
+
var i int
163
+
if len(n.Target) > 0 {
164
+
i += copy(dest, n.Target)
165
+
if filepath.Ext(string(n.Target)) == "" {
166
+
i += copy(dest[i:], _html)
167
+
}
168
+
}
169
+
if len(n.Fragment) > 0 {
170
+
i += copy(dest[i:], _hash)
171
+
i += copy(dest[i:], n.Fragment)
172
+
}
173
+
return dest[:i], nil
174
+
}
175
+
176
+
func ensureBasicFrontmatter(f *File) {
177
+
meta := f.HugoMarkdown.Meta()
178
+
179
+
// Minimum set of frontmatter fields:
180
+
// ---
181
+
// title: Example Title
182
+
// draft: false
183
+
// tags:
184
+
// - example-tag
185
+
// ---
186
+
187
+
// if title set title to filename
188
+
if meta["title"] == nil {
189
+
meta["title"] = filepath.Base(f.Name)
190
+
}
191
+
192
+
// if a file has a private flag, set treat it accordingly
193
+
if meta["private"] != nil {
194
+
f.Private = meta["private"].(bool)
195
+
}
196
+
197
+
if meta["draft"] == nil {
198
+
if f.Private {
199
+
meta["draft"] = true
200
+
} else {
201
+
meta["draft"] = false
202
+
}
203
+
}
204
+
205
+
// if tags are empty, set at least one tag "note"
206
+
if meta["tags"] == nil {
207
+
meta["tags"] = []string{"note"}
208
+
}
209
+
210
+
f.HugoMarkdown.SetMeta(meta)
211
+
}
212
+
213
+
func GenerateHugoDirectory(files []*File) error {
214
+
215
+
for _, f := range files {
216
+
if f.Private {
217
+
continue
218
+
}
219
+
if err := WriteHugoFile(f); err != nil {
220
+
return err
221
+
}
222
+
}
223
+
224
+
return nil
225
+
}
226
+
227
+
// write a function that creates a new file based on a given input path
228
+
func WriteHugoFile(f *File) error {
229
+
230
+
// Create the file with the extracted name
231
+
file, err := os.Create(f.Name)
232
+
if err != nil {
233
+
return fmt.Errorf("failed to create file %s: %w", f.Name, err)
234
+
}
235
+
defer file.Close()
236
+
237
+
return nil
238
+
}
239
+
240
+
func (f *File) containsLink() bool {
241
+
// Match all patterns like [[.*?]], including those preceded by "!"
242
+
re := regexp.MustCompile(`\[\[.*?\]\]`)
243
+
244
+
matches := re.FindAllIndex(f.RawContent, -1)
245
+
for _, match := range matches {
246
+
// Check if the match is not preceded by "!"
247
+
if match[0] == 0 || f.RawContent[match[0]-1] != '!' {
248
+
fmt.Println(string(f.RawContent))
249
+
return true
250
+
}
251
+
}
252
+
253
+
return false
254
+
}
255
+
256
+
// extractLinks extracts all valid links (e.g., [[some text]]) from the content.
257
+
func extractLinks(content []byte) []string {
258
+
re := regexp.MustCompile(`\[\[(.*?)\]\]`)
259
+
codeBlockRe := regexp.MustCompile("(?s)```.*?```") // Matches Markdown code blocks
260
+
261
+
// Remove all code blocks from the content
262
+
cleanedContent := codeBlockRe.ReplaceAll(content, []byte{})
263
+
264
+
matches := re.FindAllSubmatchIndex(cleanedContent, -1)
265
+
266
+
uniqueLinks := make(map[string]struct{})
267
+
for _, match := range matches {
268
+
if len(match) >= 4 {
269
+
start := match[0]
270
+
271
+
// Check if the match is not preceded by "!"
272
+
if start == 0 || cleanedContent[start-1] != '!' {
273
+
link := string(cleanedContent[match[2]:match[3]])
274
+
uniqueLinks[link] = struct{}{}
275
+
}
276
+
}
277
+
}
278
+
279
+
// Convert the map keys to a slice
280
+
var links []string
281
+
for link := range uniqueLinks {
282
+
links = append(links, link)
283
+
}
284
+
285
+
return links
286
+
}
+83
filelist.go
+83
filelist.go
···
1
+
package main
2
+
3
+
import (
4
+
"encoding/json"
5
+
"path/filepath"
6
+
"strings"
7
+
)
8
+
9
+
type ObsidianVaultFileList []*File
10
+
11
+
func InitObsidianVaultFileList(paths []string) ObsidianVaultFileList {
12
+
return PathListToFileList(paths)
13
+
}
14
+
15
+
func PathListToFileList(paths []string) ObsidianVaultFileList {
16
+
var fileList ObsidianVaultFileList
17
+
for _, path := range paths {
18
+
fileList = append(fileList, InitNewFile(path, path, o.HugoContentDirectory))
19
+
}
20
+
return fileList
21
+
}
22
+
23
+
// Returns amount of files in the list
24
+
func (fl *ObsidianVaultFileList) Len() int {
25
+
return len(*fl)
26
+
}
27
+
28
+
func (fl *ObsidianVaultFileList) GetIdByObsidianPath(path string) string {
29
+
for _, file := range *fl {
30
+
if file.ObsidianPath == path {
31
+
return file.Id
32
+
}
33
+
}
34
+
return ""
35
+
}
36
+
37
+
func (fl *ObsidianVaultFileList) GetIdByObsidianFileName(name string) string {
38
+
for _, file := range *fl {
39
+
if file.Name == name {
40
+
return file.Id
41
+
}
42
+
}
43
+
return ""
44
+
}
45
+
46
+
type ObsidianVaultGraph struct {
47
+
Nodes ObsidianVaultFileList
48
+
Edges map[string][]string
49
+
}
50
+
51
+
// MarshalObsidianVaultGraph marshals the ObsidianVaultGraph to JSON
52
+
func (graph ObsidianVaultGraph) MarshalObsidianVaultGraph() ([]byte, error) {
53
+
return json.Marshal(graph)
54
+
}
55
+
56
+
func InitObsidianVaultGraph() ObsidianVaultGraph {
57
+
return ObsidianVaultGraph{
58
+
Nodes: ObsidianVaultFileList{},
59
+
Edges: make(map[string][]string),
60
+
}
61
+
}
62
+
63
+
// Filter out files that are not Markdown files
64
+
func (fl *ObsidianVaultFileList) FilterMarkdown() *ObsidianVaultFileList {
65
+
var filteredFiles ObsidianVaultFileList
66
+
for _, file := range *fl {
67
+
if filepath.Ext(file.Type) == "Markdown" {
68
+
filteredFiles = append(filteredFiles, file)
69
+
}
70
+
}
71
+
return &filteredFiles
72
+
}
73
+
74
+
// Filter out files that are not Markdown files
75
+
func (fl *ObsidianVaultFileList) FilterObsidian() *ObsidianVaultFileList {
76
+
var filteredFiles ObsidianVaultFileList
77
+
for _, file := range filteredFiles {
78
+
if !strings.HasPrefix(file.ObsidianPath, ".obsidian") {
79
+
filteredFiles = append(filteredFiles, file)
80
+
}
81
+
}
82
+
return &filteredFiles
83
+
}
History
3 rounds
0 comments
voigt.tngl.sh
submitted
#2
1 commit
expand
collapse
no empty description
expand 0 comments
closed without merging
voigt.tngl.sh
submitted
#1
1 commit
expand
collapse
no empty description
expand 0 comments
voigt.tngl.sh
submitted
#0
1 commit
expand
collapse
no empty description