@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,123 @@
1
+ // Citations: `bibliography: refs.bib` in frontmatter, `[@key]` or `[@a; @b]` in the text, and
2
+ // `<References />` where the list goes. Numbered in order of first citation (frontmatter text
3
+ // first, for md()), with DOI and URL links and the BibTeX note. In the text each citation is a
4
+ // span.posuta-cite. The cited entries are also on frontmatter.references, for References.Root/Item.
5
+ // The list is formatted with a CSL style: vancouver (default), apa, harvard1, or a .csl file.
6
+ // shortcut: in-text citations are always numbers [1]; add author-date markers if a style needs them.
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { Cite, plugins } from "@citation-js/core";
10
+ import "@citation-js/plugin-bibtex";
11
+ import "@citation-js/plugin-csl";
12
+
13
+ export const CITE = /\[(@[\w:.\-/+]+(?:\s*;\s*@[\w:.\-/+]+)*)\]/g;
14
+ const HAS_CITE = new RegExp(CITE.source); // no /g: .test() must not keep state
15
+ const URL_RE = /(https?:\/\/\S*[^\s.,;:)\]])/; // trailing punctuation stays text
16
+
17
+ /** Reference text → alternating [text, url, text, …], for linking the URLs in it. */
18
+ export const urlParts = (text) => text.split(URL_RE);
19
+
20
+ /** A CSL style name, or the path of a .csl file (registered on first use). */
21
+ function template(style) {
22
+ const styles = plugins.config.get("@csl").styles;
23
+ if (style.endsWith(".csl")) {
24
+ if (!styles.has(style)) styles.add(style, readFileSync(style, "utf8"));
25
+ } else if (!styles.has(style)) {
26
+ throw new Error(`posuta: unknown citation style "${style}"; use vancouver, apa, harvard1 or a path to a .csl file`);
27
+ }
28
+ return style;
29
+ }
30
+
31
+ /**
32
+ * One CSL-JSON entry as plain text in a CSL style, without its list number or DOI (posuta adds
33
+ * the DOI as a link).
34
+ * @param {object} entry CSL-JSON, e.g. from new Cite(bibtex).data
35
+ * @param {string} [style] vancouver (default), apa, harvard1, or an absolute path to a .csl file
36
+ */
37
+ export function formatReference(entry, style = "vancouver") {
38
+ return new Cite(entry).format("bibliography", { format: "text", template: template(style) })
39
+ .trim().replace(/^\[?\d+[.\]]\s*/, "")
40
+ .replace(/\s*(Available (at|from):?\s*)?(doi:\s*|https?:\/\/(dx\.)?doi\.org\/)\S+$/i, "");
41
+ }
42
+
43
+ /** @param {{ style?: string }} [options] style: as for formatReference */
44
+ export default function remarkCite({ style = "vancouver" } = {}) {
45
+ template(style); // fail at startup on an unknown style
46
+ return (tree, file) => {
47
+ const order = [];
48
+ let refsNode = null;
49
+ let hasParts = false;
50
+ let entries = null;
51
+ const bib = () => {
52
+ if (entries) return entries;
53
+ const path = file.data.astro?.frontmatter?.bibliography;
54
+ if (!path) throw new Error(`${file.path}: [@key] citations need \`bibliography: refs.bib\` in the frontmatter`);
55
+ const data = new Cite(readFileSync(resolve(dirname(file.path), path), "utf8")).data;
56
+ return (entries = new Map(data.map((e) => [e.id, e])));
57
+ };
58
+
59
+ const walk = (node) => {
60
+ if (!node.children) return;
61
+ for (let i = 0; i < node.children.length; i++) {
62
+ const child = node.children[i];
63
+ if ((child.type === "mdxJsxFlowElement" || child.type === "mdxJsxTextElement") && /^References(\.|$)/.test(child.name ?? "")) {
64
+ if (child.name === "References") refsNode = { parent: node, child };
65
+ else { hasParts = true; walk(child); }
66
+ continue;
67
+ }
68
+ if (child.type !== "text" || !HAS_CITE.test(child.value)) { walk(child); continue; }
69
+ const parts = [];
70
+ let last = 0;
71
+ for (const m of child.value.matchAll(CITE)) {
72
+ if (m.index > last) parts.push({ type: "text", value: child.value.slice(last, m.index) });
73
+ const keys = m[1].split(";").map((k) => k.trim().slice(1));
74
+ const links = keys.map((key) => {
75
+ if (!bib().has(key)) throw new Error(`${file.path}: [@${key}] is not in the bibliography`);
76
+ if (!order.includes(key)) order.push(key);
77
+ return { type: "link", url: `#ref-${key}`, children: [{ type: "text", value: String(order.indexOf(key) + 1) }] };
78
+ });
79
+ const inner = [{ type: "text", value: "[" }];
80
+ links.forEach((l, j) => inner.push(...(j ? [{ type: "text", value: ", " }] : []), l));
81
+ inner.push({ type: "text", value: "]" });
82
+ parts.push({ type: "cite", data: { hName: "span", hProperties: { className: ["posuta-cite"] } }, children: inner });
83
+ last = m.index + m[0].length;
84
+ }
85
+ if (last < child.value.length) parts.push({ type: "text", value: child.value.slice(last) });
86
+ node.children.splice(i, 1, ...parts);
87
+ i += parts.length - 1;
88
+ }
89
+ };
90
+ // Frontmatter text (a lede, a stat label) comes first on the poster, so it is numbered first.
91
+ const fm = file.data.astro?.frontmatter;
92
+ const strings = (v) => (typeof v === "string" ? [v] : v && typeof v === "object" ? Object.values(v).flatMap(strings) : []);
93
+ for (const str of strings(fm)) for (const m of str.matchAll(CITE)) {
94
+ for (const key of m[1].split(";").map((k) => k.trim().slice(1))) {
95
+ if (!bib().has(key)) throw new Error(`${file.path}: [@${key}] in the frontmatter is not in the bibliography`);
96
+ if (!order.includes(key)) order.push(key);
97
+ }
98
+ }
99
+ walk(tree);
100
+
101
+ if (!order.length && !refsNode && !hasParts) return;
102
+ if (!refsNode && !hasParts) throw new Error(`${file.path}: add <References /> where the reference list should go`);
103
+ const refs = order.map((key, i) => {
104
+ const e = bib().get(key);
105
+ let text = formatReference(e, style);
106
+ if (e.note && !text.includes(e.note)) text += ` (${e.note})`; // as BibTeX prints it
107
+ return { key, n: i + 1, text, ...(e.DOI && { doi: e.DOI }) };
108
+ });
109
+ if (fm) fm.references = refs;
110
+ if (!refsNode) return;
111
+ const list = {
112
+ type: "list", ordered: true, spread: false, data: { hProperties: { className: ["posuta-refs"] } },
113
+ children: refs.map(({ key, text, doi }) => {
114
+ const children = urlParts(text + (doi ? " " : "")).map((p, j) => (j % 2
115
+ ? { type: "link", url: p, children: [{ type: "text", value: p }] } : { type: "text", value: p }));
116
+ if (doi) children.push({ type: "link", url: `https://doi.org/${doi}`, children: [{ type: "text", value: `doi:${doi}` }] });
117
+ return { type: "listItem", spread: false, data: { hProperties: { id: `ref-${key}` } }, children: [{ type: "paragraph", children }] };
118
+ }),
119
+ };
120
+ const { parent, child } = refsNode;
121
+ parent.children.splice(parent.children.indexOf(child), 1, list);
122
+ };
123
+ }
package/src/svg.js ADDED
@@ -0,0 +1,27 @@
1
+ // An SVG file as inline markup for <Figure.Media>: the file's own attributes kept, `class` added to
2
+ // its own, other attributes set, and $…$ in its text rendered with KaTeX (as in charts).
3
+ import { readFileSync } from "node:fs";
4
+ import { parseHTML } from "linkedom";
5
+ import { katex } from "#dom";
6
+ import { mathify } from "./plot.js";
7
+
8
+ /** @param {string} path @param {Record<string, unknown>} attrs */
9
+ export function inlineSvg(path, { class: cls, ...attrs } = {}) {
10
+ // Parsed as HTML, like the page it goes into: browsers restore SVG tag case (linearGradient).
11
+ const text = readFileSync(path, "utf8");
12
+ const { document } = parseHTML(`<!doctype html><html><body>${text}</body></html>`);
13
+ const svg = document.querySelector("svg");
14
+ if (!svg) throw new Error(`posuta: ${path} has no <svg> element`);
15
+ // linkedom reads the HTML in a foreignObject (KaTeX from texSvg) as SVG and writes its empty
16
+ // spans as <span/>, which browsers misread; set the file's own markup again, parsed as HTML.
17
+ const raw = [...text.matchAll(/<foreignObject\b[^>]*>([\s\S]*?)<\/foreignObject>/gi)].map((m) => m[1]);
18
+ svg.querySelectorAll("foreignobject").forEach((fo, i) => { fo.innerHTML = raw[i] ?? fo.innerHTML; });
19
+ for (const a of ["xmlns", "xmlns:xlink", "version"]) svg.removeAttribute(a); // not needed inline
20
+ if (cls) svg.classList.add(...String(cls).split(/\s+/).filter(Boolean));
21
+ for (const [k, v] of Object.entries(attrs)) {
22
+ if (v === false || v == null) continue;
23
+ svg.setAttribute(k, v === true ? "" : String(v));
24
+ }
25
+ mathify(svg, katex);
26
+ return svg.outerHTML;
27
+ }
package/src/tex.js ADDED
@@ -0,0 +1,29 @@
1
+ // $…$ in plain text → KaTeX: as HTML (tex) or as an SVG label (texSvg). Charts and hand-drawn
2
+ // SVG use these. At build time KaTeX is built in; in the browser pass it in:
3
+ // tex("$\\omega_n$", (await import("katex")).default)
4
+ import { katex as builtIn } from "#dom";
5
+
6
+ export const MATH = /\$[^$]+\$/;
7
+ export const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
8
+
9
+ /** Text with $…$ → HTML: the maths rendered with KaTeX, the rest escaped. */
10
+ export function tex(s, katex = builtIn) {
11
+ if (!katex) throw new Error('posuta tex: in the browser, pass KaTeX: tex(s, (await import("katex")).default)');
12
+ return String(s).split(/(\$[^$]+\$)/).map((p) => (p.length > 2 && p.startsWith("$") && p.endsWith("$")
13
+ ? katex.renderToString(p.slice(1, -1), { throwOnError: true, output: "html" }) : esc(p))).join("");
14
+ }
15
+
16
+ /**
17
+ * An SVG label with its baseline at (x, y): a <text> for plain text, or KaTeX in a foreignObject
18
+ * when it holds $…$ (the maths takes the SVG's font size and colour).
19
+ * @param {{ anchor?: "start" | "middle" | "end", class?: string, size?: number, katex?: any }} [o]
20
+ */
21
+ export function texSvg(s, x, y, { anchor = "start", class: cls, size, katex } = {}) {
22
+ const c = cls ? ` class="${esc(cls)}"` : "";
23
+ if (!MATH.test(s)) {
24
+ return `<text${c} x="${x}" y="${y}" text-anchor="${anchor}" fill="currentColor"${size ? ` font-size="${size}"` : ""}>${esc(s)}</text>`;
25
+ }
26
+ const tx = { start: "0", middle: "-50%", end: "-100%" }[anchor] ?? "0";
27
+ return `<foreignObject x="${x}" y="${y}" width="1" height="1" overflow="visible"><div xmlns="http://www.w3.org/1999/xhtml"` +
28
+ ` class="posuta-tex${cls ? " " + esc(cls) : ""}" style="transform:translate(${tx},-80%)">${tex(s, katex)}</div></foreignObject>`;
29
+ }