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}