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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -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 &amp; Francis" is not a name.
83
+ for (ent in list(c("&lt;", "<"), c("&gt;", ">"), c("&quot;", "\""), c("&apos;", "'"), c("&amp;", "&"))) {
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
+ }