calepin 0.0.19

A Rust CLI for preprocessing Typst documents with executable code chunks
#import "../core/assets.typ": _image-meta-entry, _resolve-asset-href
#import "../core/css.typ": _css-size
#import "../core/target.typ": _is-html, _is-query

#let _assets-loaded = state("calepin-elements-gallery-assets", false)

#let _asset_once() = context {
  if _assets-loaded.get() {
    none
  } else {
    _assets-loaded.update(_ => true)
    [
      #std.html.elem("link", "", attrs: (
        rel: "stylesheet",
        href: "https://unpkg.com/photoswipe@5.4.4/dist/photoswipe.css",
      ))
      #std.html.elem("style", "
        .calepin-elements-gallery {
          list-style: none;
          padding: 0;
        }

        .calepin-elements-gallery__item {
          display: block;
          break-inside: avoid;
          margin-block-end: var(--calepin-elements-gallery-gap, 0.75em);
          border-radius: 0.5rem;
          border: 1px solid var(--pico-muted-border-color);
          overflow: hidden;
          position: relative;
          isolation: isolate;
          text-decoration: none;
          background: var(--pico-background-color);
          box-shadow: 0 0.5rem 1.25rem rgba(15, 23, 42, 0.09);
          color: var(--pico-color);
        }

        .calepin-elements-gallery__image {
          display: block;
          width: 100%;
          height: auto;
        }

        .calepin-elements-gallery__item::after {
          content: \"\";
          pointer-events: none;
          position: absolute;
          inset: 0;
          background: linear-gradient(to top, rgba(15, 23, 42, 0.55), transparent 45%);
          opacity: 0;
          transition: opacity 180ms ease;
        }

        .calepin-elements-gallery__item:hover::after,
        .calepin-elements-gallery__item:focus-visible::after {
          opacity: 1;
        }

        .calepin-elements-gallery__caption {
          position: absolute;
          left: 0.6rem;
          right: 0.6rem;
          bottom: 0.5rem;
          color: white;
          z-index: 1;
          font-size: 0.86rem;
          line-height: 1.2;
          display: none;
        }

        .calepin-elements-gallery__item:hover .calepin-elements-gallery__caption,
        .calepin-elements-gallery__item:focus-visible .calepin-elements-gallery__caption {
          display: block;
        }
      ")
      #std.html.elem("script", "
        import PhotoSwipeLightbox from 'https://unpkg.com/photoswipe@5.4.4/dist/photoswipe-lightbox.esm.js';

        document.addEventListener('DOMContentLoaded', () => {
          const lightbox = new PhotoSwipeLightbox({
            gallery: '.calepin-elements-gallery--lightbox',
            children: 'a',
            pswpModule: () => import('https://unpkg.com/photoswipe@5.4.4/dist/photoswipe.esm.js'),
          });
          lightbox.init();
        });
      ", attrs: (type: "module"))
    ]
  }
}

#let _dim(value) = {
  if (type(value) == int or type(value) == float) and value > 0 {
    str(value)
  } else if type(value) == str and value != "" {
    value
  } else {
    none
  }
}

#let _entry(item) = {
  let src = none
  let alt = ""
  let caption = none
  let width = none
  let height = none

  if type(item) == dictionary {
    src = item.at("src", default: item.at("path", default: none))
    alt = item.at("alt", default: "")
    caption = item.at("caption", default: none)
    width = item.at("width", default: none)
    height = item.at("height", default: none)
  } else if type(item) == array {
    src = item.at(0, default: none)
    alt = item.at(1, default: "")
    caption = item.at(2, default: none)
    width = item.at(3, default: none)
    height = item.at(4, default: none)
  }

  if type(src) != str {
    return none
  }

  let meta = _image-meta-entry(src)
  (
    src: src,
    href: _resolve-asset-href(src),
    alt: alt,
    caption: caption,
    width: _dim(if width == none and meta != none { meta.at("width", default: none) } else { width }),
    height: _dim(if height == none and meta != none { meta.at("height", default: none) } else { height }),
  )
}

#let _entries(items) = {
  let raw = if type(items) == array { items } else { (items,) }
  let out = ()
  for item in raw {
    if type(item) == array and (type(item.at(0, default: none)) == array or type(item.at(0, default: none)) == dictionary) {
      for nested in item {
        let entry = _entry(nested)
        if entry != none { out.push(entry) }
      }
    } else {
      let entry = _entry(item)
      if entry != none { out.push(entry) }
    }
  }
  out
}

#let _style(columns, gap, max-width) = {
  let gap = _css-size(gap)
  let width = _css-size(max-width)
  let style = if type(columns) == int {
    let columns = if columns <= 0 { 3 } else { columns }
    "column-count: " + str(columns) + "; "
  } else if type(columns) == str {
    "display: grid; align-items: start; grid-template-columns: " + columns + "; "
  } else {
    "column-count: 3; "
  }
  if gap != none {
    style += if type(columns) == int {
      "column-gap: " + gap + "; --calepin-elements-gallery-gap: " + gap + "; "
    } else {
      "gap: " + gap + "; "
    }
  }
  if width != none {
    style += "max-width: " + width + "; "
  }
  style + "margin: 1rem auto;"
}

#let _anchor(entry, show-captions) = {
  let attrs = (
    href: entry.href,
    class: "calepin-elements-gallery__item",
    "data-calepin-elements-photo": "",
    "aria-label": "Open image: " + entry.alt,
  )
  if entry.width != none {
    attrs.insert("data-pswp-width", entry.width)
  }
  if entry.height != none {
    attrs.insert("data-pswp-height", entry.height)
  }
  std.html.elem("a", attrs: attrs)[
    #std.html.elem("img", attrs: (
      src: entry.href,
      alt: entry.alt,
      class: "calepin-elements-gallery__image",
      loading: "lazy",
      decoding: "async",
    ))
    #if show-captions and entry.caption != none {
      std.html.elem("span", attrs: (class: "calepin-elements-gallery__caption"))[#entry.caption]
    }
  ]
}

#let gallery(items, columns: 3, gap: 0.75em, max-width: 42em, show-captions: true) = {
  if _is-query() {
    return none
  }

  let entries = _entries(items)
  if entries.len() == 0 {
    return if _is-html() { text[No images yet.] } else { none }
  }

  if _is-html() {
    return [
      #_asset_once()
      #std.html.elem("div", attrs: (
        class: "calepin-elements-gallery calepin-elements-gallery--lightbox",
        data-calepin-elements-gallery: "true",
        style: _style(columns, gap, max-width),
      ))[
        #for entry in entries {
          _anchor(entry, show-captions)
        }
      ]
    ]
  }

  let fallback-columns = if type(columns) == int and columns > 0 { columns } else { 3 }
  grid(
    columns: fallback-columns,
    gutter: gap,
    ..entries.map(entry => {
      let img = image(entry.src, width: 100%)
      if show-captions and entry.caption != none {
        let caption = entry.caption
        [#img #v(0.3em) #text(size: 0.8em, fill: luma(38%))[#caption]]
      } else {
        img
      }
    }),
  )
}