main
  1#import "@preview/bullseye:0.1.0": match-target
  2
  3#let jsx(content) = html.elem("script", attrs: ("data-jsx": content))
  4
  5#let import-counter = counter("kp-image-import")
  6
  7#let image-import() = jsx("import { Image } from 'astro:assets'")
  8
  9#let supported-exts = regex("(?i)\\.(png|jpg|jpeg|webp|gif|svg|avif|tiff)$")
 10
 11// `path("...")` -> `"..."`
 12#let resolve-path(source) = eval(repr(source).slice(5, -1))
 13
 14#let import-specifier(source) = {
 15  if type(source) == path {
 16    resolve-path(source)
 17  } else if type(source) == str and source.starts-with("/") {
 18    source
 19  } else {
 20    none
 21  }
 22}
 23
 24#let css-length(value) = {
 25  if value == auto { none } else if repr(value).ends-with(" + 0pt") {
 26    repr(value).slice(0, -6)
 27  } else if repr(value).starts-with("0% + ") {
 28    repr(value).slice(5)
 29  } else if repr(value).contains(" + ") { "calc(" + repr(value) + ")" } else {
 30    repr(value)
 31  }
 32}
 33
 34#let fit-map = ("cover": "cover", "contain": "contain", "stretch": "fill")
 35
 36#let js-value(value) = {
 37  if value == none { "null" } else if type(value) == str {
 38    repr(value)
 39  } else if type(value) == bool {
 40    if value { "true" } else { "false" }
 41  } else if type(value) in (int, float) { str(value) } else if (
 42    type(value) == array
 43  ) { "[" + value.map(js-value).join(", ") + "]" } else if (
 44    type(value) == dictionary
 45  ) {
 46    (
 47      "{"
 48        + value
 49          .pairs()
 50          .map(pair => repr(pair.first()) + ": " + js-value(pair.last()))
 51          .join(", ")
 52        + "}"
 53    )
 54  } else {
 55    panic("kp.img: unsupported astro option " + repr(value))
 56  }
 57}
 58
 59#let jsx-attributes(options) = (
 60  options
 61    .pairs()
 62    .map(pair => {
 63      let value = pair.last()
 64      if value == none { none } else {
 65        pair.first() + "={" + js-value(value) + "}"
 66      }
 67    })
 68    .filter(attr => attr != none)
 69)
 70
 71/// An image optimized by Astro's `<Image />`.
 72///
 73/// `src` must be a `path` or root-absolute string; a relative string is rejected
 74/// because it would resolve against this file, not the caller. `..args` go to
 75/// `image()`, `astro` to `<Image />`, and unsupported sources fall back to Typst.
 76///
 77/// - src (path, str):
 78/// - alt (str, none):
 79/// - width (auto, relative, length):
 80/// - height (auto, relative, length):
 81/// - fit (str):
 82/// - scaling (auto, str):
 83/// - astro (dictionary): `<Image />` props; `style` takes a CSS property
 84///   dictionary that overrides the generated defaults
 85/// -> content
 86#let img(src, astro: (:), ..args) = {
 87  if type(src) == str and not src.starts-with("/") {
 88    panic("kp.img: wrap " + repr(src) + " in path() or make it root-absolute")
 89  }
 90
 91  let element = image(src, ..args)
 92  let specifier = import-specifier(element.at("source", default: none))
 93
 94  if specifier == none or specifier.matches(supported-exts).len() == 0 {
 95    return element
 96  }
 97
 98  let width = css-length(element.at("width", default: auto))
 99  let height = css-length(element.at("height", default: auto))
100  let fit = element.at("fit", default: "cover")
101  let scaling = element.at("scaling", default: auto)
102  let alt = element.at("alt", default: none)
103
104  // `<article>` max size: 780px
105  let options = (layout: "constrained", width: 780) + astro
106  let styles = ("display": "block")
107  if width != none { styles.insert("width", width) }
108  if height != none { styles.insert("height", height) }
109  if width != none and height != none {
110    styles.insert("object-fit", fit-map.at(fit))
111  }
112  if scaling == "pixelated" { styles.insert("image-rendering", "pixelated") }
113  if "style" in options {
114    let style = options.at("style")
115    if style != none {
116      if type(style) != dictionary {
117        panic(
118          "kp.img: astro.style must be a dictionary, e.g. (style: (width: \"30%\"))",
119        )
120      }
121      for (property, value) in style { styles.insert(property, value) }
122    }
123    let _ = options.remove("style")
124  }
125  if "alt" not in options {
126    options.insert("alt", if alt == none { "" } else { alt })
127  }
128
129  let attributes = jsx-attributes(options)
130  let tag = (
131    "<Image",
132    "src={import(" + repr(specifier) + ").then(m => m.default)}",
133    "style={",
134    js-value(styles),
135    "}",
136    ..attributes,
137    "/>",
138  ).join("  ")
139
140  import-counter.step()
141
142  context {
143    let render = match-target(
144      html: () => {
145        if import-counter.get().first() == 1 { image-import() }
146        jsx(tag)
147      },
148      paged: () => element,
149    )
150    render()
151  }
152}