@publisle/template-sdk 0.0.0-stage → 0.1.1

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.
package/src/bibtex.ts ADDED
@@ -0,0 +1,284 @@
1
+ import type { CitationEntry } from "./article.ts";
2
+
3
+ const TYPE_TO_BIBTEX: Readonly<Record<string, string>> = {
4
+ "article-journal": "article",
5
+ book: "book",
6
+ "paper-conference": "inproceedings",
7
+ chapter: "incollection",
8
+ report: "techreport",
9
+ thesis: "phdthesis",
10
+ webpage: "online",
11
+ };
12
+
13
+ const BIBTEX_TO_TYPE: Readonly<Record<string, string>> = {
14
+ article: "article-journal",
15
+ book: "book",
16
+ inproceedings: "paper-conference",
17
+ conference: "paper-conference",
18
+ incollection: "chapter",
19
+ techreport: "report",
20
+ phdthesis: "thesis",
21
+ online: "webpage",
22
+ misc: "document",
23
+ };
24
+
25
+ function balanced(
26
+ source: string,
27
+ start: number,
28
+ open: string,
29
+ close: string,
30
+ ): number {
31
+ let depth = 0;
32
+ for (let index = start; index < source.length; index += 1) {
33
+ const char = source[index];
34
+ if (char === open) depth += 1;
35
+ else if (char === close) {
36
+ depth -= 1;
37
+ if (depth === 0) return index;
38
+ }
39
+ }
40
+ throw new Error("BibTeX value is missing its closing delimiter.");
41
+ }
42
+
43
+ function skipSpace(source: string, index: number): number {
44
+ let cursor = index;
45
+ while (cursor < source.length) {
46
+ const char = source[cursor];
47
+ if (char === "%") {
48
+ const line = source.indexOf("\n", cursor);
49
+ cursor = line === -1 ? source.length : line + 1;
50
+ continue;
51
+ }
52
+ if (char !== undefined && /\s/u.test(char)) {
53
+ cursor += 1;
54
+ continue;
55
+ }
56
+ break;
57
+ }
58
+ return cursor;
59
+ }
60
+
61
+ function readValue(
62
+ source: string,
63
+ index: number,
64
+ ): { value: string; index: number } {
65
+ const start = skipSpace(source, index);
66
+ const char = source[start];
67
+ if (char === "{") {
68
+ const end = balanced(source, start, "{", "}");
69
+ return { value: source.slice(start + 1, end), index: end + 1 };
70
+ }
71
+ if (char === '"') {
72
+ const end = source.indexOf('"', start + 1);
73
+ if (end === -1) throw new Error("BibTeX quoted value is not closed.");
74
+ return { value: source.slice(start + 1, end), index: end + 1 };
75
+ }
76
+ let end = start;
77
+ while (end < source.length && source[end] !== "," && source[end] !== "}")
78
+ end += 1;
79
+ return { value: source.slice(start, end).trim(), index: end };
80
+ }
81
+
82
+ function splitAuthors(value: string): string[] {
83
+ const people: string[] = [];
84
+ let current = "";
85
+ let depth = 0;
86
+ for (let index = 0; index < value.length; index += 1) {
87
+ const char = value[index];
88
+ if (char === "{") depth += 1;
89
+ if (char === "}") depth = Math.max(0, depth - 1);
90
+ if (
91
+ depth === 0 &&
92
+ value.slice(index, index + 5).toLowerCase() === " and "
93
+ ) {
94
+ people.push(current.trim());
95
+ current = "";
96
+ index += 4;
97
+ continue;
98
+ }
99
+ current += char ?? "";
100
+ }
101
+ if (current.trim()) people.push(current.trim());
102
+ return people.map((person) => {
103
+ const comma = person.indexOf(",");
104
+ if (comma !== -1)
105
+ return `${person.slice(0, comma).trim()}, ${person.slice(comma + 1).trim()}`.replace(
106
+ /, $/,
107
+ "",
108
+ );
109
+ const parts = person.split(/\s+/u).filter((part) => part.length > 0);
110
+ if (parts.length < 2) return person;
111
+ const family = parts[parts.length - 1] ?? person;
112
+ return `${family}, ${parts.slice(0, -1).join(" ")}`;
113
+ });
114
+ }
115
+
116
+ /** Parse BibTeX entries. `raw` is the original entry text, unchanged. */
117
+ export function parseBibtex(source: string): CitationEntry[] {
118
+ const entries: CitationEntry[] = [];
119
+ let index = 0;
120
+ while (index < source.length) {
121
+ const at = source.indexOf("@", index);
122
+ if (at === -1) break;
123
+ const typeMatch = /^@([A-Za-z]+)\{/u.exec(source.slice(at));
124
+ if (!typeMatch) {
125
+ index = at + 1;
126
+ continue;
127
+ }
128
+ const bibType = (typeMatch[1] ?? "").toLowerCase();
129
+ if (
130
+ bibType === "comment" ||
131
+ bibType === "string" ||
132
+ bibType === "preamble"
133
+ ) {
134
+ index = balanced(source, at + typeMatch[0].length - 1, "{", "}") + 1;
135
+ continue;
136
+ }
137
+ const keyStart = at + typeMatch[0].length;
138
+ const keyEnd = source.indexOf(",", keyStart);
139
+ if (keyEnd === -1)
140
+ throw new Error("BibTeX entry is missing its citation key.");
141
+ const id = source.slice(keyStart, keyEnd).trim();
142
+ if (!id) throw new Error("BibTeX citation key is empty.");
143
+ const fields = new Map<string, string>();
144
+ let cursor = keyEnd + 1;
145
+ const entryEnd = balanced(source, at + typeMatch[0].length - 1, "{", "}");
146
+ while (cursor < entryEnd) {
147
+ cursor = skipSpace(source, cursor);
148
+ if (cursor >= entryEnd) break;
149
+ const nameEnd = source.slice(cursor).search(/[\s=]/u);
150
+ if (nameEnd <= 0)
151
+ throw new Error(`BibTeX field name is missing in ${id}.`);
152
+ const name = source.slice(cursor, cursor + nameEnd).toLowerCase();
153
+ cursor = skipSpace(source, cursor + nameEnd);
154
+ if (source[cursor] !== "=")
155
+ throw new Error(`BibTeX field ${name} is missing '='.`);
156
+ const read = readValue(source, cursor + 1);
157
+ fields.set(name, read.value.trim());
158
+ cursor = skipSpace(source, read.index);
159
+ if (source[cursor] === ",") cursor += 1;
160
+ }
161
+ const authors = fields.has("author")
162
+ ? splitAuthors(fields.get("author") ?? "")
163
+ : [];
164
+ const title = fields.get("title");
165
+ const issued = fields.get("year");
166
+ const containerTitle = fields.get("journal") ?? fields.get("booktitle");
167
+ const volume = fields.get("volume");
168
+ const issue = fields.get("number");
169
+ const page = fields.get("pages");
170
+ const publisher = fields.get("publisher");
171
+ const doi = fields.get("doi");
172
+ const url = fields.get("url");
173
+ const raw = source.slice(at, entryEnd + 1);
174
+ entries.push({
175
+ id,
176
+ type: BIBTEX_TO_TYPE[bibType] ?? "document",
177
+ authors,
178
+ raw,
179
+ ...(title === undefined ? {} : { title }),
180
+ ...(issued === undefined ? {} : { issued }),
181
+ ...(containerTitle === undefined ? {} : { containerTitle }),
182
+ ...(volume === undefined ? {} : { volume }),
183
+ ...(issue === undefined ? {} : { issue }),
184
+ ...(page === undefined ? {} : { page }),
185
+ ...(publisher === undefined ? {} : { publisher }),
186
+ ...(doi === undefined ? {} : { doi }),
187
+ ...(url === undefined ? {} : { url }),
188
+ });
189
+ index = entryEnd + 1;
190
+ }
191
+ return entries;
192
+ }
193
+
194
+ function brace(value: string): string {
195
+ return `{${value}}`;
196
+ }
197
+
198
+ function structured(entry: CitationEntry): boolean {
199
+ return (
200
+ entry.title !== undefined ||
201
+ entry.authors.length > 0 ||
202
+ entry.issued !== undefined ||
203
+ entry.containerTitle !== undefined ||
204
+ entry.volume !== undefined ||
205
+ entry.issue !== undefined ||
206
+ entry.page !== undefined ||
207
+ entry.publisher !== undefined ||
208
+ entry.doi !== undefined ||
209
+ entry.url !== undefined
210
+ );
211
+ }
212
+
213
+ /** Write BibTeX. An entry that only has `raw` is copied exactly. */
214
+ export function toBibtex(entries: readonly CitationEntry[]): string {
215
+ return entries
216
+ .map((entry) => {
217
+ if (!structured(entry) && entry.raw !== undefined) return entry.raw;
218
+ const type = TYPE_TO_BIBTEX[entry.type ?? ""] ?? "misc";
219
+ const fields = [
220
+ entry.authors.length > 0
221
+ ? ` author = ${brace(entry.authors.join(" and "))}`
222
+ : undefined,
223
+ entry.title === undefined
224
+ ? undefined
225
+ : ` title = ${brace(entry.title)}`,
226
+ entry.containerTitle === undefined
227
+ ? undefined
228
+ : ` ${type === "article" ? "journal" : "booktitle"} = ${brace(entry.containerTitle)}`,
229
+ entry.issued === undefined
230
+ ? undefined
231
+ : ` year = ${brace(entry.issued)}`,
232
+ entry.volume === undefined
233
+ ? undefined
234
+ : ` volume = ${brace(entry.volume)}`,
235
+ entry.issue === undefined
236
+ ? undefined
237
+ : ` number = ${brace(entry.issue)}`,
238
+ entry.page === undefined ? undefined : ` pages = ${brace(entry.page)}`,
239
+ entry.publisher === undefined
240
+ ? undefined
241
+ : ` publisher = ${brace(entry.publisher)}`,
242
+ entry.doi === undefined ? undefined : ` doi = ${brace(entry.doi)}`,
243
+ entry.url === undefined ? undefined : ` url = ${brace(entry.url)}`,
244
+ ].filter((field): field is string => field !== undefined);
245
+ return `@${type}{${entry.id},\n${fields.join(",\n")}\n}`;
246
+ })
247
+ .join("\n\n");
248
+ }
249
+
250
+ export function toCslJson(entries: readonly CitationEntry[]): string {
251
+ return JSON.stringify(
252
+ entries.map((entry) => ({
253
+ id: entry.id,
254
+ type: entry.type ?? "document",
255
+ ...(entry.title === undefined ? {} : { title: entry.title }),
256
+ ...(entry.authors.length === 0
257
+ ? {}
258
+ : {
259
+ author: entry.authors.map((author) => {
260
+ const comma = author.indexOf(",");
261
+ if (comma === -1) return { family: author };
262
+ const given = author.slice(comma + 1).trim();
263
+ return given.length === 0
264
+ ? { family: author.slice(0, comma).trim() }
265
+ : { family: author.slice(0, comma).trim(), given };
266
+ }),
267
+ }),
268
+ ...(entry.issued !== undefined && /^\d{4}/u.test(entry.issued)
269
+ ? { issued: { "date-parts": [[Number(entry.issued.slice(0, 4))]] } }
270
+ : {}),
271
+ ...(entry.containerTitle === undefined
272
+ ? {}
273
+ : { "container-title": entry.containerTitle }),
274
+ ...(entry.volume === undefined ? {} : { volume: entry.volume }),
275
+ ...(entry.issue === undefined ? {} : { issue: entry.issue }),
276
+ ...(entry.page === undefined ? {} : { page: entry.page }),
277
+ ...(entry.publisher === undefined ? {} : { publisher: entry.publisher }),
278
+ ...(entry.doi === undefined ? {} : { DOI: entry.doi }),
279
+ ...(entry.url === undefined ? {} : { URL: entry.url }),
280
+ })),
281
+ null,
282
+ 2,
283
+ );
284
+ }
package/src/escape.ts ADDED
@@ -0,0 +1,27 @@
1
+ export function latexText(value: string): string {
2
+ const escapes: Readonly<Record<string, string>> = {
3
+ "\\": "\\textbackslash{}",
4
+ "&": "\\&",
5
+ "%": "\\%",
6
+ $: "\\$",
7
+ "#": "\\#",
8
+ _: "\\_",
9
+ "{": "\\{",
10
+ "}": "\\}",
11
+ "~": "\\textasciitilde{}",
12
+ "^": "\\textasciicircum{}",
13
+ };
14
+ return value.replace(
15
+ /[\\&%$#_{}~^]/gu,
16
+ (character) => escapes[character] ?? character,
17
+ );
18
+ }
19
+
20
+ export function latexKey(value: string): string {
21
+ if (
22
+ /^[A-Za-z][A-Za-z0-9:.-]*$/u.test(value) &&
23
+ !value.startsWith("publisle-key-")
24
+ )
25
+ return value;
26
+ return `publisle-key-${Array.from(new TextEncoder().encode(value), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ export type * from "./article.ts";
2
+ export { parseBibtex, toBibtex, toCslJson } from "./bibtex.ts";
3
+ export { latexText, latexKey } from "./escape.ts";
4
+ export { renderLatexArticle } from "./render.ts";
5
+ export type { LatexProfile } from "./render.ts";
6
+ import type { Article, ResearchLoss } from "./article.ts";
7
+ export type LatexEngine = "pdflatex" | "lualatex";
8
+ export interface SourcePackage {
9
+ /** Requested tagging, not a claim that the compiled PDF passed validation. */
10
+ readonly pdfStandard?: "ua-1" | "ua-2";
11
+ readonly template: string;
12
+ readonly templateVersion: string;
13
+ readonly format: "latex";
14
+ readonly engine: LatexEngine;
15
+ readonly requirements: readonly string[];
16
+ readonly fonts: Readonly<Record<string, string>>;
17
+ readonly files: Readonly<Record<string, string>>;
18
+ readonly resources?: Readonly<Record<string, Uint8Array>>;
19
+ readonly assets: readonly {
20
+ readonly source: string;
21
+ readonly destination: string;
22
+ }[];
23
+ readonly diagnostics: readonly ResearchLoss[];
24
+ }
25
+ export interface TemplateContext {
26
+ readonly pdfUa?: "ua-1" | "ua-2" | false;
27
+ readonly engine?: LatexEngine;
28
+ readonly style: string;
29
+ readonly fonts?: Readonly<Record<string, string>>;
30
+ readonly data?: Readonly<Record<string, unknown>>;
31
+ readonly citations: readonly string[];
32
+ readonly citationText?: (
33
+ cluster: import("./article.ts").CitationCluster,
34
+ ) => string;
35
+ readonly citationForm: "numeric" | "author-date" | "note";
36
+ readonly bibliography: readonly {
37
+ readonly id: string;
38
+ readonly label: string;
39
+ readonly text: string;
40
+ }[];
41
+ }
42
+ /** Trusted host code. Rendering is pure; collecting assets and running tools belong to the host. */
43
+ export interface PublishingTemplate {
44
+ readonly id: string;
45
+ readonly version: string;
46
+ readonly format: "latex";
47
+ readonly engines: readonly LatexEngine[];
48
+ readonly languages: readonly string[];
49
+ readonly directions: readonly ("ltr" | "rtl")[];
50
+ readonly bibliographyMode: "csl" | "bibtex";
51
+ readonly requiredMetadata: readonly string[];
52
+ validate(article: Article, context: TemplateContext): readonly ResearchLoss[];
53
+ render(article: Article, context: TemplateContext): SourcePackage;
54
+ }
55
+ export type TemplateRegistry = Readonly<Record<string, PublishingTemplate>>;