@posuta/posuta 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import { parseHTML } from "linkedom";
2
+ /** A DOM for Observable Plot at build time. */
3
+ export const dom = () => parseHTML("<!doctype html><html><body></body></html>").document;
4
+ export { default as katex } from "katex";
@@ -0,0 +1,6 @@
1
+ ---
2
+ // <Figure.Caption>: the caption. `as` renders another element (default figcaption).
3
+ interface Props { as?: string; class?: string; [attr: string]: unknown }
4
+ const { as: Tag = "figcaption", class: cls, ...rest } = Astro.props;
5
+ ---
6
+ <Tag class:list={["posuta-caption", cls]} {...rest}><slot /></Tag>
@@ -0,0 +1,27 @@
1
+ ---
2
+ // <Figure.Media>: an image, an SVG or a video. `alt` is required.
3
+ // src={plot} (import plot from "./plot.svg") inlined SVG: the poster's fonts, vector in print;
4
+ // its own class kept, $…$ in its text rendered with KaTeX
5
+ // src={photo} or "/photo.jpg" <img> at its original resolution
6
+ // src="/clip.mp4" poster="/clip.jpg" a video on screen; in print, the poster frame as an image
7
+ // class goes on the media element (and on the print frame of a video); other attributes on the
8
+ // video or image, e.g. autoplay muted loop.
9
+ import { inlineSvg } from "../svg.js";
10
+ interface Props { src: any; alt: string; poster?: any; class?: string; [attr: string]: unknown }
11
+ const { src, alt, poster, class: cls, ...rest } = Astro.props;
12
+ if (!alt) throw new Error("posuta <Figure.Media>: `alt` is required (what it shows, for screen readers)");
13
+ const Svg = typeof src === "function" ? src : null; // Astro turns imported .svg files into components
14
+ const url = (s: any) => (typeof s === "string" ? s : s?.src);
15
+ const video = !Svg && /\.(mp4|webm|mov|m4v|ogv)(\?|#|$)/i.test(url(src) ?? "");
16
+ if (video && !poster) throw new Error("posuta <Figure.Media>: a video needs `poster`, the frame that prints");
17
+ const classes = ["posuta-media", cls];
18
+ // posuta's Vite plugin tags imported SVGs with their file (index.js); without it, Astro's component.
19
+ const svg = Svg?.posutaFile && inlineSvg(Svg.posutaFile, { class: classes.filter(Boolean).join(" "), role: "img", "aria-label": alt, ...rest });
20
+ ---
21
+ {svg ? <Fragment set:html={svg} />
22
+ : Svg ? <Svg class:list={classes} role="img" aria-label={alt} {...rest} />
23
+ : video ? (
24
+ <video class:list={classes} src={url(src)} poster={url(poster)} aria-label={alt} controls playsinline preload="metadata" data-print="hide" {...rest} />
25
+ <img class:list={classes} src={url(poster)} alt={alt} width={poster?.width} height={poster?.height} data-print="only" />
26
+ )
27
+ : <img class:list={classes} src={url(src)} alt={alt} width={src?.width} height={src?.height} {...rest} />}
@@ -0,0 +1,7 @@
1
+ ---
2
+ // <Figure.Root>: the figure element. Put Figure.Media, Figure.Caption and anything else inside,
3
+ // in any order. `as` renders another element (default figure).
4
+ interface Props { as?: string; class?: string; [attr: string]: unknown }
5
+ const { as: Tag = "figure", class: cls, ...rest } = Astro.props;
6
+ ---
7
+ <Tag class:list={["posuta-figure", cls]} {...rest}><slot /></Tag>
@@ -0,0 +1,9 @@
1
+ // Figure and its parts: <Figure src alt caption /> for the usual case, or build your own:
2
+ // <Figure.Root><Figure.Caption>…</Figure.Caption><a href="…"><Figure.Media src alt /></a></Figure.Root>
3
+ import Figure from "../Figure.astro";
4
+ import Caption from "./Caption.astro";
5
+ import Media from "./Media.astro";
6
+ import Root from "./Root.astro";
7
+
8
+ export default Object.assign(Figure, { Root, Media, Caption });
9
+ export { Root, Media, Caption };
package/src/index.js ADDED
@@ -0,0 +1,59 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { unified } from "@astrojs/markdown-remark";
5
+ import mdx from "@astrojs/mdx";
6
+ import rehypeKatex from "rehype-katex";
7
+ import remarkMath from "remark-math";
8
+ import remarkAutoImport from "./remark-auto-import.js";
9
+ import remarkCite from "./remark-cite.js";
10
+
11
+ /**
12
+ * posuta's Astro integration: one line in astro.config, and posters are MDX pages with math,
13
+ * citations, and components available without import lines.
14
+ * @param {{ theme?: string, citationStyle?: string, remarkPlugins?: any[], rehypePlugins?: any[] }} [options]
15
+ * theme: a CSS file or package of :root tokens, e.g. "@my-lab/posuta-theme"
16
+ * citationStyle: vancouver (default), apa, harvard1, or a .csl file relative to the project root
17
+ * remarkPlugins, rehypePlugins: yours, run after posuta's (e.g. rehype-mermaid for diagrams)
18
+ * @returns {import("astro").AstroIntegration}
19
+ */
20
+ export default function posuta({ theme, citationStyle = "vancouver", remarkPlugins = [], rehypePlugins = [] } = {}) {
21
+ return {
22
+ name: "posuta",
23
+ hooks: {
24
+ "astro:config:setup": ({ config, updateConfig, injectScript, logger }) => {
25
+ // posuta's CSS is layered (posuta.base, posuta.theme), so these unlayered tokens win whatever the order.
26
+ if (theme) injectScript("page-ssr", `import ${JSON.stringify(theme)};`);
27
+ globalThis.__posutaRoot = fileURLToPath(config.root); // data/ lives here (see data.js)
28
+ const has = (name) => config.integrations.some((i) => i.name === name);
29
+ const integrations = [];
30
+ if (!has("@astrojs/mdx")) integrations.push(mdx());
31
+ const markdown = {};
32
+ if (config.markdown.processor?.name !== "satteri") logger.warn("markdown.processor is set; add remark-math, rehype-katex and posuta's remark-cite and remark-auto-import to it yourself");
33
+ else {
34
+ const components = fileURLToPath(new URL("components/", config.srcDir));
35
+ const style = citationStyle.endsWith(".csl") ? fileURLToPath(new URL(citationStyle, config.root)) : citationStyle;
36
+ markdown.processor = unified({
37
+ remarkPlugins: [remarkMath, [remarkCite, { style }], [remarkAutoImport, { components }], ...remarkPlugins],
38
+ rehypePlugins: [rehypeKatex, ...rehypePlugins],
39
+ });
40
+ }
41
+ // base.css loads KaTeX's fonts from posuta's own node_modules, which can sit outside the
42
+ // project (a linked posuta); added after resolve so Vite keeps its default allow list.
43
+ const katex = dirname(createRequire(import.meta.url).resolve("katex/package.json"));
44
+ const allowKatex = { name: "posuta:allow-katex", configResolved: (c) => { c.server.fs.allow.push(katex); } };
45
+ // Imported .svg files are Astro components that hide their markup; tag each with its file
46
+ // so Figure.Media can inline it with its own class kept and $…$ rendered.
47
+ const svgFile = {
48
+ name: "posuta:svg-file", enforce: "post",
49
+ transform(code, id) {
50
+ if (!id.endsWith(".svg") || !code.includes("export default createSvgComponent(")) return;
51
+ return code.replace("export default createSvgComponent(", "const svg = createSvgComponent(") +
52
+ `\nexport default Object.defineProperty(svg, "posutaFile", { value: ${JSON.stringify(id)} });`;
53
+ },
54
+ };
55
+ updateConfig({ integrations, markdown, vite: { plugins: [allowKatex, svgFile] } });
56
+ },
57
+ },
58
+ };
59
+ }
package/src/md.js ADDED
@@ -0,0 +1,29 @@
1
+ // Inline Markdown in frontmatter text (a lede, a stat label) → HTML: emphasis, links, `code`,
2
+ // $…$ maths, and [@key] citations numbered as on the page (pass frontmatter.references).
3
+ // <p class="lede" set:html={await md(lede, frontmatter.references)} />
4
+ import { createMarkdownProcessor } from "@astrojs/markdown-remark";
5
+ import rehypeKatex from "rehype-katex";
6
+ import remarkMath from "remark-math";
7
+ import { CITE } from "./remark-cite.js";
8
+
9
+ let processor;
10
+
11
+ /**
12
+ * @param {string | undefined} text
13
+ * @param {{ key: string, n: number }[]} [references] frontmatter.references
14
+ */
15
+ export async function md(text, references = []) {
16
+ if (text == null) return "";
17
+ const n = new Map(references.map((r) => [r.key, r.n]));
18
+ const src = String(text).replace(CITE, (_, keys) => {
19
+ const links = keys.split(";").map((k) => k.trim().slice(1)).map((key) => {
20
+ if (!n.has(key)) throw new Error(`posuta md: [@${key}] is not on frontmatter.references (is \`bibliography\` set?)`);
21
+ return `<a href="#ref-${key}">${n.get(key)}</a>`;
22
+ });
23
+ return `<span class="posuta-cite">[${links.join(", ")}]</span>`;
24
+ });
25
+ processor ??= createMarkdownProcessor({ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex], syntaxHighlight: false });
26
+ const { code } = await (await processor).render(src);
27
+ // One paragraph comes back without its <p>, to go inside the author's own element.
28
+ return code.trim().replace(/^<p>((?:(?!<p>)[\s\S])*)<\/p>$/, "$1");
29
+ }
@@ -0,0 +1,36 @@
1
+ // The overflow check, run in the page: posuta pdf passes it to page.evaluate, the dev print
2
+ // preview imports it. Self-contained (no outer variables) so it survives serialisation.
3
+
4
+ /**
5
+ * Measure the laid-out canvas against its padding edge. Returns null without a .posuta-canvas,
6
+ * else a message per overflowing block (the innermost: an element led by h1–h4 or with
7
+ * data-name), or for the canvas itself when no block is to blame. Empty when it fits.
8
+ * @param {boolean} [mark] also set data-posuta-overflow on the blocks named
9
+ */
10
+ export function overflow(mark = false) {
11
+ const canvas = document.querySelector(".posuta-canvas");
12
+ if (!canvas) return null;
13
+ const pad = getComputedStyle(canvas);
14
+ const box = canvas.getBoundingClientRect();
15
+ const limit = { bottom: box.bottom - parseFloat(pad.paddingBottom), right: box.right - parseFloat(pad.paddingRight) };
16
+ const toMm = (v) => Math.round((v * 25.4) / 96);
17
+ const lead = (el) => (el.firstElementChild?.matches("h1, h2, h3, h4") ? el.firstElementChild : null);
18
+ const blocks = [...canvas.querySelectorAll("*")].filter((el) => el.dataset.name || lead(el));
19
+ const over = [];
20
+ for (const el of blocks) {
21
+ const r = el.getBoundingClientRect();
22
+ const name = el.dataset.name || lead(el).textContent.trim();
23
+ const wide = Math.max(r.right - limit.right, el.scrollWidth - el.clientWidth);
24
+ if (r.bottom > limit.bottom + 1) over.push({ el, msg: `"${name}" runs ${toMm(r.bottom - limit.bottom)} mm past the bottom` });
25
+ else if (wide > 1) over.push({ el, msg: `"${name}" is ${toMm(wide)} mm too wide` });
26
+ }
27
+ // Name the innermost block, not the column or section around it.
28
+ const inner = over.filter((o) => !over.some((p) => p !== o && o.el.contains(p.el)));
29
+ if (mark) inner.forEach((o) => o.el.setAttribute("data-posuta-overflow", ""));
30
+ if (inner.length) return inner.map((o) => o.msg);
31
+ // Nothing named overflows: check the canvas itself (its scroll size includes its end padding).
32
+ const down = canvas.scrollHeight - canvas.clientHeight, wide = canvas.scrollWidth - canvas.clientWidth;
33
+ if (down > 1) return [`the poster runs ${toMm(down)} mm past the bottom`];
34
+ if (wide > 1) return [`the poster is ${toMm(wide)} mm too wide`];
35
+ return [];
36
+ }
package/src/plot.js ADDED
@@ -0,0 +1,141 @@
1
+ // Observable Plot → SVG markup, with posuta's defaults (theme font, transparent background) and
2
+ // $…$ in any text rendered with KaTeX.
3
+ import * as Plot from "@observablehq/plot";
4
+ import { dom, katex as buildKatex } from "#dom";
5
+ import { MATH, esc, tex } from "./tex.js";
6
+
7
+ let katex = buildKatex; // null in the browser until plotLive needs it
8
+ const SVG = "http://www.w3.org/2000/svg";
9
+ const inherited = (el, name) => el.closest(`[${name}]`)?.getAttribute(name);
10
+
11
+ /**
12
+ * Replace text holding $…$ with KaTeX, in place: SVG text becomes a foreignObject at the same
13
+ * spot and alignment; HTML text (legends) gets the markup directly. Returns how many it changed.
14
+ */
15
+ export function mathify(root, k) {
16
+ let n = 0;
17
+ for (const t of [...root.querySelectorAll("text")]) {
18
+ if (!MATH.test(t.textContent)) continue;
19
+ // shortcut: a multi-line label (several tspans) becomes one line; split it into two labels if needed.
20
+ const anchor = inherited(t, "text-anchor") ?? "start";
21
+ // Plot nudges text with dy, or with an em-valued y; both mean vertical alignment here.
22
+ const yEm = /em$/.test(t.getAttribute("y") ?? "") ? parseFloat(t.getAttribute("y")) : 0;
23
+ const dy = parseFloat(inherited(t, "dy") ?? "0") + yEm;
24
+ const tx = { start: "0", middle: "-50%", end: "-100%" }[anchor] ?? "0";
25
+ const ty = dy > 0.6 ? "0" : dy > 0.1 ? "-50%" : "-80%"; // Plot's dy: .71em hangs, .32em centres, 0 sits on the baseline
26
+ const fill = inherited(t, "fill");
27
+ const fo = t.ownerDocument.createElementNS(SVG, "foreignObject");
28
+ for (const a of ["x", "y", "transform"]) if (t.getAttribute(a) && !/em$/.test(t.getAttribute(a))) fo.setAttribute(a, t.getAttribute(a));
29
+ fo.setAttribute("width", "1");
30
+ fo.setAttribute("height", "1");
31
+ fo.setAttribute("overflow", "visible");
32
+ const color = fill && fill !== "currentColor" ? `color:${fill};` : "";
33
+ fo.innerHTML = `<div xmlns="http://www.w3.org/1999/xhtml" class="posuta-tex" style="${color}transform:translate(${tx},${ty})">${tex(t.textContent, k)}</div>`;
34
+ t.replaceWith(fo);
35
+ n++;
36
+ }
37
+ for (const el of [root, ...root.querySelectorAll("*")]) {
38
+ if (el.closest("svg")) continue;
39
+ for (const node of [...el.childNodes]) {
40
+ if (node.nodeType !== 3 || !MATH.test(node.textContent)) continue;
41
+ const span = el.ownerDocument.createElement("span");
42
+ span.innerHTML = tex(node.textContent, k);
43
+ node.replaceWith(span);
44
+ n++;
45
+ }
46
+ }
47
+ return n;
48
+ }
49
+
50
+ /** Plot options with the theme defaults filled in. */
51
+ export function withDefaults(options) {
52
+ return {
53
+ ...options,
54
+ style: { background: "transparent", fontFamily: "var(--font-body)", ...options.style },
55
+ };
56
+ }
57
+
58
+ /** Render to an element. At build time math is rendered too; in the browser use plotLive. */
59
+ export function plot(options) {
60
+ const el = Plot.plot({ ...withDefaults(options), document: dom() });
61
+ if (katex) mathify(el, katex);
62
+ return el;
63
+ }
64
+
65
+ /** Render in the browser, loading KaTeX the first time a chart has $…$ in it. */
66
+ export async function plotLive(options) {
67
+ const el = plot(options);
68
+ if (!katex && MATH.test(el.textContent)) {
69
+ katex = (await import("katex")).default;
70
+ mathify(el, katex);
71
+ }
72
+ return el;
73
+ }
74
+
75
+ /** Render to markup; works at build time, with no browser. */
76
+ export const renderPlot = (options) => plot(options).outerHTML;
77
+
78
+ export { Plot };
79
+
80
+ /** The first mark with x and y channels, and its channel fields: what the pointer follows. */
81
+ function pointerOf(options) {
82
+ const mark = (options.marks ?? []).flat(Infinity).find((m) => m?.channels?.x && m.channels.y && m.data);
83
+ if (!mark) return null;
84
+ const ch = Object.fromEntries(["stroke", "fill", "z", "x", "y"] // series first, as in Plot's own tip
85
+ .filter((k) => mark.channels[k]).map((k) => [k, mark.channels[k].value]));
86
+ return { mark, ch };
87
+ }
88
+
89
+ /**
90
+ * Add a crosshair and a highlighted point that follow the pointer. They draw nothing until the
91
+ * pointer moves, so print and no-JS output are unchanged. The tooltip itself is HTML (attachTip),
92
+ * because Plot's own tip is SVG text and cannot hold maths.
93
+ */
94
+ export function withPointer(options) {
95
+ const p = pointerOf(options);
96
+ if (!p) return options;
97
+ const { x, y, stroke, fill } = p.ch;
98
+ const { fx, fy } = p.mark; // facet channels, so the pointer stays in the facet it points at
99
+ return {
100
+ ...options,
101
+ marks: [...options.marks, Plot.crosshair(p.mark.data, { x, y, fx, fy }),
102
+ Plot.dot(p.mark.data, Plot.pointer({ x, y, fx, fy, fill: stroke ?? fill ?? "currentColor", r: 4, className: "posuta-pointer" }))],
103
+ };
104
+ }
105
+
106
+ /** Tooltip rows for a datum: [label, value] pairs, labels from the scales, with $math$ kept. */
107
+ export function tipRows(options, d) {
108
+ const p = pointerOf(options);
109
+ if (!p) return [];
110
+ const get = (f) => (typeof f === "function" ? f(d) : typeof f === "string" ? d[f] : undefined);
111
+ const label = (k, f) => (k === "x" || k === "y" ? options[k]?.label : options.color?.label) ?? (typeof f === "string" ? f : k);
112
+ const fmt = (v) => (typeof v === "number" && !Number.isInteger(v) ? String(+v.toPrecision(4)) : String(v));
113
+ const seen = new Set();
114
+ return Object.entries(p.ch).flatMap(([k, f]) => {
115
+ if (seen.has(f) || get(f) === undefined) return [];
116
+ seen.add(f);
117
+ return [[label(k, f).replace(/^[↑↓←→]\s*|\s*[↑↓←→]$/g, ""), fmt(get(f))]];
118
+ });
119
+ }
120
+
121
+ /** Show an HTML tooltip, with maths, over a live chart drawn with withPointer. */
122
+ export function attachTip(el, options, box) {
123
+ const tip = Object.assign(document.createElement("div"), { className: "posuta-tip", hidden: true });
124
+ box.append(tip);
125
+ const html = (s) => (katex ? tex(s, katex) : esc(s));
126
+ el.addEventListener("input", () => {
127
+ const d = el.value;
128
+ if (!d) { tip.hidden = true; return; }
129
+ tip.innerHTML = tipRows(options, d).map(([l, v]) => `<div><b>${html(l)}</b> ${html(v)}</div>`).join("");
130
+ // The tip goes on the pointer dot, in whatever facet it is. The crosshair can fire input
131
+ // before the dot is redrawn, so place it a frame later.
132
+ requestAnimationFrame(() => {
133
+ const dot = el.querySelector(".posuta-pointer circle");
134
+ if (!dot || el.value !== d) return;
135
+ const r = dot.getBoundingClientRect(), b = box.getBoundingClientRect();
136
+ tip.style.left = `${r.left + r.width / 2 - b.left}px`;
137
+ tip.style.top = `${r.top + r.height / 2 - b.top}px`;
138
+ tip.hidden = false;
139
+ });
140
+ });
141
+ }
package/src/preview.js ADDED
@@ -0,0 +1,60 @@
1
+ // Print preview in the browser (astro dev, any page URL + ?print): the print CSS applies on
2
+ // screen, the canvas is laid out at the paper size, checked for overflow like posuta pdf, and
3
+ // scaled to fit the window. Overflowing blocks get a red outline; a banner lists them.
4
+ // shortcut: media queries on width alone (no print/screen) still follow the window, not the paper.
5
+ import { overflow } from "./overflow.js";
6
+
7
+ const done = new WeakSet();
8
+
9
+ /** Flip the media rules: print ones apply, screen ones don't, and colours are light, as in the PDF. */
10
+ function flip(rules) {
11
+ for (const r of rules) {
12
+ if (r.media && r.type === CSSRule.MEDIA_RULE && !done.has(r)) {
13
+ done.add(r);
14
+ r.media.mediaText = r.media.mediaText
15
+ .replace(/\b(print|screen)\b/g, (m) => (m === "print" ? "all" : "print")) // "print" never matches on screen
16
+ .replace(/\(\s*prefers-color-scheme\s*:\s*dark\s*\)/g, "(max-width: 0px)")
17
+ .replace(/\(\s*prefers-color-scheme\s*:\s*light\s*\)/g, "(min-width: 0px)");
18
+ }
19
+ if (r.styleSheet) flip(safe(r.styleSheet)); // @import
20
+ if (r.cssRules) flip(r.cssRules); // @layer, @supports, nested @media
21
+ }
22
+ }
23
+ const safe = (sheet) => { try { return sheet.cssRules; } catch { return []; } }; // cross-origin sheets can't be read
24
+
25
+ let banner;
26
+ function apply() {
27
+ for (const s of document.styleSheets) flip(safe(s));
28
+ const canvas = document.querySelector(".posuta-canvas");
29
+ if (!canvas) return;
30
+ canvas.style.transform = "";
31
+ document.querySelectorAll("[data-posuta-overflow]").forEach((el) => el.removeAttribute("data-posuta-overflow"));
32
+ const problems = overflow(true);
33
+ const css = getComputedStyle(document.documentElement);
34
+ const size = `${css.getPropertyValue("--paper-w").trim()} × ${css.getPropertyValue("--paper-h").trim()}`;
35
+ banner ??= document.body.appendChild(Object.assign(document.createElement("div"), { className: "posuta-preview-banner" }));
36
+ banner.dataset.ok = String(!problems.length);
37
+ banner.textContent = problems.length ? `Does not fit on ${size}:\n${problems.join("\n")}` : `Print preview: fits on ${size}`;
38
+ fit();
39
+ }
40
+
41
+ function fit() {
42
+ const canvas = document.querySelector(".posuta-canvas");
43
+ if (!canvas) return;
44
+ const s = Math.min(innerWidth / canvas.offsetWidth, innerHeight / canvas.offsetHeight, 1);
45
+ canvas.style.transformOrigin = "0 0";
46
+ canvas.style.transform = `scale(${s})`;
47
+ }
48
+
49
+ export function preview() {
50
+ document.head.appendChild(document.createElement("style")).textContent =
51
+ "html,body{overflow:hidden}[data-posuta-overflow]{outline:4px solid #e00;outline-offset:-2px}" +
52
+ ".posuta-preview-banner{position:fixed;right:8px;bottom:8px;z-index:2147483647;max-width:40em;padding:6px 10px;" +
53
+ "font:13px/1.4 system-ui,sans-serif;white-space:pre-line;color:#fff;background:#c00;border-radius:4px}" +
54
+ ".posuta-preview-banner[data-ok=true]{background:#286}";
55
+ const run = () => document.fonts.ready.then(apply);
56
+ run();
57
+ addEventListener("load", run);
58
+ addEventListener("resize", fit);
59
+ import.meta.hot?.on("vite:afterUpdate", run);
60
+ }
package/src/print.js ADDED
@@ -0,0 +1,48 @@
1
+ // Paper sizes and the print scale. Plain JS so the layout (at build) and the CLI share it.
2
+
3
+ /** Portrait sizes in mm. */
4
+ export const PAPER = {
5
+ A0: [841, 1189], A1: [594, 841], A2: [420, 594], A3: [297, 420],
6
+ "24x36in": [609.6, 914.4], "36x48in": [914.4, 1219.2], "42x56in": [1066.8, 1422.4], "48x72in": [1219.2, 1828.8],
7
+ };
8
+ const UNIT = { mm: 1, cm: 10, in: 25.4 };
9
+ const A0_AREA = 841 * 1189;
10
+
11
+ function mm(v) {
12
+ const m = /^\s*([\d.]+)\s*(mm|cm|in)\s*$/.exec(String(v));
13
+ if (!m) throw new Error(`posuta: print size "${v}" needs a number and a unit (mm, cm or in)`);
14
+ return +m[1] * UNIT[m[2]];
15
+ }
16
+
17
+ /**
18
+ * Frontmatter `print` → paper in mm, the type scale, and `text`, the author's own factor on it.
19
+ * Accepts "A1", "A1 landscape", { size, orientation } or { width: "900mm", height: "36in" };
20
+ * the object forms take `scale` too, e.g. { size: "A0", scale: 0.8 } for smaller text.
21
+ */
22
+ export function paper(print = "A0") {
23
+ const p = typeof print === "string" ? { size: print.split(/[\s:]+/)[0], orientation: print.split(/[\s:]+/)[1] } : print;
24
+ let w, h;
25
+ if (p.width != null || p.height != null) {
26
+ if (p.width == null || p.height == null) throw new Error("posuta: a custom print size needs both width and height");
27
+ [w, h] = [mm(p.width), mm(p.height)];
28
+ } else {
29
+ const size = PAPER[p.size ?? "A0"];
30
+ if (!size) throw new Error(`posuta: unknown print size "${p.size}"; use one of ${Object.keys(PAPER).join(", ")} or { width, height }`);
31
+ [w, h] = size;
32
+ }
33
+ if (p.orientation && !["portrait", "landscape"].includes(p.orientation)) {
34
+ throw new Error(`posuta: orientation must be portrait or landscape, not "${p.orientation}"`);
35
+ }
36
+ if ((p.orientation === "landscape" && w < h) || (p.orientation === "portrait" && w > h)) [w, h] = [h, w];
37
+ const text = p.scale ?? 1;
38
+ if (!(typeof text === "number" && text > 0)) throw new Error(`posuta: print scale must be a number above 0, not "${p.scale}"`);
39
+ // Type follows the paper's area, so a sheet reads the same in either orientation.
40
+ return { width: w, height: h, scale: Math.sqrt((w * h) / A0_AREA), text };
41
+ }
42
+
43
+ /** The CSS that sets the page box and the scale; the theme reads the custom properties. */
44
+ export function printCss(print) {
45
+ const { width, height, scale, text } = paper(print);
46
+ return `@page{size:${width}mm ${height}mm;margin:0}` +
47
+ `:root{--paper-w:${width}mm;--paper-h:${height}mm;--print-scale:${scale.toFixed(4)};--print-text:${text}}`;
48
+ }
package/src/qr.js ADDED
@@ -0,0 +1,5 @@
1
+ // QR codes as inline SVG, for hand-built markup; <QR> wraps this.
2
+ import QRCode from "qrcode";
3
+
4
+ /** An SVG QR code for `url`, black on transparent, no quiet zone. */
5
+ export const qrSvg = (url) => QRCode.toString(url, { type: "svg", margin: 0, errorCorrectionLevel: "M" });
@@ -0,0 +1,14 @@
1
+ ---
2
+ // <References.Item entry={e} />: one cited entry, e from frontmatter.references
3
+ // ({ key, n, text, doi }). Its id is the target of the [n] links in the text. With children, they
4
+ // replace the default content (the formatted reference, URLs in it linked, and its DOI link).
5
+ interface Props {
6
+ entry: { key: string; n: number; text: string; doi?: string };
7
+ as?: string; class?: string; [attr: string]: unknown;
8
+ }
9
+ import { urlParts } from "../remark-cite.js";
10
+ const { entry, as: Tag = "li", class: cls, ...rest } = Astro.props;
11
+ ---
12
+ <Tag id={`ref-${entry.key}`} class:list={["posuta-ref", cls]} {...rest}>{Astro.slots.has("default") ? <slot /> : <>
13
+ {urlParts(entry.text).map((p, i) => (i % 2 ? <a href={p}>{p}</a> : p))}{entry.doi && <> <a href={`https://doi.org/${entry.doi}`}>doi:{entry.doi}</a></>}
14
+ </>}</Tag>
@@ -0,0 +1,7 @@
1
+ ---
2
+ // <References.Root>: the reference list element. Put References.Item inside, one per entry of
3
+ // frontmatter.references. `as` renders another element (default ol).
4
+ interface Props { as?: string; class?: string; [attr: string]: unknown }
5
+ const { as: Tag = "ol", class: cls, ...rest } = Astro.props;
6
+ ---
7
+ <Tag class:list={["posuta-refs", cls]} {...rest}><slot /></Tag>
@@ -0,0 +1,8 @@
1
+ // The reference list as parts. <References /> (handled by the citation plugin) covers the usual
2
+ // case; to lay the list out yourself, map the entries cited on the page:
3
+ // <References.Root>{frontmatter.references.map((e) => <References.Item entry={e} />)}</References.Root>
4
+ import Item from "./Item.astro";
5
+ import Root from "./Root.astro";
6
+
7
+ export default { Root, Item };
8
+ export { Root, Item };
@@ -0,0 +1,79 @@
1
+ // MDX without import lines: <Chart>, <Row>, <Section>… are imported for you.
2
+ // Each capitalised JSX name a page uses (or the first part of a dotted one, <Figure.Caption>), but does not import or define itself, is looked up in the
3
+ // poster's src/components/ (file name = component name), then in posuta's own components.
4
+ // An explicit import always wins.
5
+ import { existsSync, readdirSync } from "node:fs";
6
+ import { join, parse } from "node:path";
7
+
8
+ /** posuta's components, by name. */
9
+ export const POSUTA = {
10
+ ...Object.fromEntries(["Canvas", "QR", "Row", "Col", "Grid", "Cell", "Chart", "Equation"].map((n) => [n, `@posuta/posuta/${n}.astro`])),
11
+ Figure: "@posuta/posuta/Figure", // with its parts: Figure.Root, Figure.Media, Figure.Caption
12
+ References: "@posuta/posuta/References", // parts only: <References /> itself is replaced by remark-cite
13
+ };
14
+ const EXT = [".astro", ".tsx", ".jsx"]; // first wins when two files share a name
15
+
16
+ /** The poster's own components: { Name: absolute path }, top level of src/components only. */
17
+ function local(dir) {
18
+ if (!existsSync(dir)) return {};
19
+ const found = {};
20
+ for (const f of readdirSync(dir)) {
21
+ const { name, ext } = parse(f);
22
+ if (!/^[A-Z]/.test(name) || !EXT.includes(ext)) continue;
23
+ if (!found[name] || EXT.indexOf(ext) < EXT.indexOf(parse(found[name]).ext)) found[name] = join(dir, f);
24
+ }
25
+ return found;
26
+ }
27
+
28
+ /** Names a page binds itself: imports and exported declarations. */
29
+ function defined(tree) {
30
+ const names = new Set();
31
+ for (const node of tree.children) {
32
+ if (node.type !== "mdxjsEsm") continue;
33
+ for (const st of node.data?.estree?.body ?? []) {
34
+ if (st.type === "ImportDeclaration") st.specifiers.forEach((s) => names.add(s.local.name));
35
+ const d = st.type === "ExportNamedDeclaration" ? st.declaration : null;
36
+ if (d?.id) names.add(d.id.name);
37
+ d?.declarations?.forEach((v) => v.id?.name && names.add(v.id.name));
38
+ }
39
+ }
40
+ return names;
41
+ }
42
+
43
+ function used(node, names = new Set()) {
44
+ // <Figure.Caption> needs Figure.
45
+ const name = /^([A-Z]\w*)(\.\w+)*$/.exec(node.name ?? "")?.[1];
46
+ if ((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && name) names.add(name);
47
+ node.children?.forEach((c) => used(c, names));
48
+ return names;
49
+ }
50
+
51
+ const importNode = (name, source) => ({
52
+ type: "mdxjsEsm",
53
+ value: `import ${name} from ${JSON.stringify(source)};`,
54
+ data: {
55
+ estree: {
56
+ type: "Program", sourceType: "module",
57
+ body: [{
58
+ type: "ImportDeclaration",
59
+ specifiers: [{ type: "ImportDefaultSpecifier", local: { type: "Identifier", name } }],
60
+ source: { type: "Literal", value: source, raw: JSON.stringify(source) },
61
+ }],
62
+ },
63
+ },
64
+ });
65
+
66
+ /** @param {{ components: string }} options components: absolute path of the poster's src/components */
67
+ export default function remarkAutoImport({ components }) {
68
+ return (tree) => {
69
+ const own = local(components); // read per page, so a new component is picked up on the next edit
70
+ const have = defined(tree);
71
+ const add = [];
72
+ for (const name of used(tree)) {
73
+ if (have.has(name)) continue;
74
+ const source = own[name] ?? POSUTA[name];
75
+ if (source) add.push(importNode(name, source));
76
+ }
77
+ tree.children.unshift(...add);
78
+ };
79
+ }