-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcommandBuild.go
457 lines (387 loc) · 13.9 KB
/
commandBuild.go
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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
package main
import (
"bytes"
"fmt"
"html/template"
"os"
"path/filepath"
"sort"
"strings"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/parser"
)
// Controls the build command
type Builder struct {
rootPath string
contentDir string
outputDir string
templateDir string
templates *template.Template
dirsMap map[string]DirectoryInfo
}
// Defining a global varaiable for build command
var buildCommand Builder
// Holds information about a directory during processing
// Keyed by the full path to the directory
type DirectoryInfo struct {
Path string // The relative path to the content directory
NumFiles int // The number of files in the directory
HasIndex bool // Whether the directory has an index file
Files []FileInfo // A slice of FileInfo structs for each file in the directory
}
// Holds information about a file during processing
// Keyed by the full path to the file
type FileInfo struct {
Name string // The name of the file (no extension)
Path string // The relative path to the file relative to the content directory
OutputPath string // The path and file name for the output file
FileType string // The type of file (e.g. "md", "html")
ContentType string // The type of content (e.g. "page", "post", "project")
MetaData map[string]interface{} // Metadata extracted from the file
Content template.HTML // The content of the file
}
// PageData holds data to pass into templates
// This is used to build the full page content
type PageData struct {
SiteName string // The name of the site
Logo template.HTML // The site logo
Title string // The title of the page
Content template.HTML // The content of the page
Metadata map[string]interface{} // Metadata for the page
}
// ********** Public Command Methods **********
// Generates the site from the content and template files
func (b *Builder) BuildSite() error {
// Initialize the templates
err := b.initTemplates()
if err != nil {
return err
}
// Generate the files and dirs for the content directory
dirsMap, err := b.walkContentDir()
if err != nil {
logger.Error("Error walking content directory: ", err)
return err
}
// Reset the output directory before writing new files
// @TODO: refactor to only delete files and directories that need to be deleted
b.resetOutputDirectory()
// Render the files
// @TODO: refactor to only render files that need to be rendered
err = b.renderFiles(dirsMap)
if err != nil {
return err
}
// Build index files
err = b.buildIndexFiles(dirsMap)
if err != nil {
return err
}
return nil
}
// Set the root path and comomon directories for commands
func (b *Builder) SetRootPath(path string) {
if path == "" {
path = "."
}
b.rootPath = path
b.contentDir = filepath.Join(path, config.ContentDirectory)
b.outputDir = filepath.Join(path, config.OutputDirectory)
b.templateDir = filepath.Join(path, "template")
}
// ********** Private Command Methods **********
// Walk the content directory and build the files and dirs maps
// Returns a map of files and a map of directories
func (b *Builder) walkContentDir() (map[string]DirectoryInfo, error) {
// Create maps to hold the files and directories
b.dirsMap = make(map[string]DirectoryInfo)
contentPath := filepath.Join(b.rootPath, b.contentDir)
logger.Detail("Walking content directory: " + contentPath)
// Walk the content directory and build the files and dirs maps
// We update both maps as we walk the directory one time
err := filepath.Walk(contentPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("error accessing path %q: %v", path, err)
}
logger.Detail("Processing path: " + path)
// Check if this is a directory or a file
isDir, err := filesystem.IsDir(path)
if err != nil {
return err
}
if isDir {
// Process the directory
if err := b.processDir(path); err != nil {
return err
}
} else {
// Process the file
if err := b.processFile(path); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err // More descriptive error handling
}
return b.dirsMap, nil
}
// Process a single directory, updating the directory information map.
func (b *Builder) processDir(path string) error {
// Check if the directory is already in the map
if _, exists := b.dirsMap[path]; !exists {
// Get the relative path from the root directory using filepath.Rel
relPath, err := filepath.Rel(b.contentDir, path)
if err != nil {
return fmt.Errorf("error getting relative path: %s, error: %v", path, err)
}
// Add the directory to the map
b.dirsMap[path] = DirectoryInfo{
Path: relPath,
NumFiles: 0,
HasIndex: false,
Files: []FileInfo{},
}
}
return nil
}
// processFile processes a single file, updating the directory information map.
func (b *Builder) processFile(path string) error {
relPath, dir, contentType, fileName, fileType, err := filesystem.GetFileInfo(b.contentDir, path)
if err != nil {
return fmt.Errorf("error getting file info for %q: %v", path, err)
}
if contentType == "" {
contentType = "page"
}
// Process the markdown file to extract HTML content and metadata
renderedContent, metaData, err := b.processMarkdown(path)
if err != nil {
return fmt.Errorf("error processing markdown for %q: %v", relPath, err)
}
// Create the FileInfo struct
fileInfo := FileInfo{
Name: fileName,
Path: relPath,
OutputPath: "/" + filepath.Join(dir, fileName+".html"),
FileType: fileType,
ContentType: contentType,
MetaData: metaData,
Content: template.HTML(renderedContent),
}
// Update the directory info with the new file
dirKey := filepath.Join(b.contentDir, dir)
// Process the directory
// @TODO: we are processing the directory twice - once here and once in processDir
// This is ok for now since we do a check against the map, but can we set this up better?
if err := b.processDir(dirKey); err != nil {
return err
}
// Load the directory object from the map
dirInfo, exists := (b.dirsMap)[dirKey]
if !exists {
return fmt.Errorf("directory %q not found in directory map", dir)
}
// Update the directory object with the new file
dirInfo.NumFiles++
dirInfo.Files = append(dirInfo.Files, fileInfo)
if fileName == "index" {
dirInfo.HasIndex = true
}
(b.dirsMap)[dirKey] = dirInfo
return nil
}
// Render the files and write them to the output directory
func (b *Builder) renderFiles(dirsMap map[string]DirectoryInfo) error {
// Loop through each directory in dirsMap
for _, dirInfo := range dirsMap {
// Loop through each file in the directory
for _, file := range dirInfo.Files {
// Remove the "content/" prefix from the file path so we can replace
// it with the output directory
trimmedPath := strings.TrimPrefix(file.Path, "content/")
outputPath := filepath.Join(b.outputDir, trimmedPath)
outputPath = strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".html"
// Write the HTML content to the output directory
if err := b.renderAndWriteFile(outputPath, file); err != nil {
return err
}
}
}
return nil
}
// Process the markdown file and extract metadata
// @TODO: we have to use this twice - once for markdown and once for HTML, so lets memoize it
func (b *Builder) processMarkdown(filePath string) (htmlContent string, metaData map[string]interface{}, err error) {
// @TODO: see if we need to adjust this for HTML files
// @TODO: for html files - what about the metadata?
// Create a new markdown parser with the meta extension
markdown := goldmark.New(
goldmark.WithExtensions(
meta.Meta,
),
)
// Read the MD file and process it
content, err := filesystem.Read(filePath)
if err != nil {
return "", nil, fmt.Errorf("error reading markdown file %s: %w", filePath, err)
}
// Get the metadata from the markdown file
var buf bytes.Buffer
context := parser.NewContext()
if err := markdown.Convert([]byte(content), &buf, parser.WithContext(context)); err != nil {
return "", nil, fmt.Errorf("error converting markdown to HTML: %w", err)
}
// Extract metadata with type assertion
metaDataMap := meta.Get(context)
if metaDataMap == nil {
// Handle the case where metadata is not present or not in the expected format
metaDataMap = make(map[string]interface{}) // Initialize as empty if not present
}
htmlContent = buf.String()
return htmlContent, metaDataMap, nil
}
// Render the HTML content with the template and write to the output directory
func (b *Builder) renderAndWriteFile(outputPath string, file FileInfo) error {
// Extract the template name from outputPath or set a default
templateFile := file.MetaData["template"].(string)
if templateFile == "" {
templateFile = "default.tmpl"
}
// Process the MD content with the template
// This will be used to process the full page from the template
templateContent, err := b.getTemplateContent(file, templateFile)
if err != nil {
return err
}
// Build PageData
pageData := PageData{
SiteName: config.Sitename,
Logo: logo50,
Title: file.MetaData["title"].(string),
Content: template.HTML(templateContent),
Metadata: file.MetaData,
}
// Execute the full page template with the built PageData
var output bytes.Buffer
if err := b.templates.ExecuteTemplate(&output, "fullpage.tmpl", pageData); err != nil {
return err
}
// Use filesystem.Create to write the output to the specified path
// Assuming filesystem.Create takes a string path and byte slice as content
return filesystem.Create(outputPath, output.String())
}
func (b *Builder) buildIndexFiles(dirsMap map[string]DirectoryInfo) error {
logger.Info("Building index files")
// Loop through each directory in dirsMap
for contentPath, dirInfo := range dirsMap {
logger.Detail("Processing directory: " + contentPath)
// If the directory does not have an index file, create one
if !dirInfo.HasIndex && dirInfo.NumFiles > 0 {
// Get the content type from the first file in the directory
contentType := dirInfo.Files[0].ContentType
if contentType == "content" {
continue
}
logger.Detail("Building index file for " + contentType + "s")
// Generate the list content for the index file
var listContent bytes.Buffer
if err := b.templates.ExecuteTemplate(&listContent, "list.tmpl", dirInfo); err != nil {
return err
}
// Build PageData for the full page
pageData := PageData{
SiteName: config.Sitename,
Logo: logo50,
Title: "All " + contentType + "s",
Content: template.HTML(listContent.String()),
Metadata: dirInfo.Files[0].MetaData,
}
// Execute the full page template with the built PageData
var output bytes.Buffer
if err := b.templates.ExecuteTemplate(&output, "fullpage.tmpl", pageData); err != nil {
logger.Error("Error executing template: ", err)
return err
}
// Remove the "content/" prefix from the file path so we can replace
// it with the output directory
trimmedPath := strings.TrimPrefix(dirInfo.Path, "content/")
outputPath := filepath.Join(b.outputDir, trimmedPath, "index.html")
logger.Detail("Writing index file to " + outputPath)
if err := filesystem.Create(outputPath, output.String()); err != nil {
return err
}
}
}
return nil
}
// Process the content in the pageData struct to generate templated contend
func (b *Builder) getTemplateContent(file FileInfo, templateFile string) (template.HTML, error) {
// Process the template in the metadata with the content in the metadata
var tmplContent bytes.Buffer
if err := b.templates.ExecuteTemplate(&tmplContent, templateFile, file); err != nil {
return "", err
}
return template.HTML(tmplContent.String()), nil
}
// Parse the templates and store them in a global variable
func (b *Builder) initTemplates() error {
var err error
b.templates, err = template.ParseGlob(filepath.Join(b.templateDir, "*.tmpl"))
if err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
return nil
}
// deletes all files and directories in the output folder except the assets directory.
// @TODO: this seems way too big and complex - find a better way to do this
func (b *Builder) resetOutputDirectory() error {
logger.Info("Resetting output directory")
webDir := b.outputDir
assetsDir := filepath.Join(webDir, "assets")
// Initialize a slice to keep track of directories
// NOTE: we have to do this because if we delete the directory before the file
// Then the walk will end and we will get an error trying to create the files
// Later. So we collect the directories to delete and then delete them after
var dirsToDelete []string
// First pass: Delete files and collect directories
err := filepath.Walk(webDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Normalize paths for comparison
normalizedPath := filepath.ToSlash(path)
normalizedAssetsDir := filepath.ToSlash(assetsDir)
// Skip the assets directory and its contents
if strings.HasPrefix(normalizedPath, normalizedAssetsDir+"/") {
return nil
}
if info.IsDir() {
// Collect directories for later deletion, skipping the webDir itself
if normalizedPath != filepath.ToSlash(webDir) {
dirsToDelete = append(dirsToDelete, path)
}
} else {
// Delete the file
return os.Remove(path)
}
return nil
})
if err != nil {
return err
}
// Sort directories in reverse order to ensure we delete child directories before their parents
sort.Sort(sort.Reverse(sort.StringSlice(dirsToDelete)))
// Second pass: Attempt to delete collected directories
for _, dir := range dirsToDelete {
// Attempt to remove the directory (will fail if not empty)
err := os.Remove(dir)
if err != nil && !os.IsNotExist(err) {
logger.Warn("Failed to delete directory (may not be empty): %s", dir)
// Optionally, you can log the error or handle it as needed
}
}
return nil
}