diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/b2/client.go | 121 | ||||
| -rw-r--r-- | internal/frontmatter/parser.go | 37 | ||||
| -rw-r--r-- | internal/lightgallery/block.go | 26 | ||||
| -rw-r--r-- | internal/lightgallery/extension.go | 28 | ||||
| -rw-r--r-- | internal/lightgallery/html_renderer.go | 137 | ||||
| -rw-r--r-- | internal/lightgallery/parser.go | 77 | ||||
| -rw-r--r-- | internal/tailwind/extension.go | 21 | ||||
| -rw-r--r-- | internal/tailwind/transformer.go | 72 |
8 files changed, 519 insertions, 0 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go new file mode 100644 index 0000000..f2b6cad --- /dev/null +++ b/internal/b2/client.go @@ -0,0 +1,121 @@ +package b2 + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/Backblaze/blazer/b2" + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" +) + +type B2Client struct { + prefix string + bucket *b2.Bucket + b2cl *b2.Client +} + +func NewB2Client(cfg *config.B2Config) (*B2Client, error) { + b2cl, err := b2.NewClient(context.Background(), cfg.KeyID, cfg.ApplicationKey) + if err != nil { + return nil, err + } + + bucket, err := b2cl.Bucket(context.Background(), cfg.BucketName) + if err != nil { + return nil, err + } + + return &B2Client{b2cl: b2cl, bucket: bucket, prefix: cfg.Prefix}, nil +} + +type BlogPage struct { + Link string + Metadata *frontmatter.Metadata +} + +func (c *B2Client) Scan() ([]*BlogPage, error) { + filePaths := []*BlogPage{} + + iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix)) + + for iter.Next() { + obj := iter.Object() + if obj == nil { + return nil, fmt.Errorf("failed to reference object in B2 bucket") + } + + attrs, err := obj.Attrs(context.Background()) + if err != nil { + return nil, fmt.Errorf("get attributes for object: %w", err) + } + + if attrs.Status != b2.Uploaded { + continue + } + + if !strings.Contains(attrs.ContentType, "text/markdown") { + continue + } + + if _, ok := attrs.Info["title"]; !ok { + continue + } + + publishedTime, err := time.Parse(time.RFC3339, attrs.Info["published-time"]) + if err != nil { + return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) + } + + linkParts := strings.Split(obj.Name(), ".") + + filePaths = append(filePaths, &BlogPage{ + Link: strings.Join(linkParts[:len(linkParts)-1], "."), + Metadata: &frontmatter.Metadata{ + Title: attrs.Info["title"], + ShortDescription: attrs.Info["short-description"], + ActionDate: attrs.Info["action-date"], + PublishedTime: publishedTime, + Thumbnail: attrs.Info["thumbnail"], + Tags: strings.Split(attrs.Info["tags"], ","), + }, + }) + } + + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("iterate over B2 objects: %w", err) + } + + return filePaths, nil +} + +func (c *B2Client) ReadAll(path string) ([]byte, error) { + obj := c.bucket.Object(c.prefix + path) + if obj == nil { + return nil, fmt.Errorf("failed to reference object in B2 bucket") + } + attrs, err := obj.Attrs(context.Background()) + if err != nil { + return nil, fmt.Errorf("error getting attributes of an object: %w", err) + } + + content := make([]byte, attrs.Size) + reader := obj.NewReader(context.Background()) + + if _, err = reader.Read(content); err != nil { + return nil, fmt.Errorf("failed to read file content: %w", err) + } + + return content, nil +} + +func (c *B2Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) { + contentBytes, err := c.ReadAll(path) + if err != nil { + return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err) + } + + return frontmatter.ParseFrontmatter(contentBytes) +} diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go new file mode 100644 index 0000000..6cc3ee8 --- /dev/null +++ b/internal/frontmatter/parser.go @@ -0,0 +1,37 @@ +package frontmatter + +import ( + "fmt" + "regexp" + "time" + + "gopkg.in/yaml.v3" +) + +type Metadata struct { + Title string `yaml:"title"` + ShortDescription string `yaml:"shortDescription"` + ActionDate string `yaml:"actionDate"` + PublishedTime time.Time `yaml:"publishedTime"` + Thumbnail string `yaml:"thumbnail"` + Tags []string `yaml:"tags"` +} + +func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { + frontmatterRegex := regexp.MustCompile(`^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$`) + matches := frontmatterRegex.FindSubmatch(content) + + if len(matches) != 3 { + return nil, content, nil + } + + yamlContent := matches[1] + markdownContent := matches[2] + + metadata = &Metadata{} + if err := yaml.Unmarshal([]byte(yamlContent), &metadata); err != nil { + return nil, nil, fmt.Errorf("failed to parse YAML frontmatter: %w", err) + } + + return metadata, markdownContent, nil +} diff --git a/internal/lightgallery/block.go b/internal/lightgallery/block.go new file mode 100644 index 0000000..dade2ea --- /dev/null +++ b/internal/lightgallery/block.go @@ -0,0 +1,26 @@ +package lightgallery + +import "github.com/yuin/goldmark/ast" + +// LightGalleryBlock represents a light gallery block in the AST +type LightGalleryBlock struct { + ast.BaseBlock + Images []LightGalleryImage +} + +type LightGalleryImage struct { + URL string + Caption string +} + +var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock") + +// Dump implements ast.Node.Dump +func (n *LightGalleryBlock) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// Kind implements ast.Node.Kind +func (n *LightGalleryBlock) Kind() ast.NodeKind { + return KindLightGalleryBlock +} diff --git a/internal/lightgallery/extension.go b/internal/lightgallery/extension.go new file mode 100644 index 0000000..761dc05 --- /dev/null +++ b/internal/lightgallery/extension.go @@ -0,0 +1,28 @@ +package lightgallery + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// Extension that combines parser and renderer +type LightGalleryExtension struct{} + +func NewLightGalleryExtension() goldmark.Extender { + return &LightGalleryExtension{} +} + +func (e *LightGalleryExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithBlockParsers( + util.Prioritized(NewLightGalleryParser(), 500), + ), + ) + m.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(NewLightGalleryHTMLRenderer(), 500), + ), + ) +} diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go new file mode 100644 index 0000000..fb65b06 --- /dev/null +++ b/internal/lightgallery/html_renderer.go @@ -0,0 +1,137 @@ +package lightgallery + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type LightGalleryHTMLRenderer struct { + html.Config +} + +func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &LightGalleryHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *LightGalleryHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindLightGalleryBlock, r.renderLightGallery) +} + +func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + gallery := n.(*LightGalleryBlock) + + if len(gallery.Images) == 0 { + return ast.WalkContinue, nil + } + + galleryID := generateDivId(4) + + w.WriteString(fmt.Sprintf(` +<div class="justify-content-center display-block m-2"> + <hr class="border-t-4 border-dotted border-main-dark mt-vmin0-8 mb-vmin0-8 w-p80 ml-auto mr-auto"> + <div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div> + <hr class="border-t-4 border-dotted border-main-dark mt-vmin0-8 mb-vmin0-8 w-p80 ml-auto mr-auto"> +</div>`, galleryID)) + + var dynamicElements []string + for _, img := range gallery.Images { + imageUrlSegments := strings.Split(img.URL, ".") + imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") + imageNameParts := strings.Split(imageUrlWithoutExt, "-") + + dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1]) + dynamicElements = append(dynamicElements, fmt.Sprintf(`{ + src: + "https://f003.backblazeb2.com/file/sayana-photos/full/%s", + downloadUrl: + "https://f003.backblazeb2.com/file/sayana-photos/full/%s", + alt: "%s", + sources: [{ + srcset: "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp", + media: "(max-width: 800px)" + }], + thumb: + "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp", + subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions"> + <p class="grow text-vmax1 text-left lh-0-9 font-spectral text-main-dark">%s</p> + <p class="text-vmax1 text-right lh-0-9 font-spectral text-secondary">%s</p> + </div>`+"`"+` + }`, img.URL, img.URL, img.Caption, imageUrlWithoutExt, imageUrlWithoutExt, img.Caption, dayDate.Format("2006-01-02 15:04:05 -07:00"))) + } + + w.WriteString(fmt.Sprintf(` +<script> +function createLightLibrary%s() { + const $lgContainer = document.getElementById('lg-%s'); + const inlineGallery = lightGallery($lgContainer, { + container: $lgContainer, + dynamic: true, + dynamicEl: [%s], + width: "100%%", + height: "50vmin", + hash: false, + closable: false, + showMaximizeIcon: true, + appendSubHtmlTo: ".lg-sub-html", + isMobile: () => { return false; }, + slideDelay: 0, + plugins: [lgZoom, lgThumbnail], + thumbWidth: 160, + thumbHeight: "10vmin", + thumbMargin: 4 + }); + + setTimeout(() => { + inlineGallery.openGallery(); + }, 200); +} + +document.addEventListener('DOMContentLoaded', createLightLibrary%s); + +let resizeTimer%s; +window.addEventListener("resize", () => { + clearTimeout(resizeTimer%s); + resizeTimer%s = setTimeout(() => { + const $lgContainer = document.getElementById('lg-%s'); + $lgContainer.innerHTML = ""; + createLightLibrary%s(); + }, 1000); +}); +</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) + } + + return ast.WalkContinue, nil +} + +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +func generateDivId(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz" + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} diff --git a/internal/lightgallery/parser.go b/internal/lightgallery/parser.go new file mode 100644 index 0000000..0ce209c --- /dev/null +++ b/internal/lightgallery/parser.go @@ -0,0 +1,77 @@ +package lightgallery + +import ( + "bytes" + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +type LightGalleryParser struct{} + +func NewLightGalleryParser() parser.BlockParser { + return &LightGalleryParser{} +} + +func (p *LightGalleryParser) Trigger() []byte { + return []byte{'{'} +} + +func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + line, _ := reader.PeekLine() + + if !bytes.HasPrefix(line, []byte("{Gallery}")) { + return nil, parser.NoChildren + } + + trimmed := bytes.TrimSpace(line) + if !bytes.Equal(trimmed, []byte("{Gallery}")) { + return nil, parser.NoChildren + } + + return &LightGalleryBlock{}, parser.NoChildren +} + +func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + line, segment := reader.PeekLine() + if len(line) == 0 || segment.Len() == 0 { + return parser.Close + } + + trimmed := bytes.TrimSpace(line) + if bytes.Equal(trimmed, []byte("{Gallery}")) { + reader.AdvanceLine() + return parser.Close + } + + gallery := node.(*LightGalleryBlock) + lineStr := string(trimmed) + + parts := strings.SplitN(lineStr, "|", 2) + url := strings.TrimSpace(parts[0]) + caption := "" + + if len(parts) > 1 { + caption = strings.TrimSpace(parts[1]) + } + + gallery.Images = append(gallery.Images, LightGalleryImage{ + URL: url, + Caption: caption, + }) + + return parser.Continue | parser.NoChildren +} + +func (p *LightGalleryParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { +} + +func (p *LightGalleryParser) CanInterruptParagraph() bool { + return true +} + +func (p *LightGalleryParser) CanAcceptIndentedLine() bool { + return false +} diff --git a/internal/tailwind/extension.go b/internal/tailwind/extension.go new file mode 100644 index 0000000..c3254cc --- /dev/null +++ b/internal/tailwind/extension.go @@ -0,0 +1,21 @@ +package tailwind + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/util" +) + +type TailwindExtension struct{} + +func NewTailwindExtension() goldmark.Extender { + return &TailwindExtension{} +} + +func (e *TailwindExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithASTTransformers( + util.Prioritized(&TailwindTransformer{}, 500), + ), + ) +} diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go new file mode 100644 index 0000000..51a33bc --- /dev/null +++ b/internal/tailwind/transformer.go @@ -0,0 +1,72 @@ +package tailwind + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +type TailwindTransformer struct{} + +func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch node := n.(type) { + case *ast.Heading: + classes := map[int]string{ + 1: "font-patua text-vmax2-5 font-bold text-main-dark mb-vmin1-2 ls-vmin0-04", + 2: "font-spectral text-vmax2 font-bold text-main-dark mb-vmin0-8 ls-vmin0-04", + 3: "font-spectral text-vmax1-5 font-medium text-main-dark mb-vmin0-6 ls-vmin0-04", + 4: "font-spectral text-vmax1-2 font-medium text-main-medium mb-vmin0-4 ls-vmin0-04", + 5: "font-spectral text-vmax1 font-medium text-main-medium mb-vmin0-4 italic ls-vmin0-04", + 6: "font-spectral text-vmax1 font-medium text-secondary mb-vmin0-4", + } + if class, ok := classes[node.Level]; ok { + node.SetAttribute([]byte("class"), []byte(class)) + } + + case *ast.Paragraph: + node.SetAttribute([]byte("class"), []byte("text-vmax1 font-spectral lh-2 ls-vmin0-04 timl-vmax2 mb-vmin1-6")) + + case *ast.List: + if node.IsOrdered() { + node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-vmin0-8 mb-vmin1-6 pl-vmax2")) + } else { + node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-vmin0-8 mb-vmin1-6 pl-vmax2")) + } + + case *ast.ListItem: + node.SetAttribute([]byte("class"), []byte("text-vmax1 font-spectral lh-2 ls-vmin0-04")) + + case *ast.Blockquote: + node.SetAttribute([]byte("class"), []byte("border-l-vmin0-4 border-main-medium bg-background-dark p-vmin0-8 mb-vmin0-8 italic")) + + case *ast.CodeSpan: + node.SetAttribute([]byte("class"), []byte("bg-background-dark")) + + case *ast.CodeBlock, *ast.FencedCodeBlock: + node.SetAttribute([]byte("class"), []byte("bg-background-dark p-vmin0-8 rounded-lg overflow-x-auto mb-vmin1-6")) + + case *ast.Link: + node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline")) + + case *ast.Image: + node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-vmin1-6")) + + case *ast.Emphasis: + switch node.Level { + case 1: + node.SetAttribute([]byte("class"), []byte("italic")) + case 2: + node.SetAttribute([]byte("class"), []byte("font-bold")) + case 3: + node.SetAttribute([]byte("class"), []byte("italic font-bold")) + } + } + + return ast.WalkContinue, nil + }) +} |