summaryrefslogtreecommitdiffci
path: root/internal
diff options
from:
to:
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/b2/client.go121
-rw-r--r--internal/frontmatter/parser.go37
-rw-r--r--internal/lightgallery/block.go26
-rw-r--r--internal/lightgallery/extension.go28
-rw-r--r--internal/lightgallery/html_renderer.go137
-rw-r--r--internal/lightgallery/parser.go77
-rw-r--r--internal/tailwind/extension.go21
-rw-r--r--internal/tailwind/transformer.go72
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, "&", "&amp;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ s = strings.ReplaceAll(s, "\"", "&quot;")
+ s = strings.ReplaceAll(s, "'", "&#39;")
+ 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
+ })
+}