@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.
@@ -32,7 +32,7 @@ const readInput = async (req) => {
32
32
  }
33
33
  };
34
34
  /** Local viewer only: the cookie identifies a browser session, never a document-supplied user. */
35
- export const createPluginHandler = (plugins, presentation = { mocks: {} }) => {
35
+ export const createPluginHandler = (plugins, presentation = { mocks: {} }, credentials) => {
36
36
  let config = validateViewerPlugins({ plugins, ...presentation });
37
37
  let mocks = new Map(Object.entries(config.mocks));
38
38
  let origins = new Map([...mocks].map(([id, mock]) => [mock.origin, { id, ...mock }]));
@@ -43,7 +43,7 @@ export const createPluginHandler = (plugins, presentation = { mocks: {} }) => {
43
43
  const mock = origins.get(new URL(request.url).origin);
44
44
  return mock ? mock.handle(request) : fetch(input, init);
45
45
  };
46
- const runtime = createPluginRuntime(config.plugins, transport);
46
+ const runtime = createPluginRuntime(config.plugins, transport, credentials);
47
47
  const cookie = `dooc-plugins-${randomBytes(8).toString("hex")}`;
48
48
  let revision = 0;
49
49
  const streams = new Set();
@@ -84,6 +84,24 @@ export const createPluginHandler = (plugins, presentation = { mocks: {} }) => {
84
84
  });
85
85
  },
86
86
  };
