pi-studio 0.9.37 → 0.9.39

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,169 @@
1
+ import { basename, dirname } from "node:path";
2
+
3
+ const STUDIO_QUARTO_LOG_MAX_CHARS = 80_000;
4
+
5
+ /**
6
+ * Return whether a path names a file Studio can hand to Quarto preview.
7
+ * Keep the integration to file-backed Markdown formats Quarto supports directly.
8
+ *
9
+ * @param {unknown} filePath
10
+ * @returns {boolean}
11
+ */
12
+ export function isStudioQuartoDocumentPath(filePath) {
13
+ return typeof filePath === "string" && /\.(?:qmd|md|markdown)$/i.test(filePath.trim());
14
+ }
15
+
16
+ /**
17
+ * Build the deliberately conservative Quarto preview invocation used by Studio.
18
+ * Quarto owns rendering and styling; Studio only hosts its loopback preview URL.
19
+ *
20
+ * @param {string} sourcePath
21
+ * @returns {string[]}
22
+ */
23
+ export function buildStudioQuartoPreviewArgs(sourcePath) {
24
+ if (!isStudioQuartoDocumentPath(sourcePath)) {
25
+ throw new Error("Quarto preview requires a file-backed .qmd, .md, or .markdown document.");
26
+ }
27
+ return [
28
+ "preview",
29
+ sourcePath,
30
+ "--no-browser",
31
+ "--host",
32
+ "127.0.0.1",
33
+ "--port",
34
+ "0",
35
+ "--no-execute",
36
+ ];
37
+ }
38
+
39
+ /**
40
+ * Remove terminal control sequences from Quarto output before parsing or showing it.
41
+ *
42
+ * @param {unknown} value
43
+ * @returns {string}
44
+ */
45
+ export function stripStudioQuartoAnsi(value) {
46
+ return String(value ?? "")
47
+ .replace(/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, "")
48
+ .replace(/\r(?!\n)/g, "\n");
49
+ }
50
+
51
+ /**
52
+ * Keep only loopback HTTP(S) preview URLs and normalize localhost/IPv6 to 127.0.0.1.
53
+ *
54
+ * @param {unknown} value
55
+ * @returns {string|null}
56
+ */
57
+ export function normalizeStudioQuartoLoopbackUrl(value) {
58
+ const raw = String(value ?? "").trim().replace(/[),.;]+$/, "");
59
+ if (!raw) return null;
60
+ try {
61
+ const parsed = new URL(raw);
62
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
63
+ const hostname = parsed.hostname.toLowerCase();
64
+ if (hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "::1" && hostname !== "[::1]") {
65
+ return null;
66
+ }
67
+ parsed.hostname = "127.0.0.1";
68
+ return parsed.href;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Extract Quarto's `Browse at` URL from accumulated stdout/stderr.
76
+ *
77
+ * @param {unknown} output
78
+ * @returns {string|null}
79
+ */
80
+ export function parseStudioQuartoPreviewUrl(output) {
81
+ const clean = stripStudioQuartoAnsi(output);
82
+ const browseMatches = Array.from(clean.matchAll(/Browse at\s+(https?:\/\/[^\s]+)/gi));
83
+ for (let index = browseMatches.length - 1; index >= 0; index -= 1) {
84
+ const normalized = normalizeStudioQuartoLoopbackUrl(browseMatches[index]?.[1]);
85
+ if (normalized) return normalized;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Append cleaned process output while retaining only the most recent bounded log text.
92
+ *
93
+ * @param {unknown} current
94
+ * @param {unknown} chunk
95
+ * @param {number} [maxChars]
96
+ * @returns {string}
97
+ */
98
+ export function appendStudioQuartoLog(current, chunk, maxChars = STUDIO_QUARTO_LOG_MAX_CHARS) {
99
+ const limit = Math.max(1_000, Math.floor(Number(maxChars) || STUDIO_QUARTO_LOG_MAX_CHARS));
100
+ const joined = `${String(current ?? "")}${stripStudioQuartoAnsi(chunk)}`;
101
+ if (joined.length <= limit) return joined;
102
+ return `[earlier Quarto output omitted]\n${joined.slice(-limit)}`;
103
+ }
104
+
105
+ /**
106
+ * Parse the useful, stable subset of `quarto inspect` output.
107
+ *
108
+ * @param {unknown} output
109
+ * @param {string} sourcePath
110
+ * @param {string} [fallbackVersion]
111
+ * @returns {{
112
+ * sourcePath: string,
113
+ * version: string,
114
+ * projectRoot: string,
115
+ * projectType: string,
116
+ * projectLabel: string,
117
+ * outputFile: string,
118
+ * isProject: boolean,
119
+ * }}
120
+ */
121
+ export function parseStudioQuartoInspect(output, sourcePath, fallbackVersion = "") {
122
+ if (!isStudioQuartoDocumentPath(sourcePath)) {
123
+ throw new Error("Quarto inspection requires a file-backed .qmd, .md, or .markdown document.");
124
+ }
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(stripStudioQuartoAnsi(output));
128
+ } catch (error) {
129
+ throw new Error(`Quarto inspect did not return valid JSON: ${error instanceof Error ? error.message : String(error)}`);
130
+ }
131
+ if (!parsed || typeof parsed !== "object") {
132
+ throw new Error("Quarto inspect returned an empty result.");
133
+ }
134
+ const project = parsed.project && typeof parsed.project === "object" ? parsed.project : null;
135
+ const config = project && project.config && typeof project.config === "object" ? project.config : {};
136
+ const projectConfig = config.project && typeof config.project === "object" ? config.project : {};
137
+ const bookConfig = config.book && typeof config.book === "object" ? config.book : {};
138
+ const websiteConfig = config.website && typeof config.website === "object" ? config.website : {};
139
+ const htmlFormat = parsed.formats && typeof parsed.formats === "object" && parsed.formats.html && typeof parsed.formats.html === "object"
140
+ ? parsed.formats.html
141
+ : {};
142
+ const pandoc = htmlFormat.pandoc && typeof htmlFormat.pandoc === "object" ? htmlFormat.pandoc : {};
143
+ const projectRoot = project && typeof project.dir === "string" && project.dir.trim()
144
+ ? project.dir.trim()
145
+ : dirname(sourcePath);
146
+ const projectType = typeof projectConfig.type === "string" && projectConfig.type.trim()
147
+ ? projectConfig.type.trim()
148
+ : (project ? "project" : "document");
149
+ const configuredTitle = [bookConfig.title, websiteConfig.title, config.title]
150
+ .find((value) => typeof value === "string" && value.trim());
151
+ const projectLabel = typeof configuredTitle === "string" && configuredTitle.trim()
152
+ ? configuredTitle.trim()
153
+ : (project ? basename(projectRoot) : basename(sourcePath));
154
+ const inspectedVersion = parsed.quarto && typeof parsed.quarto.version === "string"
155
+ ? parsed.quarto.version.trim()
156
+ : "";
157
+ const projectVersion = project && project.quarto && typeof project.quarto.version === "string"
158
+ ? project.quarto.version.trim()
159
+ : "";
160
+ return {
161
+ sourcePath,
162
+ version: inspectedVersion || projectVersion || String(fallbackVersion || "").trim(),
163
+ projectRoot,
164
+ projectType,
165
+ projectLabel,
166
+ outputFile: typeof pandoc["output-file"] === "string" ? pandoc["output-file"].trim() : "",
167
+ isProject: Boolean(project),
168
+ };
169
+ }