beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
package/failsafe/cite.R
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# cite.R — citations rendered by pandoc, in the style the document chose.
|
|
2
|
+
#
|
|
3
|
+
# The notebook shows citations live: the (Author, Year) in prose, the
|
|
4
|
+
# References section after the last cell, the knitted bibliography. Until
|
|
5
|
+
# 7.18 every one of them was drawn by the page in an APA shape, because a
|
|
6
|
+
# CSL engine is a megabyte and the page must stay small. The decision that
|
|
7
|
+
# replaced it — the owner's, 2026-09-04: "implement the most common ones,
|
|
8
|
+
# rely on pandoc, and that is it, to avoid bloat" — is what this file does.
|
|
9
|
+
# Six CSL files ship BESIDE the kernel (plugins/csl/, 324 KB the page never
|
|
10
|
+
# loads), pandoc's own citeproc renders with them, and the page inserts what
|
|
11
|
+
# comes back. No engine in the page. Any other style is a PLUGIN — found in
|
|
12
|
+
# the CSL repository and imported through spike/plugins.R's one door.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is pure or takes its inputs explicitly (`here`, the
|
|
15
|
+
# request), so spike/test-cite.R can drive it without a socket. serve.R owns
|
|
16
|
+
# the socket rules: `cite` is page-only and agent-refused — it spawns a
|
|
17
|
+
# process on request, and a process this supervisor starts is the person's
|
|
18
|
+
# to start — while `cite_styles` is open to any socket (it discloses six
|
|
19
|
+
# file names and a version, nothing else).
|
|
20
|
+
#
|
|
21
|
+
# What pandoc is given, and what it is not. The page sends the cited entries
|
|
22
|
+
# as CSL-YAML (the same `references:` block the .carmd keeps) and the
|
|
23
|
+
# citation groups in document order; the supervisor writes ONE markdown
|
|
24
|
+
# file — the YAML as front matter, one paragraph per group — and runs pandoc
|
|
25
|
+
# over it with `--sandbox`, so a hostile field cannot make pandoc read a
|
|
26
|
+
# file, and with raw HTML disabled in the reader, so a title cannot smuggle
|
|
27
|
+
# markup through metadata. The page scrubs the HTML it gets back all the
|
|
28
|
+
# same (lib/cite-styles.js): two doors, because a title is imported data
|
|
29
|
+
# from someone's .bib and never trusted on either side. Bounded on every
|
|
30
|
+
# axis a request has: bytes of YAML, number of groups, keys per group, key
|
|
31
|
+
# alphabet, one run at a time per socket, a wall-clock timeout that kills
|
|
32
|
+
# the process group.
|
|
33
|
+
|
|
34
|
+
CITE_MAX_YAML_BYTES <- 1000000L
|
|
35
|
+
CITE_MAX_GROUPS <- 2000L
|
|
36
|
+
CITE_MAX_KEYS_PER_GROUP <- 50L
|
|
37
|
+
CITE_TIMEOUT_MS <- 20000L
|
|
38
|
+
# pandoc's citation-key alphabet (the page's CITE_KEY, restated in R): word
|
|
39
|
+
# characters, then internal punctuation, never a final one.
|
|
40
|
+
CITE_KEY_RE <- "^[A-Za-z0-9_]([A-Za-z0-9_:.#$%&+?<>~/-]*[A-Za-z0-9_])?$"
|
|
41
|
+
|
|
42
|
+
# ── the styles are PLUGINS (spike/plugins.R) ─────────────────────────────
|
|
43
|
+
#
|
|
44
|
+
# A style is one plugin of kind `csl`: a folder holding `<id>.csl` and its
|
|
45
|
+
# manifest, in one of three layers — the person's own (what the picker
|
|
46
|
+
# imports from the CSL project's repository, ~10,900 styles), an
|
|
47
|
+
# administrator's `CARMAR_PLUGIN_DIR`, and the six shipped beside the kernel
|
|
48
|
+
# (plugins/csl/). plugins.R owns the layers, the manifest, the integrity
|
|
49
|
+
# check, the atomic install and the policy; this file owns what a CSL file
|
|
50
|
+
# IS (its title, its parent), the repository search, and pandoc. On the wire
|
|
51
|
+
# a style is still `<id>.csl` — the value `csl:` carries in a document's
|
|
52
|
+
# front matter — so the page and the saved files know nothing of folders.
|
|
53
|
+
# Nothing here runs at start: the index is fetched on the first search of a
|
|
54
|
+
# session, a style on the click that imports it, both through the policy's
|
|
55
|
+
# host list.
|
|
56
|
+
|
|
57
|
+
CSL_INDEX_URL <- function() Sys.getenv("CARMAR_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json")
|
|
58
|
+
CSL_STYLE_BASE <- function() Sys.getenv("CARMAR_CSL_STYLE_BASE", "https://www.zotero.org/styles/")
|
|
59
|
+
CSL_MAX_INDEX_BYTES <- 8000000
|
|
60
|
+
CSL_INDEX_MAX_AGE_DAYS <- 7
|
|
61
|
+
CSL_SEARCH_LIMIT <- 40L
|
|
62
|
+
# The repository's own name alphabet (measured over all 10,861 entries: only
|
|
63
|
+
# [a-z0-9.-], longest 119). Narrower than a plugin id, which is what the
|
|
64
|
+
# repository door checks against — a URL or file door uses PLUGIN_ID_RE.
|
|
65
|
+
CSL_NAME_RE <- "^[a-z0-9][a-z0-9.-]{0,118}$"
|
|
66
|
+
CSL_NS <- "http://purl.org/net/xbiblio/csl"
|
|
67
|
+
|
|
68
|
+
csl_cache_dir <- function() tools::R_user_dir("carmar", "cache")
|
|
69
|
+
|
|
70
|
+
#' A style's own <title>, and the parent a DEPENDENT style names.
|
|
71
|
+
#'
|
|
72
|
+
#' Read as text from the head of the file — a full XML parse of Chicago
|
|
73
|
+
#' (164 KB) for two lines is not worth a dependency. A dependent style is
|
|
74
|
+
#' metadata only (title, category, a link to its parent); the formatting is
|
|
75
|
+
#' entirely the parent's.
|
|
76
|
+
csl_info <- function(path) {
|
|
77
|
+
head_text <- tryCatch(paste(readLines(path, n = 60L, warn = FALSE, encoding = "UTF-8"), collapse = "\n"),
|
|
78
|
+
error = function(e) "")
|
|
79
|
+
title <- regmatches(head_text, regexpr("<title>[^<]+</title>", head_text))
|
|
80
|
+
title <- if (length(title)) trimws(sub("</title>$", "", sub("^<title>", "", title[[1L]]))) else ""
|
|
81
|
+
# The five XML entities, decoded: a title is shown as text, and
|
|
82
|
+
# "Taylor & Francis" is not a name.
|
|
83
|
+
for (ent in list(c("<", "<"), c(">", ">"), c(""", "\""), c("'", "'"), c("&", "&"))) {
|
|
84
|
+
title <- gsub(ent[[1L]], ent[[2L]], title, fixed = TRUE)
|
|
85
|
+
}
|
|
86
|
+
parent <- regmatches(head_text, regexpr('<link[^>]*rel="independent-parent"[^>]*/?>', head_text))
|
|
87
|
+
parent_name <- ""
|
|
88
|
+
if (length(parent)) {
|
|
89
|
+
href <- regmatches(parent[[1L]], regexpr('href="[^"]+"', parent[[1L]]))
|
|
90
|
+
if (length(href)) parent_name <- basename(sub('"$', "", sub('^href="', "", href[[1L]])))
|
|
91
|
+
}
|
|
92
|
+
parent_name <- sub("\\.csl$", "", parent_name)
|
|
93
|
+
list(title = title, parent = if (nzchar(parent_name)) paste0(parent_name, ".csl") else "")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#' Is this file a CSL style? The root element, in the CSL namespace, with a
|
|
97
|
+
#' title — checked on the head of the file. Not a schema validation;
|
|
98
|
+
#' pandoc's own parser is the judge of the rest, and refuses cleanly.
|
|
99
|
+
csl_looks_like_style <- function(path) {
|
|
100
|
+
head_text <- tryCatch(paste(readLines(path, n = 40L, warn = FALSE, encoding = "UTF-8"), collapse = "\n"),
|
|
101
|
+
error = function(e) "")
|
|
102
|
+
grepl("<style[ >]", head_text, fixed = FALSE) && grepl(CSL_NS, head_text, fixed = TRUE) &&
|
|
103
|
+
!grepl("<!DOCTYPE|<!ENTITY", head_text) && nzchar(csl_info(path)$title)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#' Every style this kernel can render with: the ACTIVE csl plugins.
|
|
107
|
+
#'
|
|
108
|
+
#' READ from the store, never declared — a style staged beside the kernel is
|
|
109
|
+
#' offered the moment it is there, an imported one the moment it is
|
|
110
|
+
#' written, and one whose file no longer matches its manifest is absent
|
|
111
|
+
#' from this list rather than promised and broken.
|
|
112
|
+
#'
|
|
113
|
+
#' @return data.frame(file, name, layer, dependent_on, path)
|
|
114
|
+
csl_styles <- function(here, policy) {
|
|
115
|
+
rows <- plugin_rows(here, policy, "csl")
|
|
116
|
+
rows <- rows[rows$active, , drop = FALSE]
|
|
117
|
+
data.frame(
|
|
118
|
+
file = rows$file,
|
|
119
|
+
name = ifelse(nzchar(rows$name), rows$name, rows$id),
|
|
120
|
+
layer = rows$layer,
|
|
121
|
+
dependent_on = ifelse(nzchar(rows$requires), paste0(sub(";.*$", "", rows$requires), ".csl"), ""),
|
|
122
|
+
path = ifelse(nzchar(rows$dir), file.path(rows$dir, rows$file), ""),
|
|
123
|
+
stringsAsFactors = FALSE)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#' The .csl file a request may name, resolved in a layer or refused.
|
|
127
|
+
#'
|
|
128
|
+
#' A bare file name from the list, nothing else: no path separators, no
|
|
129
|
+
#' `..`, no absolute path. "default" (or nothing) means pandoc's built-in
|
|
130
|
+
#' Chicago author-date, which needs no file at all.
|
|
131
|
+
#'
|
|
132
|
+
#' @return The absolute path, "" for the default, or NULL when refused.
|
|
133
|
+
csl_resolve <- function(here, policy, style) {
|
|
134
|
+
if (is.null(style) || identical(style, "") || identical(style, "default")) return("")
|
|
135
|
+
if (!is.character(style) || length(style) != 1L || is.na(style)) return(NULL)
|
|
136
|
+
if (!grepl("\\.csl$", style) || !plugin_id_ok(sub("\\.csl$", "", style))) return(NULL)
|
|
137
|
+
styles <- csl_styles(here, policy)
|
|
138
|
+
row <- styles[styles$file == style, , drop = FALSE]
|
|
139
|
+
if (!nrow(row)) return(NULL)
|
|
140
|
+
normalizePath(row$path[[1L]], mustWork = FALSE)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#' The file pandoc is actually handed: a dependent style's PARENT, resolved
|
|
144
|
+
#' locally in any layer — pandoc would otherwise go looking for it, which
|
|
145
|
+
#' the sandbox forbids. The formatting of a dependent style IS its parent's.
|
|
146
|
+
#'
|
|
147
|
+
#' @return The path to render with, or NULL when a dependent's parent is
|
|
148
|
+
#' not on this machine.
|
|
149
|
+
csl_engine_path <- function(here, policy, path) {
|
|
150
|
+
if (is.null(path) || !nzchar(path)) return(path)
|
|
151
|
+
parent <- csl_info(path)$parent
|
|
152
|
+
if (!nzchar(parent)) return(path)
|
|
153
|
+
csl_resolve(here, policy, parent)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# The repository index, once per session (and on disk for a week, so a
|
|
157
|
+
# second session searches without a fetch).
|
|
158
|
+
.csl_index_cache <- new.env(parent = emptyenv())
|
|
159
|
+
|
|
160
|
+
csl_index <- function(policy) {
|
|
161
|
+
if (!is.null(.csl_index_cache$rows)) return(.csl_index_cache$rows)
|
|
162
|
+
dir <- csl_cache_dir()
|
|
163
|
+
disk <- file.path(dir, "csl-index.json")
|
|
164
|
+
fresh <- file.exists(disk) &&
|
|
165
|
+
as.numeric(difftime(Sys.time(), file.info(disk)$mtime, units = "days")) < CSL_INDEX_MAX_AGE_DAYS
|
|
166
|
+
if (!fresh) {
|
|
167
|
+
dir.create(dir, recursive = TRUE, showWarnings = FALSE, mode = "0700")
|
|
168
|
+
tmp <- tempfile("csl-index-", fileext = ".json")
|
|
169
|
+
err <- plugin_fetch(policy, CSL_INDEX_URL(), tmp, CSL_MAX_INDEX_BYTES)
|
|
170
|
+
if (nzchar(err)) {
|
|
171
|
+
if (!file.exists(disk)) return(structure(list(), error = paste0("The style index could not be fetched: ", err)))
|
|
172
|
+
} else {
|
|
173
|
+
file.copy(tmp, disk, overwrite = TRUE); unlink(tmp); Sys.chmod(disk, "0600")
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
raw <- tryCatch(jsonlite::fromJSON(disk, simplifyVector = TRUE), error = function(e) NULL)
|
|
177
|
+
if (!is.data.frame(raw) || !all(c("name", "title") %in% names(raw))) {
|
|
178
|
+
return(structure(list(), error = "The style index did not read as a list of styles."))
|
|
179
|
+
}
|
|
180
|
+
fmt <- if (is.data.frame(raw$categories) && "format" %in% names(raw$categories)) raw$categories$format else rep("", nrow(raw))
|
|
181
|
+
rows <- data.frame(
|
|
182
|
+
name = as.character(raw$name), title = as.character(raw$title),
|
|
183
|
+
dependent = if ("dependent" %in% names(raw)) as.integer(raw$dependent) == 1L else rep(FALSE, nrow(raw)),
|
|
184
|
+
format = ifelse(is.na(fmt), "", as.character(fmt)),
|
|
185
|
+
stringsAsFactors = FALSE)
|
|
186
|
+
rows <- rows[grepl(CSL_NAME_RE, rows$name) & nzchar(rows$title), , drop = FALSE]
|
|
187
|
+
.csl_index_cache$rows <- rows
|
|
188
|
+
rows
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#' Search the repository index: every term must appear in the title or the
|
|
192
|
+
#' name, case-insensitively; titles that START with the query come first.
|
|
193
|
+
#' @return list(ok, total, rows = list of {name, title, dependent, format}) or list(ok = FALSE, error)
|
|
194
|
+
csl_search <- function(policy, q, limit = CSL_SEARCH_LIMIT) {
|
|
195
|
+
q <- trimws(if (is.character(q) && length(q) == 1L && !is.na(q)) q else "")
|
|
196
|
+
if (nchar(q) < 2L) return(list(ok = TRUE, total = 0L, rows = list()))
|
|
197
|
+
if (!isTRUE(policy$network)) return(list(ok = FALSE, error = plugin_policy_reason(policy, "network")))
|
|
198
|
+
idx <- csl_index(policy)
|
|
199
|
+
if (!is.null(attr(idx, "error"))) return(list(ok = FALSE, error = attr(idx, "error")))
|
|
200
|
+
terms <- strsplit(tolower(q), "\\s+")[[1L]]
|
|
201
|
+
hay <- tolower(paste(idx$title, idx$name))
|
|
202
|
+
hit <- Reduce(`&`, lapply(terms, function(t) grepl(t, hay, fixed = TRUE)), rep(TRUE, length(hay)))
|
|
203
|
+
found <- idx[hit, , drop = FALSE]
|
|
204
|
+
starts <- startsWith(tolower(found$title), tolower(q))
|
|
205
|
+
found <- found[order(!starts, found$dependent, tolower(found$title)), , drop = FALSE]
|
|
206
|
+
shown <- head(found, limit)
|
|
207
|
+
list(ok = TRUE, total = nrow(found),
|
|
208
|
+
rows = lapply(seq_len(nrow(shown)), function(i) list(
|
|
209
|
+
name = shown$name[[i]], title = shown$title[[i]],
|
|
210
|
+
dependent = isTRUE(shown$dependent[[i]]), format = shown$format[[i]])))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#' Import one style from the repository: `plugin_install` by registry name.
|
|
214
|
+
#' A dependent style brings its parent along (the store completes it), so
|
|
215
|
+
#' rendering never needs the network again.
|
|
216
|
+
#'
|
|
217
|
+
#' @return list(ok = TRUE, file, name, dependent_on, already) or list(ok = FALSE, error)
|
|
218
|
+
csl_import <- function(here, policy, name) {
|
|
219
|
+
if (!is.character(name) || length(name) != 1L || is.na(name) || !grepl(CSL_NAME_RE, name)) {
|
|
220
|
+
return(list(ok = FALSE, error = "That is not a style name from the repository."))
|
|
221
|
+
}
|
|
222
|
+
r <- plugin_install(here, policy, "csl", list(registry = name))
|
|
223
|
+
if (!isTRUE(r$ok)) return(r)
|
|
224
|
+
list(ok = TRUE, file = r$file, name = r$name,
|
|
225
|
+
dependent_on = if (length(r$requires)) paste0(r$requires[[1L]], ".csl") else "",
|
|
226
|
+
already = isTRUE(r$already))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#' Remove a style from the user layer — `plugin_remove`, by file name.
|
|
230
|
+
csl_remove <- function(here, policy, file) {
|
|
231
|
+
if (!is.character(file) || length(file) != 1L || is.na(file) || !grepl("\\.csl$", file) ||
|
|
232
|
+
!plugin_id_ok(sub("\\.csl$", "", file))) {
|
|
233
|
+
return(list(ok = FALSE, error = "That is not a style file name."))
|
|
234
|
+
}
|
|
235
|
+
r <- plugin_remove(here, policy, "csl", sub("\\.csl$", "", file))
|
|
236
|
+
if (!isTRUE(r$ok)) return(r)
|
|
237
|
+
list(ok = TRUE, file = file)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#' Locate pandoc, the same ladder shape find_quarto() climbs: an explicit
|
|
241
|
+
#' override, what RStudio sets, rmarkdown's own answer, PATH, then the
|
|
242
|
+
#' bundles known by name. Measured on a normal RStudio machine: no pandoc on
|
|
243
|
+
#' PATH, one inside RStudio's Quarto.
|
|
244
|
+
#' @return Path to pandoc, or "".
|
|
245
|
+
find_pandoc <- function() {
|
|
246
|
+
explicit <- Sys.getenv("CARMAR_PANDOC", "")
|
|
247
|
+
if (nzchar(explicit) && file.exists(explicit)) return(explicit)
|
|
248
|
+
from_env <- Sys.getenv("RSTUDIO_PANDOC", "")
|
|
249
|
+
if (nzchar(from_env)) {
|
|
250
|
+
cand <- file.path(from_env, if (.Platform$OS.type == "windows") "pandoc.exe" else "pandoc")
|
|
251
|
+
if (file.exists(cand)) return(cand)
|
|
252
|
+
}
|
|
253
|
+
if (requireNamespace("rmarkdown", quietly = TRUE)) {
|
|
254
|
+
found <- tryCatch(rmarkdown::pandoc_exec(), error = function(e) NULL)
|
|
255
|
+
if (!is.null(found) && nzchar(found) && file.exists(found)) return(found)
|
|
256
|
+
}
|
|
257
|
+
on_path <- unname(Sys.which("pandoc"))
|
|
258
|
+
if (nzchar(on_path)) return(on_path)
|
|
259
|
+
arch <- if (grepl("aarch64|arm64", R.version$arch)) "aarch64" else "x86_64"
|
|
260
|
+
bundles <- c(
|
|
261
|
+
file.path("/Applications/RStudio.app/Contents/Resources/app/quarto/bin/tools", arch, "pandoc"),
|
|
262
|
+
file.path("/Applications/Positron.app/Contents/Resources/app/quarto/bin/tools", arch, "pandoc"),
|
|
263
|
+
file.path("/Applications/quarto/bin/tools", arch, "pandoc"),
|
|
264
|
+
"/usr/local/bin/pandoc", "/opt/homebrew/bin/pandoc",
|
|
265
|
+
"C:/Program Files/RStudio/resources/app/bin/quarto/bin/tools/pandoc.exe",
|
|
266
|
+
"C:/Program Files/Pandoc/pandoc.exe"
|
|
267
|
+
)
|
|
268
|
+
hit <- Find(file.exists, bundles)
|
|
269
|
+
if (is.null(hit)) "" else hit
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#' pandoc's version, or "" when it is absent or does not answer.
|
|
273
|
+
pandoc_version <- function(bin) {
|
|
274
|
+
if (!nzchar(bin)) return("")
|
|
275
|
+
ran <- tryCatch(processx::run(bin, "--version", timeout = 5, error_on_status = FALSE),
|
|
276
|
+
error = function(e) NULL)
|
|
277
|
+
if (is.null(ran) || !identical(as.integer(ran$status), 0L)) return("")
|
|
278
|
+
first <- strsplit(ran$stdout %||% "", "\n", fixed = TRUE)[[1L]][1L]
|
|
279
|
+
trimws(sub("^pandoc\\s+", "", first %||% ""))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#' Validate a `cite` request. The whole contract, in one place.
|
|
283
|
+
#'
|
|
284
|
+
#' @param cmd The parsed frame: `yaml` (the CSL-YAML `references:` block),
|
|
285
|
+
#' `groups` (a list of {keys: character, narrative: logical}), `style`.
|
|
286
|
+
#' @return list(ok = TRUE, yaml, groups, style) or list(ok = FALSE, error).
|
|
287
|
+
cite_validate <- function(here, policy, cmd) {
|
|
288
|
+
refuse <- function(msg) list(ok = FALSE, error = msg)
|
|
289
|
+
yaml <- cmd$yaml
|
|
290
|
+
if (!is.character(yaml) || length(yaml) != 1L || is.na(yaml)) return(refuse("cite: `yaml` must be a string."))
|
|
291
|
+
if (nchar(yaml, type = "bytes") > CITE_MAX_YAML_BYTES) return(refuse("cite: the reference block is too large."))
|
|
292
|
+
# The block is one YAML document whose only top-level key is `references:`.
|
|
293
|
+
# A second top-level key would be metadata injection — a `csl:` or
|
|
294
|
+
# `bibliography:` smuggled in by a field — so the first line is pinned and
|
|
295
|
+
# every later line must be indented or a list item.
|
|
296
|
+
lines <- strsplit(yaml, "\n", fixed = TRUE)[[1L]]
|
|
297
|
+
if (!length(lines) || !identical(trimws(lines[[1L]]), "references:")) {
|
|
298
|
+
return(refuse("cite: the reference block must start with `references:`."))
|
|
299
|
+
}
|
|
300
|
+
rest <- lines[-1L]
|
|
301
|
+
bad <- rest[nzchar(trimws(rest)) & !grepl("^( |-)", rest)]
|
|
302
|
+
if (length(bad)) return(refuse("cite: the reference block carries a key that is not a reference."))
|
|
303
|
+
style <- csl_resolve(here, policy, cmd$style)
|
|
304
|
+
if (is.null(style)) return(refuse("cite: that style is not one this kernel has."))
|
|
305
|
+
style <- csl_engine_path(here, policy, style)
|
|
306
|
+
if (is.null(style)) return(refuse("cite: that style depends on a parent style that is not on this machine."))
|
|
307
|
+
groups <- cmd$groups
|
|
308
|
+
if (is.null(groups)) groups <- list()
|
|
309
|
+
if (is.data.frame(groups)) {
|
|
310
|
+
# jsonlite simplifies a uniform list of objects into a data.frame.
|
|
311
|
+
groups <- lapply(seq_len(nrow(groups)), function(i) list(
|
|
312
|
+
keys = groups$keys[[i]], narrative = groups$narrative[[i]]))
|
|
313
|
+
}
|
|
314
|
+
if (!is.list(groups)) return(refuse("cite: `groups` must be a list."))
|
|
315
|
+
if (length(groups) > CITE_MAX_GROUPS) return(refuse("cite: too many citations in one request."))
|
|
316
|
+
clean <- vector("list", length(groups))
|
|
317
|
+
for (i in seq_along(groups)) {
|
|
318
|
+
g <- groups[[i]]
|
|
319
|
+
keys <- as.character(unlist(g$keys %||% character()))
|
|
320
|
+
if (!length(keys) || length(keys) > CITE_MAX_KEYS_PER_GROUP) {
|
|
321
|
+
return(refuse("cite: a citation must name between one and fifty keys."))
|
|
322
|
+
}
|
|
323
|
+
if (!all(grepl(CITE_KEY_RE, keys))) return(refuse("cite: a citation key has characters pandoc would not read."))
|
|
324
|
+
clean[[i]] <- list(keys = keys, narrative = isTRUE(g$narrative))
|
|
325
|
+
}
|
|
326
|
+
list(ok = TRUE, yaml = yaml, groups = clean, style = style)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#' The markdown pandoc renders: front matter, then one paragraph per group.
|
|
330
|
+
#'
|
|
331
|
+
#' Each paragraph is wrapped in a span with a known class so the answer can
|
|
332
|
+
#' be split back into groups by position, whatever the style did inside.
|
|
333
|
+
cite_document <- function(yaml, groups) {
|
|
334
|
+
paras <- vapply(groups, function(g) {
|
|
335
|
+
cite <- if (g$narrative) paste0("@", g$keys[[1L]])
|
|
336
|
+
else paste0("[", paste0("@", g$keys, collapse = "; "), "]")
|
|
337
|
+
paste0("[", cite, "]{.carmar-cite-group}")
|
|
338
|
+
}, character(1))
|
|
339
|
+
paste0("---\n", yaml, "\n---\n\n", paste(paras, collapse = "\n\n"), "\n")
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#' Split pandoc's HTML into the group labels and the bibliography.
|
|
343
|
+
#'
|
|
344
|
+
#' Positional: the i-th `carmar-cite-group` span is the i-th group. The
|
|
345
|
+
#' bibliography is pandoc's `<div id="refs">`, whole. Anything else pandoc
|
|
346
|
+
#' printed is dropped.
|
|
347
|
+
cite_split <- function(html, n_groups) {
|
|
348
|
+
labels <- character(n_groups)
|
|
349
|
+
# A group span holds no nested span of its own class, so a non-greedy
|
|
350
|
+
# match to the first closing </span> after the citation's inner spans is
|
|
351
|
+
# wrong; count nesting instead by walking span tags.
|
|
352
|
+
starts <- gregexpr('<span class="carmar-cite-group">', html, fixed = TRUE)[[1L]]
|
|
353
|
+
if (starts[[1L]] != -1L) {
|
|
354
|
+
for (i in seq_len(min(length(starts), n_groups))) {
|
|
355
|
+
from <- starts[[i]] + nchar('<span class="carmar-cite-group">')
|
|
356
|
+
rest <- substring(html, from)
|
|
357
|
+
depth <- 1L
|
|
358
|
+
pos <- 0L
|
|
359
|
+
tags <- gregexpr("<span\\b[^>]*>|</span>", rest)[[1L]]
|
|
360
|
+
lens <- attr(tags, "match.length")
|
|
361
|
+
for (j in seq_along(tags)) {
|
|
362
|
+
if (tags[[j]] == -1L) break
|
|
363
|
+
tag <- substring(rest, tags[[j]], tags[[j]] + lens[[j]] - 1L)
|
|
364
|
+
depth <- depth + if (startsWith(tag, "</")) -1L else 1L
|
|
365
|
+
if (depth == 0L) { pos <- tags[[j]]; break }
|
|
366
|
+
}
|
|
367
|
+
labels[[i]] <- if (pos > 0L) substring(rest, 1L, pos - 1L) else ""
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
refs <- ""
|
|
371
|
+
at <- regexpr('<div id="refs"', html, fixed = TRUE)
|
|
372
|
+
if (at != -1L) {
|
|
373
|
+
tail <- substring(html, at)
|
|
374
|
+
# The refs div closes at the last </div> of the document pandoc printed
|
|
375
|
+
# (nothing follows it in a body-only render).
|
|
376
|
+
ends <- gregexpr("</div>", tail, fixed = TRUE)[[1L]]
|
|
377
|
+
if (ends[[1L]] != -1L) refs <- substring(tail, 1L, ends[[length(ends)]] + 5L)
|
|
378
|
+
}
|
|
379
|
+
list(labels = labels, bibliography = refs)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#' Run pandoc over one validated request.
|
|
383
|
+
#'
|
|
384
|
+
#' @return list(ok, labels, bibliography, engine) or list(ok = FALSE, error).
|
|
385
|
+
cite_run <- function(bin, valid, timeout_ms = CITE_TIMEOUT_MS) {
|
|
386
|
+
if (!nzchar(bin)) return(list(ok = FALSE, error = "pandoc is not installed, or CarmaR could not find it."))
|
|
387
|
+
input <- tempfile("carmar-cite-", fileext = ".md")
|
|
388
|
+
on.exit(unlink(input), add = TRUE)
|
|
389
|
+
writeLines(cite_document(valid$yaml, valid$groups), input, useBytes = TRUE)
|
|
390
|
+
Sys.chmod(input, "0600")
|
|
391
|
+
args <- c(
|
|
392
|
+
# raw_html and raw_tex OFF in the reader: a title in the metadata is
|
|
393
|
+
# parsed as markdown, and raw HTML in it would otherwise pass straight
|
|
394
|
+
# through to the output. Smart quotes on, as in every render.
|
|
395
|
+
"--from", "markdown-raw_html-raw_tex",
|
|
396
|
+
"--to", "html", "--citeproc", "--wrap", "none", "--sandbox",
|
|
397
|
+
# A citation to a key the block does not hold must still render as
|
|
398
|
+
# SOMETHING (pandoc prints `[@key]` with a warning) rather than fail.
|
|
399
|
+
input
|
|
400
|
+
)
|
|
401
|
+
if (nzchar(valid$style)) args <- c(args, paste0("--csl=", valid$style))
|
|
402
|
+
ran <- tryCatch(processx::run(
|
|
403
|
+
bin, args, timeout = timeout_ms / 1000, error_on_status = FALSE, echo = FALSE,
|
|
404
|
+
cleanup_tree = TRUE, env = c("current", PANDOC_DATA_DIR = tempdir(), HOME = tempdir())
|
|
405
|
+
), error = function(e) e)
|
|
406
|
+
if (inherits(ran, "error")) {
|
|
407
|
+
msg <- if (grepl("timeout", conditionMessage(ran), ignore.case = TRUE))
|
|
408
|
+
"pandoc took too long and was stopped." else conditionMessage(ran)
|
|
409
|
+
return(list(ok = FALSE, error = paste0("cite: ", msg)))
|
|
410
|
+
}
|
|
411
|
+
if (isTRUE(ran$timeout)) return(list(ok = FALSE, error = "cite: pandoc took too long and was stopped."))
|
|
412
|
+
if (!identical(as.integer(ran$status), 0L)) {
|
|
413
|
+
first <- strsplit(ran$stderr %||% "", "\n", fixed = TRUE)[[1L]][1L] %||% ""
|
|
414
|
+
return(list(ok = FALSE, error = paste0("cite: pandoc exited ", ran$status, if (nzchar(first)) paste0(": ", first) else "")))
|
|
415
|
+
}
|
|
416
|
+
parts <- cite_split(ran$stdout %||% "", length(valid$groups))
|
|
417
|
+
list(ok = TRUE, labels = parts$labels, bibliography = parts$bibliography)
|
|
418
|
+
}
|