87
+ const tickets = new Map();
88
+ const loginLink = (plugin, origin) => {
89
+ if (!config.plugins.some((item) => item.id === plugin))
90
+ throw new Error("Plugin is not configured");
91
+ for (const [key, value] of tickets)
92
+ if (value.expires <= Date.now())
93
+ tickets.delete(key);
94
+ const ticket = randomBytes(32).toString("hex");
95
+ tickets.set(ticket, { plugin, origin, expires: Date.now() + 10 * 60 * 1000 });
96
+ return `${origin}/api/plugins/login/${plugin}?ticket=${ticket}`;
97
+ };
98
+ const authorizationUrl = (session, plugin, host) => {
99
+ const authorization = new URL(runtime.connect(session, plugin, `http://${host}/api/plugins/callback/${plugin}`));
100
+ const mock = origins.get(authorization.origin);
101
+ return mock
102
+ ? `http://${host}/api/plugins/mock/${mock.id}${authorization.pathname}${authorization.search}`
103
+ : authorization.href;
104
+ };
87
105
  const handle = async (req, res) => {
88
106
  const url = new URL(req.url ?? "/", "http://localhost");
89
107
  if (url.pathname !== "/api/plugins" && !url.pathname.startsWith("/api/plugins/"))
@@ -150,11 +168,42 @@ export const createPluginHandler = (plugins, presentation = { mocks: {} }) => {
150
168
  .map((part) => part.trim())
151
169
  .find((part) => part.startsWith(`${cookie}=`))
152
170
  ?.slice(cookie.length + 1) ?? "";
171
+ const createSession = () => {
172
+ session = runtime.createSession();
173
+ res.setHeader("set-cookie", `${cookie}=${session}; HttpOnly; SameSite=Lax; Path=/api/plugins; Max-Age=28800`);
174
+ };
175
+ const login = /^\/api\/plugins\/login\/([a-z][a-z0-9-]{0,63})$/.exec(url.pathname);
176
+ if (req.method === "GET" && login?.[1]) {
177
+ const key = url.searchParams.get("ticket") ?? "";
178
+ const ticket = tickets.get(key);
179
+ if (!ticket ||
180
+ ticket.expires <= Date.now() ||
181
+ ticket.plugin !== login[1] ||
182
+ ticket.origin !== `http://${host}`)
183
+ throw new PluginError(400, "Invalid or expired login link");
184
+ tickets.delete(key);
185
+ if (!runtime.hasSession(session))
186
+ createSession();
187
+ res.writeHead(303, { location: authorizationUrl(session, ticket.plugin, host) });
188
+ res.end();
189
+ return true;
190
+ }
153
191
  const callback = /^\/api\/plugins\/callback\/([a-z][a-z0-9-]{0,63})$/.exec(url.pathname);
154
192
  if (req.method === "GET" && callback?.[1]) {
155
- await runtime.callback(session, callback[1], `http://${host}${url.pathname}`, url.searchParams);
156
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
157
- res.end('<!doctype html><html lang="en"><meta charset="utf-8"><title>Plugin connected</title><p>Connected. Close this tab and choose Load data in dooc.</p></html>');
193
+ let cancelled = false;
194
+ try {
195
+ await runtime.callback(session, callback[1], `http://${host}${url.pathname}`, url.searchParams);
196
+ }
197
+ catch (error) {
198
+ if (!isPluginError(error) ||
199
+ error.message !== "OAuth authorization was not completed")
200
+ throw error;
201
+ cancelled = true;
202
+ }
203
+ res.writeHead(cancelled ? 400 : 200, {
204
+ "content-type": "text/html; charset=utf-8",
205
+ });
206
+ res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>${cancelled ? "Sign-in not completed" : "Plugin connected"}</title><p>${cancelled ? "Sign-in was not completed. No account was connected." : "Connected."} You can close this tab and return to dooc.</p></html>`);
158
207
  return true;
159
208
  }
160
209
  if (url.pathname !== "/api/plugins")
@@ -165,26 +214,27 @@ export const createPluginHandler = (plugins, presentation = { mocks: {} }) => {
165
214
  const input = await readInput(req);
166
215
  if (input.action === "resolve") {
167
216
  const plugin = runtime.resolve(input.url);
217
+ if (plugin && credentials && !runtime.hasSession(session))
218
+ createSession();
168
219
  send(200, {
169
220
  plugin,
221
+ ...(plugin && runtime.hasSession(session) && runtime.status(session, plugin)
222
+ ? { connected: true }
223
+ : {}),
170
224
  ...(plugin && mocks.get(plugin)?.autoConnect ? { autoConnect: true } : {}),
171
225
  });
172
226
  return true;
173
227
  }
174
228
  if (typeof input.plugin !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(input.plugin))
175
229
  throw new PluginError(400, "Invalid plugin ID");
230
+ if (!runtime.hasSession(session) &&
231
+ (credentials || input.action === "connect" || input.action === "status"))
232
+ createSession();
176
233
  if (input.action === "connect") {
177
- if (!runtime.hasSession(session)) {
178
- session = runtime.createSession();
179
- res.setHeader("set-cookie", `${cookie}=${session}; HttpOnly; SameSite=Lax; Path=/api/plugins; Max-Age=28800`);
180
- }
181
- const authorization = new URL(runtime.connect(session, input.plugin, `http://${host}/api/plugins/callback/${input.plugin}`));
182
- const mock = origins.get(authorization.origin);
183
- send(200, {
184
- url: mock
185
- ? `http://${host}/api/plugins/mock/${mock.id}${authorization.pathname}${authorization.search}`
186
- : authorization.href,
187
- });
234
+ send(200, { url: authorizationUrl(session, input.plugin, host) });
235
+ }
236
+ else if (input.action === "status") {
237
+ send(200, { user: runtime.status(session, input.plugin) ?? null });
188
238
  }
189
239
  else if (input.action === "disconnect") {
190
240
  if (runtime.hasSession(session))
@@ -210,5 +260,8 @@ export const createPluginHandler = (plugins, presentation = { mocks: {} }) => {
210
260
  }
211
261
  return true;
212
262
  };
213
- return Object.assign(handle, registry);
263
+ return Object.assign(handle, registry, {
264
+ loginLink,
265
+ configuration: () => config,
266
+ });
214
267
  };
@@ -203,4 +203,8 @@ export interface BundleCache {
203
203
  export declare const createBundleCache: () => BundleCache;
204
204
  /** Client bundle (src/<entry> as an IIFE). Sourcemap for the server, minified for publish. */
205
205
  export declare const bundleClient: (entry: string, publish?: boolean) => Promise<string>;
206
- export declare const startServer: (mode: ServeMode, port?: number, pluginModule?: string) => Promise<ViewerRuntime>;
206
+ export declare const startServer: (mode: ServeMode, port?: number, pluginModule?: string, startup?: {
207
+ file?: string;
208
+ data?: string;
209
+ login?: string;
210
+ }) => Promise<ViewerRuntime>;
@@ -30,8 +30,10 @@ import { readManifest, writeManifest } from "../compiler/manifest.js";
30
30
  import { assembleDoc, createDocOps, isOpError } from "../compiler/ops.js";
31
31
  import { walkBlocks } from "../core/blocks.js";
32
32
  import { docIdOfFile } from "../core/content-metadata.js";
33
+ import { MAX_EXTENSION_BYTES } from "../core/extension-package.js";
33
34
  import { figureFrameHtml, mountFigureFrames } from "../core/figure-frame.js";
34
35
  import { blocksHtml, docHtml, escapeHtml, termsHtml } from "../core/html.js";
36
+ import { PLUGINS_ICON } from "../core/icons.js";
35
37
  import { DEFAULT_LANG, isLangTag, resolveLanguage, } from "../core/language.js";
36
38
  import { defaultPreviewSettings, parsePreviewSettings, } from "../core/previewers.js";
37
39
  import { ancestorsOf, childrenOf, docPath, resolveDocPath } from "../core/routing.js";
@@ -44,10 +46,12 @@ import { mountThemeVariants } from "../core/theme-variants.js";
44
46
  import { mountToc } from "../core/toc-ui.js";
45
47
  import { mountVideos } from "../core/video-ui.js";
46
48
  import { parseWorkspaceSource, workspaceLabel } from "../core/workspace.js";
49
+ import { createLocalPluginStorage } from "../plugins/local-storage.js";
47
50
  import { readViewerAsset, prebuilt as usePrebuiltAssets } from "./assets.js";
48
51
  import { mountBundleNetwork } from "./bundle-network.js";
49
52
  import { codeFileResponse } from "./code-file.js";
50
53
  import { isLocalToolRequest, runCodeTool, validateCodeTools, validateTextfileCode, } from "./editor-tools.js";
54
+ import { createExtensionManager, EXTENSIONS_DIR } from "./extensions.js";
51
55
  import { fileReferences } from "./file-references.js";
52
56
  import { mountFontPickers } from "./font-picker.js";
53
57
  import { createFontSettings } from "./font-settings.js";
@@ -399,6 +403,7 @@ const DOCS_ICON = icon('<path d="M10 5.2c-1.2-1-2.9-1.5-4.7-1.5-.7 0-1.3.6-1.3 1
399
403
  const CHEVRON_ICON = icon('<path d="M8 5.5 12.5 10 8 14.5"/>', 14);
400
404
  const GLOBE_ICON = icon('<circle cx="10" cy="10" r="7"/><ellipse cx="10" cy="10" rx="3" ry="7"/><path d="M3 10h14"/>');
401
405
  const BOX_ICON = icon('<path d="M3.5 6.5 10 3l6.5 3.5v7L10 17l-6.5-3.5v-7Z"/><path d="M3.5 6.5 10 10l6.5-3.5M10 10v7"/>');
406
+ const STATICS_ICON = icon('<path d="M3 3v14h14M7 13V9m4 4V5m4 8V7"/>');
402
407
  const GIT_ICON = icon('<path d="M5.5 6.5v7M14.5 6.5v1a4 4 0 0 1-4 4h-5"/><circle cx="5.5" cy="4.5" r="2"/><circle cx="5.5" cy="15.5" r="2"/><circle cx="14.5" cy="4.5" r="2"/>');
403
408
  const STORAGE_ICON = icon('<path d="m3 12 2-8h10l2 8"/><rect x="3" y="12" width="14" height="5" rx="1"/><path d="M13 14.5h1"/>');
404
409
  const workspaceName = (site) => site.workspace?.protocol === "storage" ? workspaceLabel(site.workspace) : "Files";
@@ -644,11 +649,10 @@ img.doc-icon { flex: none; width: 18px; height: 18px; object-fit: contain; }
644
649
  .nav-row > a.cur .doc-icon-frame > img, .nav-doc-row:has(> a.cur) > .nav-doc-icon .doc-icon-frame > img { visibility: hidden; }
645
650
  .nav-row > a.cur .doc-icon-frame::after, .nav-doc-row:has(> a.cur) > .nav-doc-icon .doc-icon-frame::after {
646
651
  content: ""; position: absolute; inset: 0; background: currentColor; mask: var(--doc-icon) center / contain no-repeat; }
647
- .nav-count { margin-left: auto; padding-left: 8px; color: var(--c-fg-faint); font-size: 11px; }
648
652
  body.sidebar-closed #sidebar-content { display: block; overflow-y: auto; scrollbar-width: none; padding: 6px 0; }
649
653
  body.sidebar-closed #nav-groups { display: block; }
650
654
  body.sidebar-closed #nav-docs { scrollbar-width: none; }
651
- body.sidebar-closed #nav-groups :is(.nav-label, .nav-count, .nav-toc, .nav-tree-toggle, .nav-tree-spacer, .nav-placeholder, .new, .nav-delete, .files-upload, .files-drop-hint, input, .nav-icon-clear),
655
+ body.sidebar-closed #nav-groups :is(.nav-label, .nav-toc, .nav-tree-toggle, .nav-tree-spacer, .nav-placeholder, .new, .nav-delete, .files-upload-status, .files-drop-hint, .extensions-drop-hint, input, .nav-icon-clear),
652
656
  body.sidebar-closed #nav-status { display: none; }
653
657
  body.sidebar-closed #nav-groups .nav-tree { display: block; padding: 0; margin: 0; }
654
658
  body.sidebar-closed #nav-groups .nav-row { position: relative; padding: 0; gap: 0; min-height: 36px; }
@@ -679,25 +683,19 @@ body.sidebar-closed :is(#tools-resize, .workspace-location) { display: none; }
679
683
  .nav-resources .nav-row > .nav-link { height: 100%; padding: 8px 10px; gap: 8px; }
680
684
  .nav-resources .nav-row { cursor: default; }
681
685
  .nav-resources .nav-link::before { display: none; }
682
- .nav-resources .files-upload { margin-right: 6px; }
683
- .files-upload { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0;
684
- border: 0; border-radius: 4px; background: transparent; color: var(--c-fg-muted); cursor: pointer; }
685
- .files-upload:hover, .files-upload:focus-visible { color: var(--c-fg); background: var(--c-raised); }
686
- .files-upload svg { display: block; width: 16px; height: 16px; }
687
- .files-upload > span { display: none; font: 10px var(--doc-mono); }
688
- .files-upload[data-state] > svg { display: none; }
689
- .files-upload[data-state] > span { display: block; }
690
- .files-upload[data-state="error"] { color: var(--c-danger); }
691
- .files-upload[data-state="done"] { color: var(--doc-ok); }
692
- .files-drop-hint { position: absolute; inset: 0; display: none; align-items: center;
686
+ .files-upload-status { flex: none; margin-right: 10px; color: var(--c-fg-muted); font: 10px var(--doc-mono); }
687
+ .files-upload-status:empty { display: none; }
688
+ .files-upload-status[data-state="error"] { color: var(--c-danger); }
689
+ .files-upload-status[data-state="done"] { color: var(--doc-ok); }
690
+ .files-drop-hint, .extensions-drop-hint { position: absolute; inset: 0; display: none; align-items: center;
693
691
  justify-content: center; gap: 6px; padding: 6px; pointer-events: none; text-align: center; }
694
692
  .files-drop-hint > svg { width: 28px; height: 28px; }
695
693
  .files-drop-hint ul { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 8px;
696
694
  margin: 0; padding: 0; list-style: none; font: 11px var(--doc-mono); color: var(--c-fg-muted); }
697
695
  @container (max-height: 140px) { .files-drop-hint ul { display: none; } }
698
696
  body.files-dragging #nav-files { border-color: var(--c-accent); }
699
- body.files-dragging #nav-files > .nav-item { opacity: .15; }
700
- body.files-dragging .files-drop-hint { display: flex; }
697
+ body.files-dragging :is(#nav-files, #nav-extensions) > .nav-item { opacity: .15; }
698
+ body.files-dragging :is(.files-drop-hint, .extensions-drop-hint) { display: flex; }
701
699
  body.files-over #nav-files { background: color-mix(in srgb, var(--c-accent) 10%, var(--c-bg1)); }
702
700
  #nav-status { min-height: 16px; margin: 6px 16px 0; color: var(--c-fg-faint); font-size: 11px;
703
701
  line-height: 1.35; }
@@ -738,21 +736,10 @@ article { max-width: 860px; margin: 0 auto; font-family: var(--viewer-font, syst
738
736
  [data-plugin-controls] button:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: 3px; }
739
737
  [data-plugin-controls] button:disabled { opacity: .55; cursor: wait; }
740
738
  .plugin-content { padding: 12px; overflow: auto; }
741
- .plugin-content:has(> iframe) { padding: 0; }
742
739
  .plugin-content:empty { display: none; }
743
- .plugin .plugin-card { margin: 0; max-width: none; }
744
- .plugin-card strong { font-size: 14px; font-weight: 600; }
745
- .plugin-card p { margin: 6px 0 0; font-size: 13px; white-space: pre-wrap; line-height: 1.5; overflow-wrap: anywhere; }
746
- .plugin-content > ul { display: grid; gap: 4px; padding-left: 20px; margin: 0; line-height: 1.5; }
747
- .plugin-content > ul li::marker { color: var(--doc-accent); }
748
- .plugin-content > table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.6; }
749
740
  .plugin-content :is(th, td) { padding: 6px 8px; text-align: start; border-bottom: 1px solid var(--doc-border); }
750
741
  .plugin-content th { background: var(--doc-bg-2); color: var(--doc-fg-2); font-size: 12px; font-weight: 600; }
751
742
  .plugin-content tr:last-child td { border-bottom: 0; }
752
- .plugin-content > pre { margin: 0; max-height: 320px; padding: 10px; }
753
- .plugin-content > iframe { display: block; color-scheme: inherit; }
754
- [data-plugin-result][data-resized] > iframe { height: 100% !important; }
755
- [data-plugin-result][data-resized] > pre { max-height: none; }
756
743
  .plugin-resize { display: none; height: 12px; align-items: center; justify-content: center;
757
744
  cursor: row-resize; touch-action: none; user-select: none; }
758
745
  .plugin[data-ready="true"]:not(.collapsed) > .plugin-resize { display: flex; }
@@ -762,63 +749,60 @@ article { max-width: 860px; margin: 0 auto; font-family: var(--viewer-font, syst
762
749
  body:has(.plugin-resize.drag), body:has(.plugin-resize.drag) * { cursor: row-resize !important; user-select: none !important; }
763
750
  body:has(.plugin-resize.drag) iframe { pointer-events: none; }
764
751
 
765
- .plugin[data-blocks="true"] { container-type: inline-size; background: var(--doc-bg); color: var(--doc-fg); }
766
- .plugin[data-blocks="true"] .plugin-content { padding: 20px; }
767
- .plugin[data-blocks="true"] .plugin-header { padding: 0 16px; }
768
- .plugin[data-blocks="true"][data-ready="true"] .plugin-info { display: block; flex-basis: auto; }
769
- .plugin[data-blocks="true"][data-ready="true"] .plugin-info h2 { display: none; }
770
- .plugin[data-blocks="true"][data-ready="true"] .plugin-header:has([data-plugin-status]:not(:empty)) { display: flex; padding-top: 12px; }
771
- .plugin[data-blocks="true"] [data-plugin-status] { margin: 0; }
752
+ .plugin { container-type: inline-size; background: var(--doc-bg); color: var(--doc-fg); }
753
+ .plugin .plugin-content { padding: 12px 14px; }
754
+ .plugin .plugin-header { padding: 0 16px; }
755
+ .plugin[data-ready="true"] .plugin-info { display: block; flex-basis: auto; }
756
+ .plugin[data-ready="true"] .plugin-info h2 { display: none; }
757
+ .plugin[data-ready="true"] .plugin-header:has([data-plugin-status]:not(:empty)) { display: flex; padding-top: 12px; }
758
+ .plugin [data-plugin-status] { margin: 0; }
772
759
  .plugin[data-operation-error="true"] [data-plugin-status] { color: var(--doc-danger); }
773
- .plugin-heading { order: -1; display: flex; align-items: center; gap: 8px; min-width: 0; max-width: calc(100% - 156px); font: 11px/20px var(--doc-mono); }
760
+ .plugin-heading { order: -1; display: flex; align-items: center; gap: 8px; min-width: 0; max-width: calc(100% - 156px); font: inherit; font-size: 11px; line-height: 20px; }
774
761
  .plugin-heading::before { content: ""; border-top: 2px solid var(--doc-border); width: 8px; flex: none; }
775
762
  .plugin-heading a { display: flex; align-items: center; gap: 6px; color: var(--doc-fg-2); text-decoration: none; flex: none; }
776
763
  .plugin-heading img { display: block; width: 16px; height: 16px; }
777
764
  .plugin-heading-context { color: var(--doc-fg-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
778
765
  .plugin-heading button { font: inherit; color: var(--doc-fg-2); background: transparent; border: 0; padding: 0; cursor: pointer; white-space: nowrap; }
779
- .plugin-action-menu { position: relative; margin: 0; }
780
- .plugin-action-menu > summary { list-style: none; font-size: 20px; line-height: 16px; }
781
- .plugin-action-menu > summary::-webkit-details-marker { display: none; }
782
- .plugin-action-menu > div { position: absolute; z-index: 20; right: 0; top: 26px; width: min(340px, calc(100vw - 64px)); max-height: 65vh; overflow: auto; padding: 8px; background: var(--doc-bg); color: var(--doc-fg); border: 1px solid var(--doc-border-2); border-radius: 8px; box-shadow: 0 8px 24px var(--c-shadow); }
783
- .plugin-action-menu > div > div > button { width: 100%; text-align: start; margin-bottom: 3px; }
784
- .plugin-blocks { font: 13px/1.65 var(--doc-mono); overflow-wrap: anywhere; }
766
+ .plugin-blocks { font: inherit; font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; }
785
767
  .plugin-blocks > :first-child, .plugin-block-columns > div > :first-child { margin-top: 0; }
786
- .plugin-blocks .plugin-block-heading { margin: 0 0 16px; padding: 0; border: 0; font: 600 20px/1.45 var(--doc-mono); color: var(--doc-fg); }
787
- .plugin-block-text { margin: 12px 0; white-space: pre-wrap; }
768
+ .plugin-blocks .plugin-block-heading { margin: 0 0 8px; padding: 0; border: 0; font: inherit; font-size: 17px; font-weight: 600; line-height: 1.4; color: var(--doc-fg); }
769
+ .plugin-block-text { margin: 8px 0; white-space: pre-wrap; }
788
770
  .plugin-block-text:has(p, pre, ul, ol) { white-space: normal; }
789
- .plugin-blocks p { margin: 6px 0; white-space: pre-wrap; }
771
+ .plugin-blocks p { margin: 4px 0; white-space: pre-wrap; }
772
+ .plugin-blocks :is(ul, ol) { margin: 6px 0; padding-left: 22px; }
773
+ .plugin-blocks > :last-child { margin-bottom: 0; }
790
774
  .plugin-blocks strong { font-weight: 600; }
791
775
  .plugin-blocks a { color: var(--doc-accent); text-underline-offset: 3px; }
792
776
  .plugin-blocks code { font-family: var(--doc-mono); }
793
- .plugin-block-context { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin: 10px 0; color: var(--doc-fg-3); font-size: 11px; }
794
- .plugin-block-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px 20px; margin: 14px 0; }
777
+ .plugin-block-context { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; margin: 6px 0; color: var(--doc-fg-3); font-size: 11px; }
778
+ .plugin-block-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 6px 16px; margin: 8px 0; }
795
779
  .plugin-block-fields > div { min-width: 0; }
796
- .plugin-block-fields dt { color: var(--doc-fg-3); font-size: 11px; margin-bottom: 3px; }
797
- .plugin-block-fields dd { margin: 0; min-height: 26px; font-size: 12px; }
798
- .plugin-block-columns { display: grid; grid-template-columns: minmax(0, 1fr) 200px; gap: 24px; }
780
+ .plugin-block-fields dt { color: var(--doc-fg-3); font-size: 10px; margin-bottom: 1px; }
781
+ .plugin-block-fields dd { margin: 0; min-height: 20px; font-size: 12px; }
782
+ .plugin-block-columns { display: grid; grid-template-columns: minmax(0, 1fr) 180px; gap: 16px; }
799
783
  .plugin-block-columns > div, .plugin-block-columns aside { min-width: 0; }
800
- .plugin-block-columns aside { border-left: 1px solid var(--doc-border); padding-left: 18px; }
784
+ .plugin-block-columns aside { border-left: 1px solid var(--doc-border); padding-left: 12px; }
801
785
  .plugin-block-columns aside .plugin-block-fields { grid-template-columns: 1fr; margin-top: 0; }
802
- .plugin-block-section { border-top: 1px solid var(--doc-border); padding-top: 10px; margin: 18px 0 0; min-width: 0; }
786
+ .plugin-block-section { border-top: 1px solid var(--doc-border); padding-top: 6px; margin: 10px 0 0; min-width: 0; }
803
787
  .plugin-block-section > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--doc-fg-2); padding: 4px 0; }
804
788
  .plugin-block-note { color: var(--doc-warn); font-size: 11px; border-left: 2px solid currentColor; padding-left: 10px; }
805
789
  .plugin-block-table { overflow: auto; width: 100%; margin: 8px 0; }
806
790
  .plugin-block-table table { width: 100%; border-collapse: collapse; font-size: 12px; }
807
- .plugin-block-table :is(th, td) { padding: 8px 10px; min-width: 90px; max-width: 320px; vertical-align: top; }
791
+ .plugin-block-table :is(th, td) { padding: 5px 8px; min-width: 72px; max-width: 320px; vertical-align: top; }
808
792
  .plugin-blocks .plugin-block-code { font: 12px/1.65 var(--doc-mono); padding: 12px; margin: 10px 0; border: 1px solid var(--doc-border); border-radius: 6px; max-height: 400px; overflow: auto; white-space: pre-wrap; }
809
793
  .plugin-block-diff code > span { display: inline-block; min-width: 100%; }
810
794
  .plugin-block-diff [data-tone="success"] { color: var(--doc-ok); background: color-mix(in srgb, var(--doc-ok) 8%, transparent); }
811
795
  .plugin-block-diff [data-tone="danger"] { color: var(--doc-danger); background: color-mix(in srgb, var(--doc-danger) 8%, transparent); }
812
- .plugin-block-message { padding: 12px 0; border-bottom: 1px solid var(--doc-border); }
796
+ .plugin-block-message { padding: 8px 0; border-bottom: 1px solid var(--doc-border); }
813
797
  .plugin-block-message:last-child { border-bottom: 0; }
814
798
  .plugin-message-byline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
815
799
  .plugin-message-byline > span:not(.plugin-avatar, .plugin-message-actions) { color: var(--doc-fg-3); font-size: 10px; }
816
- .plugin-avatar { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 7px; color: var(--doc-accent); background: color-mix(in srgb, var(--doc-accent) 12%, var(--doc-bg)); font-size: 10px; }
800
+ .plugin-avatar { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 7px; color: var(--doc-accent); background: color-mix(in srgb, var(--doc-accent) 12%, var(--doc-bg)); font-size: 10px; }
817
801
  .plugin-task { display: flex; align-items: baseline; gap: 8px; list-style: none; }
818
802
  .plugin-message-actions svg { display: block; width: 14px; height: 14px; }
819
803
  .plugin-message-actions .plugin-block-button { padding: 4px; border: 0; color: var(--doc-fg-3); }
820
804
  .plugin-message-actions { margin-left: auto; display: flex; gap: 4px; }
821
- .plugin-message-body, .plugin-reactions { margin: 6px 0 0 36px; }
805
+ .plugin-message-body, .plugin-reactions { margin: 4px 0 0 32px; }
822
806
  .plugin-reactions { display: flex; flex-wrap: wrap; gap: 5px; font-size: 11px; }
823
807
  .plugin-reactions button { font: inherit; padding: 2px 8px; border: 1px solid var(--doc-border); border-radius: 12px; background: transparent; color: var(--doc-fg-2); cursor: pointer; }
824
808
  .plugin-reactions [aria-pressed="true"] { border-color: var(--doc-accent); color: var(--doc-accent); background: color-mix(in srgb, var(--doc-accent) 8%, transparent); }
@@ -832,19 +816,18 @@ body:has(.plugin-resize.drag) iframe { pointer-events: none; }
832
816
  .plugin-copy svg { width: 14px; height: 14px; }
833
817
  .plugin-block-progress { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--doc-fg-3); margin: 12px 0; }
834
818
  .plugin-block-progress progress { flex: 1; width: 0; height: 6px; accent-color: var(--doc-accent); }
835
- .plugin-block-actions { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
819
+ .plugin-block-actions { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
836
820
  .plugin-block-actions:has([data-block-composer]) { display: block; }
837
821
  .plugin-block-button { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid var(--doc-border); border-radius: 5px; color: var(--doc-fg); background: transparent; cursor: pointer; }
838
- .plugin-block-button:hover, .plugin-edit-display:hover { background: var(--doc-bg-2); }
822
+ .plugin-block-button:hover { background: var(--doc-bg-2); }
839
823
  .plugin-block-button:disabled { opacity: .5; cursor: wait; }
824
+ .plugin-action[aria-pressed="true"] { color: var(--doc-accent); background: var(--doc-bg-2); }
840
825
  .plugin-danger { color: var(--doc-danger); }
841
- .plugin-edit-display { border-radius: 4px; cursor: text; min-height: 24px; }
842
- .plugin-edit-display:focus-visible, .plugin :is(button, summary, input, select, textarea):focus-visible, .plugin-block-table:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: 2px; }
843
- .plugin-editor { display: grid; gap: 10px; margin: 8px 0; padding: 10px; border: 1px solid var(--doc-border); border-radius: 6px; font: 12px/1.6 var(--doc-mono); color: var(--doc-fg); background: var(--doc-bg); min-width: 0; }
844
- .plugin-editor label { display: grid; gap: 5px; color: var(--doc-fg-2); }
845
- .plugin-editor :is(input, textarea, select) { box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; padding: 7px 8px; border: 1px solid var(--doc-border-2); border-radius: 4px; color: var(--doc-fg); background: var(--doc-bg); font: inherit; }
846
- .plugin-editor textarea { resize: vertical; min-height: 96px; max-height: 60vh; }
847
- .plugin-editor-controls { display: flex; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
826
+ .plugin [data-plugin-edit-trigger]:focus-visible, .plugin :is(button, summary, input, select, textarea):focus-visible, .plugin-block-table:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: 2px; }
827
+ .plugin-editor { display: grid; gap: 6px; margin: 0; padding: 0; border: 0; font: inherit; font-size: 12px; line-height: 1.5; color: var(--doc-fg); background: var(--doc-bg); min-width: 0; }
828
+ .plugin-editor label { display: grid; gap: 0; font-size: 0; }
829
+ .plugin-editor :is(input, textarea, select) { box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; padding: 3px 6px; border: 1px solid var(--doc-border); border-radius: 4px; color: var(--doc-fg); background: var(--doc-bg); font: inherit; font-size: 12px; line-height: 1.5; }
830
+ .plugin-editor textarea { resize: vertical; min-height: 54px; max-height: 40vh; }
848
831
  .plugin-editor .plugin-submit { background: var(--doc-accent); color: var(--doc-accent-fg); border-color: transparent; }
849
832
  .plugin-editor-notice { color: var(--doc-danger); font-size: 11px; }
850
833
  .plugin-editor-notice:empty { display: none; }
@@ -852,9 +835,9 @@ body:has(.plugin-resize.drag) iframe { pointer-events: none; }
852
835
  @container (max-width: 600px) {
853
836
  .plugin-block-columns { grid-template-columns: minmax(0, 1fr); gap: 14px; }
854
837
  .plugin-block-columns aside { border-left: 0; border-top: 1px solid var(--doc-border); padding: 14px 0 0; }
855
- .plugin-block-columns aside .plugin-block-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
838
+ .plugin-block-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
856
839
  .plugin-heading-context { display: none; }
857
- .plugin-blocks .plugin-block-heading { font-size: 18px; }
840
+ .plugin-blocks .plugin-block-heading { font-size: 16px; }
858
841
  .plugin-message-body, .plugin-reactions { margin-left: 0; }
859
842
  }
860
843
 
@@ -1075,6 +1058,40 @@ details summary { cursor: pointer; opacity: .8; }
1075
1058
  .terms .term-description::before { content: ""; position: absolute; left: 0; right: 0; top: -9px; height: 9px; }
1076
1059
  .terms .term-name:hover .term-description,
1077
1060
  .terms .term-name:focus .term-description { display: block; }
1061
+
1062
+ .extensions-header { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 12px 0 6px; }
1063
+ .extensions-header h1 { margin: 0 auto 0 0; font-size: 18px; }
1064
+ .extensions-description, .extension-info small { color: var(--c-fg-muted); font-size: 12px; }
1065
+ .extensions-description { margin: 0 0 12px; }
1066
+ .extensions-notice { color: var(--c-danger); white-space: pre-wrap; font-size: 12px; }
1067
+ .extensions-notice:empty { display: none; }
1068
+ .extension-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 0; border-top: 1px solid var(--c-line); }
1069
+ .extension-info { flex: 1; min-width: 180px; overflow-wrap: anywhere; }
1070
+ .extension-info strong { font-size: 13px; font-weight: 500; }
1071
+ .extension-version { margin-left: 8px; color: var(--c-fg-muted); font-size: 11px; }
1072
+ .extension-info small { display: block; margin-top: 3px; }
1073
+ .extension-accounts { width: 100%; display: grid; gap: 4px; font-size: 12px; color: var(--c-fg-muted); }
1074
+ .extension-accounts .extension-actions { align-items: center; }
1075
+ .extension-actions { display: flex; gap: 6px; }
1076
+ .extension-button, .extension-editor select { padding: 4px 8px; border: 1px solid var(--c-line); border-radius: 4px; color: var(--c-fg); background: var(--c-bg1); font: inherit; font-size: 12px; cursor: pointer; }
1077
+ .extension-button:hover { background: var(--c-raised); }
1078
+ .extension-button:disabled { opacity: .5; cursor: default; }
1079
+ .extension-editor { border-top: 1px solid var(--c-line); margin-top: 12px; padding-top: 4px; }
1080
+ .extension-editor .extensions-header { font-size: 12px; }
1081
+ .extension-editor select { min-width: 0; max-width: 100%; margin-left: auto; }
1082
+ .extension-editor textarea { display: block; box-sizing: border-box; width: 100%; min-height: 320px; margin: 8px 0; padding: 10px; border: 1px solid var(--c-line); border-radius: 4px; background: var(--c-bg1); color: var(--c-fg); font: 12px/1.6 var(--font-mono); tab-size: 2; resize: vertical; }
1083
+ .extension-icon { position: relative; flex: none; display: grid; place-items: center; width: 40px; height: 40px; color: var(--c-fg-muted); }
1084
+ .extension-icon > :is(svg, img) { width: 32px; height: 32px; object-fit: contain; }
1085
+ .extension-icon > img { position: absolute; inset: 4px; }
1086
+ .extension-icon:has(img) > svg { visibility: hidden; }
1087
+ .extensions-dropzone { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 28px 16px; margin: 16px 0; border: 1px dashed var(--c-fg-faint); border-radius: 8px; color: var(--c-fg-muted); font-size: 12px; cursor: pointer; }
1088
+ .extensions-dropzone strong { color: var(--c-fg); font-size: 14px; font-weight: 500; }
1089
+ .extensions-dropzone input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
1090
+ .extensions-dropzone:focus-within { outline: 2px solid var(--c-accent); outline-offset: 2px; }
1091
+ .extensions-dropzone:has(input:disabled) { opacity: .5; }
1092
+ body.extensions-over #nav-extensions, body.extensions-over .extensions-dropzone { outline: 1px solid var(--c-accent); outline-offset: -1px; background: var(--c-raised); }
1093
+
1094
+
1078
1095
  .resource-library { width: 100%; max-width: none; }
1079
1096
  .git-page { padding-top: 8px; font: 12px/1.55 var(--font-sans); }
1080
1097
  .git-controls { min-width: 0; margin: 0; padding: 0; border: 0; }
@@ -1572,7 +1589,6 @@ const navFilesHtml = (site, currentRes) => {
1572
1589
  const statics = currentRes?.startsWith("_statics") ||
1573
1590
  ["dictionary", "code", "urls"].includes(currentRes ?? "");
1574
1591
  const folderIcon = icon('<path d="M2 5h6l2 2h8v10H2Z"/>');
1575
- const upload = `<button class="files-upload" type="button" title="Upload files" data-i18n-title="Upload files" aria-label="Upload files" data-i18n-aria-label="Upload files">${UPLOAD_ICON}<span></span></button><input id="files-upload-input" type="file" multiple hidden>`;
1576
1592
  const hint = `<div class="files-drop-hint" aria-hidden="true">${UPLOAD_ICON}<span data-i18n="Upload files">Upload files</span></div>`;
1577
1593
  const location = source?.protocol === "git"
1578
1594
  ? `<div class="workspace-location" data-workspace-protocol="git" title="${escapeHtml(`${source.repo ?? "repo"}[${source.branch}]`)}">${GIT_ICON}<span class="workspace-address"><span class="git-repo-name">${escapeHtml(source.repo ?? "repo")}</span><span class="git-branch-caption">[${escapeHtml(source.branch)}]</span></span></div>`
@@ -1580,7 +1596,8 @@ const navFilesHtml = (site, currentRes) => {
1580
1596
  const git = source?.protocol === "git"
1581
1597
  ? `<div class="nav-resources" id="nav-git"><div class="nav-item" data-node-id="_git"><div class="nav-row"><a class="nav-link${currentRes === "_git" ? " cur" : ""}" href="/_git" title="Git controls" data-i18n-title="Git controls">${GIT_ICON}<span class="nav-label">Git</span></a></div></div></div>`
1582
1598
  : "";
1583
- return `<section id="nav-tools" aria-label="Workspace tools" data-i18n-aria-label="Workspace tools"><div id="tools-resize" role="separator" tabindex="0" aria-orientation="horizontal" aria-controls="nav-tools" aria-label="Resize workspace tools" data-i18n-aria-label="Resize workspace tools"></div>${location}<div class="nav-tool-cards"><div class="nav-resources" id="nav-files"><div class="nav-item" data-node-id="_files"><div class="nav-row"><a class="nav-link${currentRes !== undefined && currentRes !== "_git" && !statics ? " cur" : ""}" href="/_files" title="Files" data-i18n-title="Files">${folderIcon}<span class="nav-label" data-i18n="Files">Files</span></a>${upload}</div></div>${hint}</div><div class="nav-resources" id="nav-statics"><div class="nav-item" data-node-id="_statics"><div class="nav-row"><a class="nav-link${statics ? " cur" : ""}" href="/_statics" title="Statics" data-i18n-title="Statics">${BOX_ICON}<span class="nav-label" data-i18n="Statics">Statics</span></a></div></div></div>${git}</div></section>`;
1599
+ const extensions = `<div class="nav-resources" id="nav-extensions" data-scope="${createHash("sha256").update(root).digest("hex")}"><div class="nav-item" data-node-id="_extensions"><div class="nav-row"><a class="nav-link${currentRes === "_extensions" ? " cur" : ""}" href="/_extensions" title="Plugins" data-i18n-title="Plugins">${PLUGINS_ICON}<span class="nav-label" data-i18n="Plugins">Plugins</span></a></div></div><div class="extensions-drop-hint" aria-hidden="true">${PLUGINS_ICON}<span data-i18n="Add plugins">Add plugins</span></div></div>`;
1600
+ return `<section id="nav-tools" aria-label="Workspace tools" data-i18n-aria-label="Workspace tools"><div id="tools-resize" role="separator" tabindex="0" aria-orientation="horizontal" aria-controls="nav-tools" aria-label="Resize workspace tools" data-i18n-aria-label="Resize workspace tools"></div>${location}<div class="nav-tool-cards"><div class="nav-resources" id="nav-files"><div class="nav-item" data-node-id="_files"><div class="nav-row"><a class="nav-link${currentRes !== undefined && currentRes !== "_git" && currentRes !== "_extensions" && !statics ? " cur" : ""}" href="/_files" title="Files" data-i18n-title="Files">${folderIcon}<span class="nav-label" data-i18n="Files">Files</span></a><span class="files-upload-status" role="status"></span></div></div>${hint}</div><div class="nav-resources" id="nav-statics"><div class="nav-item" data-node-id="_statics"><div class="nav-row"><a class="nav-link${statics ? " cur" : ""}" href="/_statics" title="Statics" data-i18n-title="Statics">${STATICS_ICON}<span class="nav-label" data-i18n="Statics">Statics</span></a></div></div></div>${git}${extensions}</div></section>`;
1584
1601
  };
1585
1602
  const formatSize = (bytes) => bytes < 1024
1586
1603
  ? `${bytes} B`
@@ -1674,6 +1691,11 @@ export const textfilePreviewErrors = (site, file, source) => {
1674
1691
  return errors;
1675
1692
  };
1676
1693
  export const resourcesMain = (site, rest) => {
1694
+ if (rest === "_extensions")
1695
+ return {
1696
+ title: "Plugins",
1697
+ main: '<article class="res-page resource-library"><div id="extensions-manager"></div></article>',
1698
+ };
1677
1699
  if (rest === "_git")
1678
1700
  return site.workspace?.protocol === "git"
1679
1701
  ? {
@@ -1932,7 +1954,7 @@ ${mode === "edit"
1932
1954
  if (window.__docsRefreshNav) return window.__docsRefreshNav();
1933
1955
  const cur = document.querySelector("#nav-groups .nav-row a.cur");
1934
1956
  const id = (cur && cur.closest(".nav-item").dataset.docId) || "";
1935
- const rp = location.pathname.startsWith("/_statics") ? location.pathname.slice(1) : location.pathname === "/_git" ? "_git" : location.pathname.startsWith("/_files") ? location.pathname.slice(8).replace(/^\\/|\\/$/g, "") : null;
1957
+ const rp = location.pathname.startsWith("/_statics") ? location.pathname.slice(1) : ["/_git", "/_extensions"].includes(location.pathname) ? location.pathname.slice(1) : location.pathname.startsWith("/_files") ? location.pathname.slice(8).replace(/^\\/|\\/$/g, "") : null;
1936
1958
  const res = await localeFetch("/api/nav?current=" + encodeURIComponent(id) + (rp == null ? "" : "&res=" + encodeURIComponent(rp)));
1937
1959
  if (res.ok) document.getElementById("nav-groups").innerHTML = (await res.json()).html;
1938
1960
  if (window.__docsNavRestore) window.__docsNavRestore();
@@ -2391,7 +2413,8 @@ export const pageDataResponse = (site, path) => {
2391
2413
  if (typeof path !== "string" || !path.startsWith("/")) {
2392
2414
  return { status: 400, body: { error: "route must be /<docId>" } };
2393
2415
  }
2394
- if (path === "/_git" ||
2416
+ if (path === "/_extensions" ||
2417
+ path === "/_git" ||
2395
2418
  path === "/_statics" ||
2396
2419
  path.startsWith("/_statics/") ||
2397
2420
  path === "/_files" ||
@@ -2399,8 +2422,8 @@ export const pageDataResponse = (site, path) => {
2399
2422
  try {
2400
2423
  const resource = resourcesMain(site, path.startsWith("/_statics")
2401
2424
  ? path.slice(1)
2402
- : path === "/_git"
2403
- ? "_git"
2425
+ : ["/_git", "/_extensions"].includes(path)
2426
+ ? path.slice(1)
2404
2427
  : path.slice("/_files".length).replace(/^\/|\/$/g, ""));
2405
2428
  return resource
2406
2429
  ? {
@@ -2734,14 +2757,18 @@ export const bundleClient = async (entry, publish = false) => {
2734
2757
  });
2735
2758
  return out.outputFiles?.[0]?.text ?? "";
2736
2759
  };
2737
- export const startServer = async (mode, port = 5179, pluginModule) => {
2760
+ export const startServer = async (mode, port = 5179, pluginModule, startup) => {
2738
2761
  // Language adapters are read once; the manifest is re-read by the compiler.
2739
2762
  siteLanguages = await loadSiteLanguages(root);
2740
2763
  const viewerPlugins = await loadViewerPlugins(pluginModule);
2741
2764
  workspaceSource = viewerPlugins.workspace;
2742
2765
  let gitOrigin = viewerPlugins.gitOrigin;
2743
2766
  const handleGit = createGitHandler(root, () => gitOrigin);
2744
- const handlePlugins = createPluginHandler(viewerPlugins.plugins, viewerPlugins);
2767
+ const personal = createLocalPluginStorage();
2768
+ const handlePlugins = createPluginHandler(viewerPlugins.plugins, viewerPlugins, personal.credentials);
2769
+ const handleExtensions = createExtensionManager(root, handlePlugins, viewerPlugins, personal);
2770
+ let loginPlugins = startup?.login ? [startup.login] : [];
2771
+ process.once("exit", handleExtensions.dispose);
2745
2772
  // Client bundles (bundleClient) = edit's editing UI and the schema graph interaction layer
2746
2773
  // shared by preview/edit. React Flow's stylesheet isn't mixed into the bundle — the mount
2747
2774
  // layer (flow-css.ts) injects it into a <style> at runtime, so the page needs no CSS delivery
@@ -2776,6 +2803,21 @@ export const startServer = async (mode, port = 5179, pluginModule) => {
2776
2803
  };
2777
2804
  viewer.setAllowedDomains(viewerPlugins.allowedDomains ?? []);
2778
2805
  await viewerPlugins.setup?.(viewer);
2806
+ const extensionErrors = await handleExtensions
2807
+ .restore()
2808
+ .catch((error) => [error instanceof Error ? error.message : String(error)]);
2809
+ for (const error of extensionErrors)
2810
+ console.error(`Plugin: ${error}`);
2811
+ if (startup?.file && startup.data) {
2812
+ const result = (await handleExtensions({
2813
+ action: "install",
2814
+ file: startup.file,
2815
+ data: startup.data,
2816
+ base: null,
2817
+ }));
2818
+ loginPlugins = result.items.find((item) => item.file === startup.file)?.plugins ?? [];
2819
+ console.log(`Installed ${startup.file} in ${join(root, EXTENSIONS_DIR)}`);
2820
+ }
2779
2821
  // DOCS_HMR=0 = no hot reload (nothing is watched = changes need a manual reload).
2780
2822
  if (process.env.DOCS_HMR !== "0") {
2781
2823
  const watcher = watchProject(root, pkgRoot, (kind, files) => {
@@ -2851,6 +2893,32 @@ export const startServer = async (mode, port = 5179, pluginModule) => {
2851
2893
  return;
2852
2894
  }
2853
2895
  if (mode === "edit" && req.method === "POST") {
2896
+ if (path === "/api/extensions") {
2897
+ res.setHeader("cache-control", "no-store");
2898
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"]) ||
2899
+ ![
2900
+ `localhost:${req.socket.localPort}`,
2901
+ `127.0.0.1:${req.socket.localPort}`,
2902
+ `[::1]:${req.socket.localPort}`,
2903
+ ].includes(req.headers.host ?? "")) {
2904
+ sendJson(res, 403, {
2905
+ error: "Plugins require a same-origin local editor request",
2906
+ });
2907
+ return;
2908
+ }
2909
+ try {
2910
+ const input = await readJson(req, MAX_EXTENSION_BYTES * 6 + 4096);
2911
+ if (!input)
2912
+ throw new Error("Expected a plugin request");
2913
+ sendJson(res, 200, await handleExtensions(input));
2914
+ }
2915
+ catch (error) {
2916
+ sendJson(res, 409, {
2917
+ error: error instanceof Error ? error.message : "Plugin operation failed",
2918
+ });
2919
+ }
2920
+ return;
2921
+ }
2854
2922
  if (path === "/api/git") {
2855
2923
  res.setHeader("cache-control", "no-store");
2856
2924
  if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"]) ||
@@ -3143,7 +3211,8 @@ export const startServer = async (mode, port = 5179, pluginModule) => {
3143
3211
  return;
3144
3212
  }
3145
3213
  if ((mode === "edit" &&
3146
- (path === "/_git" ||
3214
+ (path === "/_extensions" ||
3215
+ path === "/_git" ||
3147
3216
  path === "/_statics" ||
3148
3217
  path.startsWith("/_statics/") ||
3149
3218
  path === "/_files" ||
@@ -3151,8 +3220,8 @@ export const startServer = async (mode, port = 5179, pluginModule) => {
3151
3220
  path === "/_files/terms") {
3152
3221
  const rest = path.startsWith("/_statics")
3153
3222
  ? path.slice(1)
3154
- : path === "/_git"
3155
- ? "_git"
3223
+ : ["/_git", "/_extensions"].includes(path)
3224
+ ? path.slice(1)
3156
3225
  : requestUrl.pathname.slice("/_files".length).replace(/^\/|\/$/g, "");
3157
3226
  const page = resourcesMain(site, rest);
3158
3227
  if (!page) {
@@ -3216,6 +3285,16 @@ export const startServer = async (mode, port = 5179, pluginModule) => {
3216
3285
  const addr = server.address();
3217
3286
  const p = typeof addr === "object" && addr ? addr.port : port;
3218
3287
  console.log(`dooc ${mode} (${root}): http://localhost:${p}/`);
3288
+ for (const plugin of loginPlugins) {
3289
+ try {
3290
+ console.log(`Log in to ${plugin}: ${handlePlugins.loginLink(plugin, `http://localhost:${p}`)}`);
3291
+ }
3292
+ catch (error) {
3293
+ console.error(error instanceof Error ? error.message : String(error));
3294
+ }
3295
+ }
3296
+ if (loginPlugins.length)
3297
+ console.log("Keep this command running while signing in. Login links expire in 10 minutes.");
3219
3298
  });
3220
3299
  const listen = (p, left) => {
3221
3300
  server.once("error", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dooc-dev/dooc",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "View and edit source files with dooc.yaml configuration, and share them as source ZIPs or standalone HTML.",
5
5
  "type": "module",
6
6
  "files": [
@@ -65,7 +65,8 @@
65
65
  "test:install": "node scripts/test-install.mjs",
66
66
  "check": "biome check . && tsc --noEmit && pnpm run build && vitest run && pnpm run test:install && pnpm run test:plugin-package",
67
67
  "fix": "biome check --write .",
68
- "build:fonts": "node -e \"require('node:fs').cpSync('src/viewer/fonts', 'dist/viewer/fonts', {recursive:true})\""
68
+ "build:fonts": "node -e \"require('node:fs').cpSync('src/viewer/fonts', 'dist/viewer/fonts', {recursive:true})\"",
69
+ "build:sample-extension": "node --import tsx scripts/build-sample-extension.mjs"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@biomejs/biome": "2.4.15",