@dooc-dev/dooc 0.0.1 → 0.0.3

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,2 @@
1
+ /** Package an already bundled module and its relative assets without running build scripts. */
2
+ export declare const packExtension: (root: string, output?: string) => Promise<Buffer>;
@@ -0,0 +1,36 @@
1
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { inflateRawSync } from "node:zlib";
4
+ import { zip } from "../compiler/zip.js";
5
+ import { MAX_EXTENSION_BYTES, MAX_EXTENSION_FILES, readExtensionZip, } from "../core/extension-package.js";
6
+ /** Package an already bundled module and its relative assets without running build scripts. */
7
+ export const packExtension = async (root, output) => {
8
+ const entries = [];
9
+ let size = 0;
10
+ const walk = (folder, prefix = "") => {
11
+ for (const entry of readdirSync(folder, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
12
+ const path = join(folder, entry.name);
13
+ if (output && resolve(path) === resolve(output))
14
+ continue;
15
+ const name = prefix + entry.name;
16
+ if (entry.isSymbolicLink())
17
+ throw new Error(`Plugin assets cannot be symlinks: ${name}`);
18
+ if (name.length > 240)
19
+ throw new Error("Plugin asset path is too long");
20
+ if (entry.isDirectory()) {
21
+ walk(path, `${name}/`);
22
+ continue;
23
+ }
24
+ if (!entry.isFile())
25
+ throw new Error(`Expected a regular plugin asset: ${name}`);
26
+ size += lstatSync(path).size;
27
+ if (size > MAX_EXTENSION_BYTES || entries.length >= MAX_EXTENSION_FILES)
28
+ throw new Error("Plugins are limited to 16 MiB and 256 files");
29
+ entries.push({ name, data: readFileSync(path) });
30
+ }
31
+ };
32
+ walk(root);
33
+ const data = zip(entries);
34
+ await readExtensionZip(data, async (bytes, size) => inflateRawSync(bytes, { maxOutputLength: Math.max(1, size) }));
35
+ return data;
36
+ };
@@ -0,0 +1,10 @@
1
+ import type { LocalPluginStorage } from "../plugins/local-storage.js";
2
+ import { type ViewerPluginRegistry, type ViewerPlugins } from "./plugin-config.js";
3
+ export declare const EXTENSIONS_DIR = ".dooc/plugins";
4
+ /** ZIPs found on disk stay inactive until the browser approves their exact content hash. */
5
+ export declare const createExtensionManager: (root: string, registry: ViewerPluginRegistry & {
6
+ configuration?: () => ViewerPlugins;
7
+ }, initial: Pick<ViewerPlugins, "plugins" | "mocks">, personal?: LocalPluginStorage) => ((input: Record<string, unknown>) => Promise<unknown>) & {
8
+ restore(): Promise<string[]>;
9
+ dispose: () => void;
10
+ };
@@ -0,0 +1,302 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { inflateRawSync } from "node:zlib";
7
+ import { textfilePath } from "../compiler/textfile.js";
8
+ import { zip } from "../compiler/zip.js";
9
+ import { extensionFile, extensionManifest, MAX_EXTENSION_BYTES, readExtensionZip, } from "../core/extension-package.js";
10
+ import { validateViewerPlugins, } from "./plugin-config.js";
11
+ export const EXTENSIONS_DIR = ".dooc/plugins";
12
+ const hash = (source) => createHash("sha256").update(source).digest("hex");
13
+ const unpack = (data) => readExtensionZip(data, async (bytes, size) => inflateRawSync(bytes, { maxOutputLength: Math.max(1, size) }));
14
+ /** ZIPs found on disk stay inactive until the browser approves their exact content hash. */
15
+ export const createExtensionManager = (root, registry, initial, personal) => {
16
+ const active = new Map();
17
+ const scope = createHash("sha256").update(realpathSync(root)).digest("hex");
18
+ const approvalFile = `extensions-${scope}.json`;
19
+ const approval = () => {
20
+ const value = personal?.read(approvalFile);
21
+ return value && typeof value === "object" && !Array.isArray(value)
22
+ ? value
23
+ : {};
24
+ };
25
+ const remember = (file, revision) => {
26
+ if (!personal)
27
+ return;
28
+ const approved = approval();
29
+ if (revision)
30
+ approved[file] = revision;
31
+ else
32
+ delete approved[file];
33
+ personal.write(approvalFile, approved);
34
+ };
35
+ let queued = Promise.resolve();
36
+ const directory = () => {
37
+ for (const name of [".dooc", EXTENSIONS_DIR]) {
38
+ const entry = lstatSync(join(root, name), { throwIfNoEntry: false });
39
+ if (entry && (!entry.isDirectory() || entry.isSymbolicLink()))
40
+ throw new Error("Plugin folders must be local directories");
41
+ }
42
+ return textfilePath(root, EXTENSIONS_DIR);
43
+ };
44
+ const read = (name) => {
45
+ const path = join(directory(), name);
46
+ const entry = lstatSync(path, { throwIfNoEntry: false });
47
+ if (!entry)
48
+ return null;
49
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > MAX_EXTENSION_BYTES)
50
+ throw new Error("Expected a plugin ZIP smaller than 16 MiB");
51
+ return readFileSync(path);
52
+ };
53
+ const disable = (name) => {
54
+ const value = active.get(name);
55
+ if (!value)
56
+ return;
57
+ for (const plugin of value.config.plugins)
58
+ if (!registry.configuration || registry.configuration().plugins.includes(plugin))
59
+ registry.unregister(plugin.id);
60
+ rmSync(value.directory, { recursive: true, force: true });
61
+ active.delete(name);
62
+ };
63
+ const list = async () => {
64
+ const path = directory();
65
+ const entries = lstatSync(path, { throwIfNoEntry: false })
66
+ ? readdirSync(path, { withFileTypes: true })
67
+ : [];
68
+ const items = [];
69
+ for (const entry of entries.filter((entry) => entry.isFile() && entry.name.endsWith(".zip"))) {
70
+ let file;
71
+ try {
72
+ file = extensionFile(entry.name);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ const data = read(file);
78
+ if (!data)
79
+ continue;
80
+ const revision = hash(data);
81
+ const loaded = active.get(file);
82
+ if (loaded &&
83
+ (loaded.hash !== revision ||
84
+ (registry.configuration &&
85
+ loaded.config.plugins.some((plugin) => !registry.configuration?.().plugins.includes(plugin)))))
86
+ disable(file);
87
+ try {
88
+ const pkg = await unpack(data);
89
+ items.push({
90
+ file,
91
+ name: pkg.name,
92
+ icon: pkg.icon ??
93
+ active.get(file)?.config.plugins.find((plugin) => plugin.icon)?.icon,
94
+ version: pkg.version,
95
+ hash: revision,
96
+ size: data.length,
97
+ enabled: active.has(file),
98
+ plugins: active.get(file)?.config.plugins.map((plugin) => plugin.id) ?? [],
99
+ });
100
+ }
101
+ catch (error) {
102
+ disable(file);
103
+ items.push({
104
+ file,
105
+ name: file,
106
+ version: "",
107
+ hash: revision,
108
+ size: data.length,
109
+ enabled: false,
110
+ plugins: [],
111
+ error: error instanceof Error ? error.message : String(error),
112
+ });
113
+ }
114
+ }
115
+ for (const name of active.keys())
116
+ if (!items.some((item) => item.file === name))
117
+ disable(name);
118
+ const owned = new Set([...active.values()].flatMap((value) => value.config.plugins));
119
+ for (const plugin of (registry.configuration?.() ?? initial).plugins) {
120
+ if (owned.has(plugin))
121
+ continue;
122
+ items.push({
123
+ file: `viewer/${plugin.id}`,
124
+ name: plugin.name ?? plugin.id,
125
+ icon: plugin.icon,
126
+ version: "",
127
+ hash: "",
128
+ size: 0,
129
+ enabled: true,
130
+ plugins: [plugin.id],
131
+ readonly: true,
132
+ });
133
+ }
134
+ return { scope, items: items.sort((a, b) => a.name.localeCompare(b.name)) };
135
+ };
136
+ const activate = async (name, data, save, base) => {
137
+ const revision = hash(data);
138
+ if (!save && active.get(name)?.hash === revision)
139
+ return;
140
+ const pkg = await unpack(data);
141
+ const folder = mkdtempSync(join(tmpdir(), "dooc-extension-"));
142
+ let timer;
143
+ try {
144
+ for (const file of pkg.files) {
145
+ const path = join(folder, file.name);
146
+ mkdirSync(dirname(path), { recursive: true });
147
+ writeFileSync(path, file.data, { flag: "wx" });
148
+ }
149
+ const module = await Promise.race([
150
+ import(pathToFileURL(join(folder, pkg.main)).href),
151
+ new Promise((_, reject) => {
152
+ timer = setTimeout(() => reject(new Error("Plugin initialization timed out")), 10000);
153
+ }),
154
+ ]);
155
+ const config = validateViewerPlugins(module.default);
156
+ if (!config.plugins.length ||
157
+ config.setup ||
158
+ config.gitOrigin ||
159
+ config.workspace ||
160
+ config.allowedDomains)
161
+ throw new Error("Export a viewer configuration with plugins and optional mocks");
162
+ const live = registry.configuration?.();
163
+ const owned = active.get(name)?.config.plugins ?? [];
164
+ for (const plugin of config.plugins) {
165
+ const existing = live?.plugins.find((item) => item.id === plugin.id);
166
+ if (existing && !owned.includes(existing))
167
+ throw new Error(`Plugin ID is already registered: ${plugin.id}`);
168
+ }
169
+ if (Object.keys(config.mocks).some((id) => !config.plugins.some((plugin) => plugin.id === id)))
170
+ throw new Error("Mocks must belong to plugins in this package");
171
+ const other = live
172
+ ? [
173
+ {
174
+ plugins: live.plugins.filter((plugin) => !owned.includes(plugin)),
175
+ mocks: Object.fromEntries(Object.entries(live.mocks).filter(([id]) => !owned.some((plugin) => plugin.id === id))),
176
+ },
177
+ ]
178
+ : [
179
+ initial,
180
+ ...[...active]
181
+ .filter(([key]) => key !== name)
182
+ .map(([, value]) => value.config),
183
+ ];
184
+ validateViewerPlugins({
185
+ plugins: [...other.flatMap((config) => config.plugins), ...config.plugins],
186
+ mocks: Object.assign({}, ...other.map((config) => config.mocks), config.mocks),
187
+ });
188
+ if (save) {
189
+ const current = read(name);
190
+ if ((current ? hash(current) : null) !== base)
191
+ throw new Error("Plugin changed. Reopen it before saving.");
192
+ const target = join(directory(), name);
193
+ mkdirSync(dirname(target), { recursive: true });
194
+ const temporary = join(dirname(target), `.upload-${Date.now()}.tmp`);
195
+ try {
196
+ writeFileSync(temporary, data, { flag: "wx" });
197
+ renameSync(temporary, target);
198
+ }
199
+ finally {
200
+ rmSync(temporary, { force: true });
201
+ }
202
+ }
203
+ disable(name);
204
+ for (const plugin of config.plugins)
205
+ registry.register(plugin, { mock: config.mocks[plugin.id] });
206
+ active.set(name, { hash: revision, config, directory: folder });
207
+ remember(name, revision);
208
+ }
209
+ finally {
210
+ clearTimeout(timer);
211
+ if (active.get(name)?.directory !== folder)
212
+ rmSync(folder, { recursive: true, force: true });
213
+ }
214
+ };
215
+ const perform = async (input) => {
216
+ if (input.action === "list")
217
+ return list();
218
+ const name = extensionFile(input.file);
219
+ const current = read(name);
220
+ if (input.action === "read") {
221
+ if (!current)
222
+ throw new Error("Plugin not found");
223
+ const pkg = await unpack(current);
224
+ return {
225
+ file: name,
226
+ hash: hash(current),
227
+ ...pkg,
228
+ files: pkg.files.map((file) => ({
229
+ name: file.name,
230
+ data: Buffer.from(file.data).toString("base64"),
231
+ })),
232
+ };
233
+ }
234
+ if (input.action === "install") {
235
+ if (typeof input.data !== "string" ||
236
+ input.data.length > Math.ceil((MAX_EXTENSION_BYTES * 4) / 3) ||
237
+ input.data.length % 4 !== 0 ||
238
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(input.data))
239
+ throw new Error("Expected a ZIP smaller than 16 MiB");
240
+ if ((current ? hash(current) : null) !== input.base)
241
+ throw new Error("Plugin changed. Reopen it before saving.");
242
+ await activate(name, Buffer.from(input.data, "base64"), true, input.base);
243
+ }
244
+ else {
245
+ if (!current || hash(current) !== input.hash)
246
+ throw new Error("Plugin changed. Reopen it before continuing.");
247
+ if (input.action === "enable")
248
+ await activate(name, current, false, input.hash);
249
+ else if (input.action === "disable") {
250
+ disable(name);
251
+ remember(name);
252
+ }
253
+ else if (input.action === "delete") {
254
+ unlinkSync(join(directory(), name));
255
+ disable(name);
256
+ remember(name);
257
+ }
258
+ else if (input.action === "edit") {
259
+ if (typeof input.source !== "string" ||
260
+ Buffer.byteLength(input.source) > MAX_EXTENSION_BYTES ||
261
+ input.source.includes("\0"))
262
+ throw new Error("Expected UTF-8 text smaller than 16 MiB");
263
+ const pkg = await unpack(current);
264
+ const file = pkg.files.find((file) => file.name === input.path);
265
+ if (!file)
266
+ throw new Error("Plugin file not found");
267
+ file.data = Buffer.from(input.source);
268
+ extensionManifest(pkg.files);
269
+ const data = zip(pkg.files.map((file) => ({ name: file.name, data: Buffer.from(file.data) })));
270
+ await activate(name, data, true, input.hash);
271
+ }
272
+ else
273
+ throw new Error("Unknown plugin action");
274
+ }
275
+ return list();
276
+ };
277
+ const request = (input) => {
278
+ const result = queued.then(() => perform(input));
279
+ queued = result.catch(() => { });
280
+ return result;
281
+ };
282
+ return Object.assign(request, {
283
+ async restore() {
284
+ const approved = approval();
285
+ const errors = [];
286
+ for (const item of (await list()).items)
287
+ if (!item.readonly && !item.error && approved[item.file] === item.hash) {
288
+ try {
289
+ await request({ action: "enable", file: item.file, hash: item.hash });
290
+ }
291
+ catch (error) {
292
+ errors.push(`${item.file}: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ }
295
+ return errors;
296
+ },
297
+ dispose: () => {
298
+ for (const name of active.keys())
299
+ disable(name);
300
+ },
301
+ });
302
+ };
@@ -59,6 +59,8 @@ export const classifyChange = (rel) => {
59
59
  p === "src/viewer/language-picker.ts" ||
60
60
  p === "src/viewer/theme-picker.ts" ||
61
61
  p === "src/viewer/ui-client.ts" ||
62
+ p === "src/viewer/extensions-ui.ts" ||
63
+ p === "src/viewer/extensions-store.ts" ||
62
64
  p === "src/viewer/plugins-ui.ts" ||
63
65
  p === "src/viewer/plugin-view.ts" ||
64
66
  p === "src/viewer/plugin-resize.ts" ||
@@ -178,7 +178,7 @@
178
178
  "File preview": "File preview",
179
179
  "Files": "Files",
180
180
  "Search files": "Search files",
181
- "Extension": "Extension",
181
+ "Plugin": "Plugin",
182
182
  "Default folder layout": "Default folder layout",
183
183
  "Folder restructure": "Folder restructure",
184
184
  "Move": "Move",
@@ -290,5 +290,28 @@
290
290
  "Reload actions before saving again.": "Reload actions before saving again.",
291
291
  "Saved, but refresh failed. Reload actions before saving again.": "Saved, but refresh failed. Reload actions before saving again.",
292
292
  "Actions could not be loaded.": "Actions could not be loaded.",
293
- "An open draft is no longer editable. Copy or cancel it before reloading.": "An open draft is no longer editable. Copy or cancel it before reloading."
293
+ "An open draft is no longer editable. Copy or cancel it before reloading.": "An open draft is no longer editable. Copy or cancel it before reloading.",
294
+ "Changed elsewhere. Your input is kept; edit again to apply it.": "Changed elsewhere. Your input is kept; edit again to apply it.",
295
+ "Plugins": "Plugins",
296
+ "Add plugins": "Add plugins",
297
+ "Plugins are saved in this browser. Run server plugins in a local dooc editor.": "Plugins are saved in this browser. Run server plugins in a local dooc editor.",
298
+ "Drag and drop here, or click to browse.": "Drag and drop here, or click to browse.",
299
+ "Invalid package": "Invalid package",
300
+ "Browser storage": "Browser storage",
301
+ "Enabled": "Enabled",
302
+ "Disabled": "Disabled",
303
+ "Enable": "Enable",
304
+ "Disable": "Disable",
305
+ "Delete plugin {name}?": "Delete plugin {name}?",
306
+ "Replace plugin {name}?": "Replace plugin {name}?",
307
+ "Discard plugin changes?": "Discard plugin changes?",
308
+ "Plugin file": "Plugin file",
309
+ "Apply changes": "Apply changes",
310
+ "Binary asset — replace it by importing a new ZIP.": "Binary asset — replace it by importing a new ZIP.",
311
+ "Choose a ZIP smaller than 16 MiB": "Choose a ZIP smaller than 16 MiB",
312
+ "Sign in": "Sign in",
313
+ "Sign out": "Sign out",
314
+ "Not signed in": "Not signed in",
315
+ "Continue login": "Continue login",
316
+ "Provided by viewer": "Provided by viewer"
294
317
  }
@@ -178,7 +178,7 @@
178
178
  "File preview": "ファイルプレビュー",
179
179
  "Files": "ファイル",
180
180
  "Search files": "ファイルを検索",
181
- "Extension": "拡張子",
181
+ "Plugin": "拡張子",
182
182
  "Default folder layout": "既定のフォルダー配置",
183
183
  "Folder restructure": "フォルダーを整理",
184
184
  "Move": "移動",
@@ -290,5 +290,28 @@
290
290
  "Reload actions before saving again.": "再保存する前に再読み込みしてください。",
291
291
  "Saved, but refresh failed. Reload actions before saving again.": "保存は完了しましたが、再取得に失敗しました。再保存する前に再読み込みしてください。",
292
292
  "Actions could not be loaded.": "操作を取得できませんでした。",
293
- "An open draft is no longer editable. Copy or cancel it before reloading.": "編集中の項目を更新する権限がありません。再読み込みの前に入力をコピーするか、キャンセルしてください。"
293
+ "An open draft is no longer editable. Copy or cancel it before reloading.": "編集中の項目を更新する権限がありません。再読み込みの前に入力をコピーするか、キャンセルしてください。",
294
+ "Changed elsewhere. Your input is kept; edit again to apply it.": "外部で変更されました。入力は保持しています。再度編集すると反映します。",
295
+ "Plugins": "Plugins",
296
+ "Add plugins": "プラグインを追加",
297
+ "Plugins are saved in this browser. Run server plugins in a local dooc editor.": "プラグインはこのブラウザーに保存されます。サーバープラグインの実行にはローカルの dooc を使います。",
298
+ "Drag and drop here, or click to browse.": "ここにドラッグ&ドロップ、またはクリックして選択",
299
+ "Invalid package": "無効なパッケージ",
300
+ "Browser storage": "ブラウザーに保存",
301
+ "Enabled": "有効",
302
+ "Disabled": "無効",
303
+ "Enable": "有効にする",
304
+ "Disable": "無効にする",
305
+ "Delete plugin {name}?": "プラグイン「{name}」を削除しますか?",
306
+ "Replace plugin {name}?": "プラグイン「{name}」を置き換えますか?",
307
+ "Discard plugin changes?": "プラグインの未適用の変更を破棄しますか?",
308
+ "Plugin file": "プラグインのファイル",
309
+ "Apply changes": "変更を適用",
310
+ "Binary asset — replace it by importing a new ZIP.": "バイナリファイルです。新しい ZIP を読み込むと置き換えられます。",
311
+ "Choose a ZIP smaller than 16 MiB": "16 MiB 以下の ZIP を選択してください",
312
+ "Sign in": "ログイン",
313
+ "Sign out": "ログアウト",
314
+ "Not signed in": "未ログイン",
315
+ "Continue login": "ログインを続ける",
316
+ "Provided by viewer": "開発用プラグイン"
294
317
  }
@@ -178,7 +178,7 @@
178
178
  "File preview": "文件预览",
179
179
  "Files": "文件",
180
180
  "Search files": "搜索文件",
181
- "Extension": "扩展名",
181
+ "Plugin": "插件名",
182
182
  "Default folder layout": "默认文件夹布局",
183
183
  "Folder restructure": "整理文件夹",
184
184
  "Move": "移动",
@@ -290,5 +290,28 @@
290
290
  "Reload actions before saving again.": "再次保存前请重新加载。",
291
291
  "Saved, but refresh failed. Reload actions before saving again.": "保存已完成,但刷新失败。再次保存前请重新加载。",
292
292
  "Actions could not be loaded.": "无法加载操作。",
293
- "An open draft is no longer editable. Copy or cancel it before reloading.": "正在编辑的草稿已无法修改。重新加载前请复制或取消草稿。"
293
+ "An open draft is no longer editable. Copy or cancel it before reloading.": "正在编辑的草稿已无法修改。重新加载前请复制或取消草稿。",
294
+ "Changed elsewhere. Your input is kept; edit again to apply it.": "内容已在其他位置更改。已保留输入,再次编辑即可应用。",
295
+ "Plugins": "插件",
296
+ "Add plugins": "添加插件",
297
+ "Plugins are saved in this browser. Run server plugins in a local dooc editor.": "插件保存在此浏览器中。服务器插件需在本地 dooc 编辑器中运行。",
298
+ "Drag and drop here, or click to browse.": "拖放到此处,或点击选择。",
299
+ "Invalid package": "无效包",
300
+ "Browser storage": "浏览器存储",
301
+ "Enabled": "已启用",
302
+ "Disabled": "已禁用",
303
+ "Enable": "启用",
304
+ "Disable": "禁用",
305
+ "Delete plugin {name}?": "删除插件 {name}?",
306
+ "Replace plugin {name}?": "替换插件 {name}?",
307
+ "Discard plugin changes?": "放弃未应用的插件更改?",
308
+ "Plugin file": "插件文件",
309
+ "Apply changes": "应用更改",
310
+ "Binary asset — replace it by importing a new ZIP.": "二进制资源。导入新 ZIP 以替换。",
311
+ "Choose a ZIP smaller than 16 MiB": "请选择不超过 16 MiB 的 ZIP",
312
+ "Sign in": "登录",
313
+ "Sign out": "退出登录",
314
+ "Not signed in": "未登录",
315
+ "Continue login": "继续登录",
316
+ "Provided by viewer": "由查看器提供"
294
317
  }
@@ -1,3 +1,2 @@
1
1
  import { type PluginView } from "../plugins/view.js";
2
- /** Authored HTML cannot run scripts; auto height permits only a nonce-protected reporter. */
3
2
  export declare const pluginViewHtml: (input: PluginView) => string;
@@ -1,28 +1,6 @@
1
- import { escapeHtml } from "../core/html.js";
2
1
  import { validatePluginView } from "../plugins/view.js";
3
2
  import { pluginBlocksHtml } from "./plugin-blocks.js";
4
- /** Authored HTML cannot run scripts; auto height permits only a nonce-protected reporter. */
5
3
  export const pluginViewHtml = (input) => {
6
4
  const view = validatePluginView(input);
7
- if (view.format === "blocks")
8
- return `<div class="plugin-blocks">${pluginBlocksHtml(view.blocks)}</div>`;
9
- if (view.format === "html") {
10
- const auto = view.height === "auto";
11
- const nonce = auto ? crypto.randomUUID() : "";
12
- const script = auto
13
- ? `<script nonce="${nonce}">(()=>{const report=()=>parent.postMessage({doocPluginHeight:Math.ceil(document.body.getBoundingClientRect().height)},"*");new ResizeObserver(report).observe(document.body);report();})()</script>`
14
- : "";
15
- const source = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';${auto ? ` script-src 'nonce-${nonce}';` : ""} base-uri 'none'; form-action 'none'"><meta name="referrer" content="no-referrer"><style>html,body{margin:0}body{display:flow-root}</style></head><body>${view.html}${script}</body></html>`;
16
- return `<iframe title="Plugin content" sandbox="${auto ? "allow-scripts" : ""}"${auto ? ' data-plugin-auto-height=""' : ""} referrerpolicy="no-referrer" srcdoc="${escapeHtml(source)}" style="width:100%;height:${auto ? 120 : (view.height ?? 240)}px;border:0"></iframe>`;
17
- }
18
- switch (view.preset) {
19
- case "card":
20
- return `<article class="plugin-card"><strong>${escapeHtml(view.title)}</strong><p>${escapeHtml(view.text)}</p></article>`;
21
- case "list":
22
- return `<ul>${view.items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`;
23
- case "table":
24
- return `<table><thead><tr>${view.headers.map((header) => `<th scope="col">${escapeHtml(header)}</th>`).join("")}</tr></thead><tbody>${view.rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell === null ? "" : String(cell))}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
25
- case "code":
26
- return `<pre><code>${escapeHtml(JSON.stringify(view.value, null, 2))}</code></pre>`;
27
- }
5
+ return `<div class="plugin-blocks">${pluginBlocksHtml(view.blocks)}</div>`;
28
6
  };
@@ -1,8 +1,12 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { PluginCredentialStore } from "../plugins/local-storage.js";
2
3
  import type { DataPlugin } from "../plugins/types.js";
3
4
  export type { GitWorkspace, StorageWorkspace, WorkspaceSource } from "../core/workspace.js";
4
5
  import { type ViewerPluginRegistry, type ViewerPlugins } from "./plugin-config.js";
5
6
  export type { GitContext, GitOrigin, GitOriginAction, GitOriginState, GitPullRequest, } from "./git-types.js";
6
7
  export type { PluginMock, ViewerPluginRegistry, ViewerPlugins, ViewerRuntime, } from "./plugin-config.js";
7
8
  /** Local viewer only: the cookie identifies a browser session, never a document-supplied user. */
8
- export declare const createPluginHandler: (plugins: readonly DataPlugin[], presentation?: Pick<ViewerPlugins, "mocks">) => ((req: IncomingMessage, res: ServerResponse) => Promise<boolean>) & ViewerPluginRegistry;
9
+ export declare const createPluginHandler: (plugins: readonly DataPlugin[], presentation?: Pick<ViewerPlugins, "mocks">, credentials?: PluginCredentialStore) => ((req: IncomingMessage, res: ServerResponse) => Promise<boolean>) & ViewerPluginRegistry & {
10
+ loginLink: (plugin: string, origin: string) => string;
11
+ configuration: () => ViewerPlugins;
12
+ };