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.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -1
- package/client/studio-client.js +537 -16
- package/client/studio.css +253 -0
- package/index.ts +474 -5
- package/package.json +1 -1
- package/shared/studio-mermaid.js +39 -3
- package/shared/studio-quarto-preview.js +169 -0
|
@@ -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
|
+
}
|