···11+# hugo-digital-garden
22+33+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.
44+55+What it will do
66+77+- filter out private notes
88+- create a content structure, mimicking the Obsidian Vault structure
99+- handle attachments like images, videos, audio files, etc.
1010+- handle varying frontmatter
1111+ - add missing frontmatter
1212+ - merge hugo and existing frontmatter
1313+ - generate "last modified at" information to every note (optopnal)
1414+1515+Assumptions
1616+1717+- all note editing will take place in Obsidian
1818+- no manual editing of Hugos `./content` directory
1919+- general editing flow is
2020+ - edit note in Obsidian
2121+ - use hugo-digital-garden to generate content directory
2222+ - `hugo serve` ...
2323+2424+A private note is
2525+2626+- marked within frontmatter as `private: true`
2727+- located in a folder named `private` - somewhere in the directory path
2828+2929+The structure of your `./content` directory will mimic the structure of your Obsidian Vault, with the following exceptions:
3030+3131+- Private notes are not included in the generated content directory.
3232+- The `private` folder is not included in the generated content directory.
3333+3434+Additional notes that are excluded
3535+3636+- configured directories like
3737+ - Clippings
3838+ - Templates
3939+- bases or files that contain bases (simply as it is unclear how to render them)
4040+4141+4242+Questions:
4343+4444+- How to handle varying frontmatter?
4545+- How to handle backlinks?
4646+- Do we need something that does graph visualization?
4747+ - could be done via a json file and a hugo theme extension reading this file
4848+- should we support Hugo archetypes?
+194
file.go
···11package main
22+23import (
34 "fmt"
55+ "io"
66+ "log"
77+ "os"
48 "path/filepath"
99+ "regexp"
510 "strings"
1111+612 "github.com/google/uuid"
1313+ "github.com/yuin/goldmark"
1414+ "github.com/yuin/goldmark/ast"
1515+ "github.com/yuin/goldmark/text"
1616+ "go.abhg.dev/goldmark/frontmatter"
1717+ "go.abhg.dev/goldmark/wikilink"
718)
1919+820type File struct {
921 Id string
1022 Name string
···21332234 Links []string
2335}
3636+2437func InitNewFile(name string, obsidianPath string, hugoPath string) *File {
2538 id := uuid.New().String()
2639 relativePath, err := GetRelativePath(obsidianPath)
···5063func HugoPath(contentDir, relativePath string) string {
5164 return filepath.Join(contentDir, relativePath)
5265}
6666+6767+func (f *File) GetHugoPath() string {
6868+ return f.HugoPath
6969+}
7070+7171+func (f *File) GetObsidianPath() string {
7272+ return f.ObsidianPath
7373+}
7474+7575+func (f *File) ReadObsidianFile() error {
7676+ // Open the file with the extracted name
7777+ file, err := os.Open(f.ObsidianPath)
7878+ if err != nil {
7979+ return fmt.Errorf("failed to open file %s: %w", f.ObsidianPath, err)
8080+ }
8181+ defer file.Close()
8282+8383+ // Read the file content
8484+ f.RawContent, err = io.ReadAll(file)
8585+ if err != nil {
8686+ return fmt.Errorf("failed to read file %s: %w", f.ObsidianPath, err)
8787+ }
8888+8989+ // Parse the Markdown content
9090+ f.ObsidianMarkdown = parseMarkdown(f.RawContent)
9191+ f.HugoMarkdown = parseMarkdown(f.RawContent)
9292+ ensureBasicFrontmatter(f)
9393+9494+ return nil
9595+}
9696+5397func getFileType(path string) string {
5498 if strings.HasSuffix(path, "/") {
5599 return "Directory"
···90134 return "Unknown"
91135 }
92136}
137137+138138+func parseMarkdown(src []byte) *ast.Document {
139139+ md := goldmark.New(
140140+ goldmark.WithExtensions(
141141+ &frontmatter.Extender{
142142+ Mode: frontmatter.SetMetadata,
143143+ },
144144+ &wikilink.Extender{
145145+ // Resolver: ResolveWikilink,
146146+ }),
147147+ )
148148+149149+ root := md.Parser().Parse(text.NewReader(src))
150150+ doc := root.OwnerDocument()
151151+152152+ return doc
153153+}
154154+155155+type myresolver struct{}
156156+157157+var _html = []byte(".html")
158158+var _hash = []byte{'#'}
159159+160160+func (myresolver) ResolveWikilink(n *wikilink.Node) ([]byte, error) {
161161+ dest := make([]byte, len(n.Target)+len(_html)+len(_hash)+len(n.Fragment))
162162+ var i int
163163+ if len(n.Target) > 0 {
164164+ i += copy(dest, n.Target)
165165+ if filepath.Ext(string(n.Target)) == "" {
166166+ i += copy(dest[i:], _html)
167167+ }
168168+ }
169169+ if len(n.Fragment) > 0 {
170170+ i += copy(dest[i:], _hash)
171171+ i += copy(dest[i:], n.Fragment)
172172+ }
173173+ return dest[:i], nil
174174+}
175175+176176+func ensureBasicFrontmatter(f *File) {
177177+ meta := f.HugoMarkdown.Meta()
178178+179179+ // Minimum set of frontmatter fields:
180180+ // ---
181181+ // title: Example Title
182182+ // draft: false
183183+ // tags:
184184+ // - example-tag
185185+ // ---
186186+187187+ // if title set title to filename
188188+ if meta["title"] == nil {
189189+ meta["title"] = filepath.Base(f.Name)
190190+ }
191191+192192+ // if a file has a private flag, set treat it accordingly
193193+ if meta["private"] != nil {
194194+ f.Private = meta["private"].(bool)
195195+ }
196196+197197+ if meta["draft"] == nil {
198198+ if f.Private {
199199+ meta["draft"] = true
200200+ } else {
201201+ meta["draft"] = false
202202+ }
203203+ }
204204+205205+ // if tags are empty, set at least one tag "note"
206206+ if meta["tags"] == nil {
207207+ meta["tags"] = []string{"note"}
208208+ }
209209+210210+ f.HugoMarkdown.SetMeta(meta)
211211+}
212212+213213+func GenerateHugoDirectory(files []*File) error {
214214+215215+ for _, f := range files {
216216+ if f.Private {
217217+ continue
218218+ }
219219+ if err := WriteHugoFile(f); err != nil {
220220+ return err
221221+ }
222222+ }
223223+224224+ return nil
225225+}
226226+227227+// write a function that creates a new file based on a given input path
228228+func WriteHugoFile(f *File) error {
229229+230230+ // Create the file with the extracted name
231231+ file, err := os.Create(f.Name)
232232+ if err != nil {
233233+ return fmt.Errorf("failed to create file %s: %w", f.Name, err)
234234+ }
235235+ defer file.Close()
236236+237237+ return nil
238238+}
239239+240240+func (f *File) containsLink() bool {
241241+ // Match all patterns like [[.*?]], including those preceded by "!"
242242+ re := regexp.MustCompile(`\[\[.*?\]\]`)
243243+244244+ matches := re.FindAllIndex(f.RawContent, -1)
245245+ for _, match := range matches {
246246+ // Check if the match is not preceded by "!"
247247+ if match[0] == 0 || f.RawContent[match[0]-1] != '!' {
248248+ fmt.Println(string(f.RawContent))
249249+ return true
250250+ }
251251+ }
252252+253253+ return false
254254+}
255255+256256+// extractLinks extracts all valid links (e.g., [[some text]]) from the content.
257257+func extractLinks(content []byte) []string {
258258+ re := regexp.MustCompile(`\[\[(.*?)\]\]`)
259259+ codeBlockRe := regexp.MustCompile("(?s)```.*?```") // Matches Markdown code blocks
260260+261261+ // Remove all code blocks from the content
262262+ cleanedContent := codeBlockRe.ReplaceAll(content, []byte{})
263263+264264+ matches := re.FindAllSubmatchIndex(cleanedContent, -1)
265265+266266+ uniqueLinks := make(map[string]struct{})
267267+ for _, match := range matches {
268268+ if len(match) >= 4 {
269269+ start := match[0]
270270+271271+ // Check if the match is not preceded by "!"
272272+ if start == 0 || cleanedContent[start-1] != '!' {
273273+ link := string(cleanedContent[match[2]:match[3]])
274274+ uniqueLinks[link] = struct{}{}
275275+ }
276276+ }
277277+ }
278278+279279+ // Convert the map keys to a slice
280280+ var links []string
281281+ for link := range uniqueLinks {
282282+ links = append(links, link)
283283+ }
284284+285285+ return links
286286+}
+83
filelist.go
···11+package main
22+33+import (
44+ "encoding/json"
55+ "path/filepath"
66+ "strings"
77+)
88+99+type ObsidianVaultFileList []*File
1010+1111+func InitObsidianVaultFileList(paths []string) ObsidianVaultFileList {
1212+ return PathListToFileList(paths)
1313+}
1414+1515+func PathListToFileList(paths []string) ObsidianVaultFileList {
1616+ var fileList ObsidianVaultFileList
1717+ for _, path := range paths {
1818+ fileList = append(fileList, InitNewFile(path, path, o.HugoContentDirectory))
1919+ }
2020+ return fileList
2121+}
2222+2323+// Returns amount of files in the list
2424+func (fl *ObsidianVaultFileList) Len() int {
2525+ return len(*fl)
2626+}
2727+2828+func (fl *ObsidianVaultFileList) GetIdByObsidianPath(path string) string {
2929+ for _, file := range *fl {
3030+ if file.ObsidianPath == path {
3131+ return file.Id
3232+ }
3333+ }
3434+ return ""
3535+}
3636+3737+func (fl *ObsidianVaultFileList) GetIdByObsidianFileName(name string) string {
3838+ for _, file := range *fl {
3939+ if file.Name == name {
4040+ return file.Id
4141+ }
4242+ }
4343+ return ""
4444+}
4545+4646+type ObsidianVaultGraph struct {
4747+ Nodes ObsidianVaultFileList
4848+ Edges map[string][]string
4949+}
5050+5151+// MarshalObsidianVaultGraph marshals the ObsidianVaultGraph to JSON
5252+func (graph ObsidianVaultGraph) MarshalObsidianVaultGraph() ([]byte, error) {
5353+ return json.Marshal(graph)
5454+}
5555+5656+func InitObsidianVaultGraph() ObsidianVaultGraph {
5757+ return ObsidianVaultGraph{
5858+ Nodes: ObsidianVaultFileList{},
5959+ Edges: make(map[string][]string),
6060+ }
6161+}
6262+6363+// Filter out files that are not Markdown files
6464+func (fl *ObsidianVaultFileList) FilterMarkdown() *ObsidianVaultFileList {
6565+ var filteredFiles ObsidianVaultFileList
6666+ for _, file := range *fl {
6767+ if filepath.Ext(file.Type) == "Markdown" {
6868+ filteredFiles = append(filteredFiles, file)
6969+ }
7070+ }
7171+ return &filteredFiles
7272+}
7373+7474+// Filter out files that are not Markdown files
7575+func (fl *ObsidianVaultFileList) FilterObsidian() *ObsidianVaultFileList {
7676+ var filteredFiles ObsidianVaultFileList
7777+ for _, file := range filteredFiles {
7878+ if !strings.HasPrefix(file.ObsidianPath, ".obsidian") {
7979+ filteredFiles = append(filteredFiles, file)
8080+ }
8181+ }
8282+ return &filteredFiles
8383+}