@dooc-dev/dooc 0.0.0-stage → 0.0.1

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 (213) hide show
  1. package/README.md +26 -2
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +244 -0
  4. package/dist/code.global.js +285 -0
  5. package/dist/compiler/bundle-urls.d.ts +3 -0
  6. package/dist/compiler/bundle-urls.js +294 -0
  7. package/dist/compiler/bundle.d.ts +2 -0
  8. package/dist/compiler/bundle.js +100 -0
  9. package/dist/compiler/check-prose.d.ts +11 -0
  10. package/dist/compiler/check-prose.js +138 -0
  11. package/dist/compiler/check-types.d.ts +44 -0
  12. package/dist/compiler/check-types.js +1 -0
  13. package/dist/compiler/check.d.ts +5 -0
  14. package/dist/compiler/check.js +450 -0
  15. package/dist/compiler/code-blocks.d.ts +17 -0
  16. package/dist/compiler/code-blocks.js +198 -0
  17. package/dist/compiler/code-languages.d.ts +10 -0
  18. package/dist/compiler/code-languages.js +109 -0
  19. package/dist/compiler/compile.d.ts +95 -0
  20. package/dist/compiler/compile.js +410 -0
  21. package/dist/compiler/config.d.ts +7 -0
  22. package/dist/compiler/config.js +52 -0
  23. package/dist/compiler/dialect.d.ts +24 -0
  24. package/dist/compiler/dialect.js +198 -0
  25. package/dist/compiler/drawio.d.ts +26 -0
  26. package/dist/compiler/drawio.js +176 -0
  27. package/dist/compiler/figures.d.ts +14 -0
  28. package/dist/compiler/figures.js +221 -0
  29. package/dist/compiler/file-cache.d.ts +14 -0
  30. package/dist/compiler/file-cache.js +61 -0
  31. package/dist/compiler/files.d.ts +29 -0
  32. package/dist/compiler/files.js +431 -0
  33. package/dist/compiler/fonts.d.ts +10 -0
  34. package/dist/compiler/fonts.js +54 -0
  35. package/dist/compiler/format.d.ts +1 -0
  36. package/dist/compiler/format.js +42 -0
  37. package/dist/compiler/from-mdast.d.ts +16 -0
  38. package/dist/compiler/from-mdast.js +142 -0
  39. package/dist/compiler/icons.d.ts +14 -0
  40. package/dist/compiler/icons.js +85 -0
  41. package/dist/compiler/language-codes.d.ts +7 -0
  42. package/dist/compiler/language-codes.js +17 -0
  43. package/dist/compiler/locales.d.ts +7 -0
  44. package/dist/compiler/locales.js +67 -0
  45. package/dist/compiler/manifest.d.ts +6 -0
  46. package/dist/compiler/manifest.js +30 -0
  47. package/dist/compiler/ops.d.ts +44 -0
  48. package/dist/compiler/ops.js +362 -0
  49. package/dist/compiler/public-file.d.ts +2 -0
  50. package/dist/compiler/public-file.js +93 -0
  51. package/dist/compiler/serialize.d.ts +3 -0
  52. package/dist/compiler/serialize.js +16 -0
  53. package/dist/compiler/terms.d.ts +9 -0
  54. package/dist/compiler/terms.js +44 -0
  55. package/dist/compiler/textfile-remote.d.ts +12 -0
  56. package/dist/compiler/textfile-remote.js +124 -0
  57. package/dist/compiler/textfile.d.ts +9 -0
  58. package/dist/compiler/textfile.js +113 -0
  59. package/dist/compiler/theme-variants.d.ts +4 -0
  60. package/dist/compiler/theme-variants.js +22 -0
  61. package/dist/compiler/zip.d.ts +6 -0
  62. package/dist/compiler/zip.js +48 -0
  63. package/dist/core/blocks.d.ts +99 -0
  64. package/dist/core/blocks.js +29 -0
  65. package/dist/core/code-file.d.ts +12 -0
  66. package/dist/core/code-file.js +68 -0
  67. package/dist/core/content-metadata.d.ts +48 -0
  68. package/dist/core/content-metadata.js +264 -0
  69. package/dist/core/content-types.d.ts +45 -0
  70. package/dist/core/content-types.js +1 -0
  71. package/dist/core/figure-frame.d.ts +12 -0
  72. package/dist/core/figure-frame.js +108 -0
  73. package/dist/core/file-embed.d.ts +13 -0
  74. package/dist/core/file-embed.js +43 -0
  75. package/dist/core/files.d.ts +15 -0
  76. package/dist/core/files.js +59 -0
  77. package/dist/core/flow-base-css.generated.d.ts +1 -0
  78. package/dist/core/flow-base-css.generated.js +4 -0
  79. package/dist/core/flow-css.d.ts +9 -0
  80. package/dist/core/flow-css.js +54 -0
  81. package/dist/core/graph-common.d.ts +32 -0
  82. package/dist/core/graph-common.js +113 -0
  83. package/dist/core/graph.d.ts +117 -0
  84. package/dist/core/graph.js +480 -0
  85. package/dist/core/html.d.ts +29 -0
  86. package/dist/core/html.js +444 -0
  87. package/dist/core/icons.d.ts +1 -0
  88. package/dist/core/icons.js +1 -0
  89. package/dist/core/inline.d.ts +27 -0
  90. package/dist/core/inline.js +42 -0
  91. package/dist/core/language.d.ts +41 -0
  92. package/dist/core/language.js +44 -0
  93. package/dist/core/languages/ja.d.ts +2 -0
  94. package/dist/core/languages/ja.js +5 -0
  95. package/dist/core/markdown.d.ts +27 -0
  96. package/dist/core/markdown.js +154 -0
  97. package/dist/core/previewers.d.ts +37 -0
  98. package/dist/core/previewers.js +103 -0
  99. package/dist/core/routing.d.ts +20 -0
  100. package/dist/core/routing.js +64 -0
  101. package/dist/core/schema-comments.d.ts +14 -0
  102. package/dist/core/schema-comments.js +52 -0
  103. package/dist/core/schema-graph-flow.d.ts +9 -0
  104. package/dist/core/schema-graph-flow.js +379 -0
  105. package/dist/core/schema-graph-mount.d.ts +9 -0
  106. package/dist/core/schema-graph-mount.js +105 -0
  107. package/dist/core/schema-graph.d.ts +254 -0
  108. package/dist/core/schema-graph.js +981 -0
  109. package/dist/core/table-ui.d.ts +2 -0
  110. package/dist/core/table-ui.js +180 -0
  111. package/dist/core/taxonomy.d.ts +12 -0
  112. package/dist/core/taxonomy.js +49 -0
  113. package/dist/core/term-reference.d.ts +5 -0
  114. package/dist/core/term-reference.js +19 -0
  115. package/dist/core/terms-ui.d.ts +6 -0
  116. package/dist/core/terms-ui.js +22 -0
  117. package/dist/core/textfile.d.ts +10 -0
  118. package/dist/core/textfile.js +39 -0
  119. package/dist/core/theme-variants.d.ts +3 -0
  120. package/dist/core/theme-variants.js +37 -0
  121. package/dist/core/toc-ui.d.ts +2 -0
  122. package/dist/core/toc-ui.js +161 -0
  123. package/dist/core/video-ui.d.ts +2 -0
  124. package/dist/core/video-ui.js +253 -0
  125. package/dist/core/workspace.d.ts +15 -0
  126. package/dist/core/workspace.js +28 -0
  127. package/dist/edit.global.js +305 -0
  128. package/dist/flow.global.js +4959 -0
  129. package/dist/index.d.ts +17 -0
  130. package/dist/index.js +31 -0
  131. package/dist/mcp.d.ts +24 -0
  132. package/dist/mcp.js +467 -0
  133. package/dist/plugins/actions.d.ts +45 -0
  134. package/dist/plugins/actions.js +125 -0
  135. package/dist/plugins/blocks.d.ts +92 -0
  136. package/dist/plugins/blocks.js +99 -0
  137. package/dist/plugins/index.d.ts +4 -0
  138. package/dist/plugins/index.js +1 -0
  139. package/dist/plugins/oauth.d.ts +14 -0
  140. package/dist/plugins/oauth.js +164 -0
  141. package/dist/plugins/runtime.d.ts +24 -0
  142. package/dist/plugins/runtime.js +339 -0
  143. package/dist/plugins/types.d.ts +56 -0
  144. package/dist/plugins/types.js +1 -0
  145. package/dist/plugins/view.d.ts +29 -0
  146. package/dist/plugins/view.js +60 -0
  147. package/dist/ui.global.js +77 -0
  148. package/dist/viewer/assets.d.ts +2 -0
  149. package/dist/viewer/assets.js +3 -0
  150. package/dist/viewer/build.d.ts +12 -0
  151. package/dist/viewer/build.js +55 -0
  152. package/dist/viewer/bundle-network.d.ts +2 -0
  153. package/dist/viewer/bundle-network.js +88 -0
  154. package/dist/viewer/code-file.d.ts +20 -0
  155. package/dist/viewer/code-file.js +42 -0
  156. package/dist/viewer/editor-tools.d.ts +38 -0
  157. package/dist/viewer/editor-tools.js +211 -0
  158. package/dist/viewer/file-references.d.ts +14 -0
  159. package/dist/viewer/file-references.js +38 -0
  160. package/dist/viewer/font-picker.d.ts +4 -0
  161. package/dist/viewer/font-picker.js +145 -0
  162. package/dist/viewer/font-settings.d.ts +11 -0
  163. package/dist/viewer/font-settings.js +50 -0
  164. package/dist/viewer/fonts/LICENSE.txt +119 -0
  165. package/dist/viewer/fonts/udev-gothic-regular.woff2 +0 -0
  166. package/dist/viewer/fonts.d.ts +2 -0
  167. package/dist/viewer/fonts.js +13 -0
  168. package/dist/viewer/git-diff.d.ts +3 -0
  169. package/dist/viewer/git-diff.js +33 -0
  170. package/dist/viewer/git-types.d.ts +73 -0
  171. package/dist/viewer/git-types.js +1 -0
  172. package/dist/viewer/git.d.ts +5 -0
  173. package/dist/viewer/git.js +337 -0
  174. package/dist/viewer/hmr.d.ts +53 -0
  175. package/dist/viewer/hmr.js +335 -0
  176. package/dist/viewer/i18n.d.ts +6 -0
  177. package/dist/viewer/i18n.js +65 -0
  178. package/dist/viewer/ja-lint.d.ts +3 -0
  179. package/dist/viewer/ja-lint.js +44 -0
  180. package/dist/viewer/language-picker.d.ts +2 -0
  181. package/dist/viewer/language-picker.js +233 -0
  182. package/dist/viewer/languages.d.ts +11 -0
  183. package/dist/viewer/languages.js +20 -0
  184. package/dist/viewer/locales/eng.json +294 -0
  185. package/dist/viewer/locales/jpn.json +294 -0
  186. package/dist/viewer/locales/zho.json +294 -0
  187. package/dist/viewer/nav-focus.d.ts +2 -0
  188. package/dist/viewer/nav-focus.js +64 -0
  189. package/dist/viewer/plugin-blocks.d.ts +3 -0
  190. package/dist/viewer/plugin-blocks.js +115 -0
  191. package/dist/viewer/plugin-config.d.ts +35 -0
  192. package/dist/viewer/plugin-config.js +57 -0
  193. package/dist/viewer/plugin-view.d.ts +3 -0
  194. package/dist/viewer/plugin-view.js +28 -0
  195. package/dist/viewer/plugins.d.ts +8 -0
  196. package/dist/viewer/plugins.js +214 -0
  197. package/dist/viewer/resize.d.ts +4 -0
  198. package/dist/viewer/resize.js +238 -0
  199. package/dist/viewer/resource-library.d.ts +29 -0
  200. package/dist/viewer/resource-library.js +104 -0
  201. package/dist/viewer/search.d.ts +16 -0
  202. package/dist/viewer/search.js +358 -0
  203. package/dist/viewer/serve.d.ts +206 -0
  204. package/dist/viewer/serve.js +3233 -0
  205. package/dist/viewer/site-icon.d.ts +8 -0
  206. package/dist/viewer/site-icon.js +56 -0
  207. package/dist/viewer/textfile.d.ts +9 -0
  208. package/dist/viewer/textfile.js +50 -0
  209. package/dist/viewer/theme-picker.d.ts +2 -0
  210. package/dist/viewer/theme-picker.js +135 -0
  211. package/dist/viewer/workspace.d.ts +4 -0
  212. package/dist/viewer/workspace.js +47 -0
  213. package/package.json +127 -5
@@ -0,0 +1,3233 @@
1
+ import { filesSnapshot, updateFiles, uploadFile } from "../compiler/files.js";
2
+ import { loadProjectFonts } from "../compiler/fonts.js";
3
+ import { textfilePath, writeTextfile } from "../compiler/textfile.js";
4
+ import { defaultFilePath, docIconFile, MAX_FILE_UPLOAD_BYTES, parseFileLayout, docFile as sourceDocFile, suggestDocFileName, } from "../core/files.js";
5
+ // The local server itself (shared by preview / edit). Files are re-read on every request, so a
6
+ // change is always reflected on the next request. HMR (hmr.ts) triggers that "next request"
7
+ // automatically: content changes are pushed over SSE and preview swaps just the main body, while
8
+ // src/*.ts changes cause a tsx watch restart → reconnect → reload (= no manual reload or restart
9
+ // needed). Rendering goes through the same "mdast → Block AST → HTML" pipeline as the build.
10
+ // Dialect validation errors are shown at the top of the page.
11
+ // preview = read-only view
12
+ // edit = left = live render of the body / right = front-matter form + insert palette +
13
+ // CodeMirror (`dooc edit`). The save API rewrites content/<id>.md directly,
14
+ // and hierarchy changes (create / move / rename / delete) rewrite
15
+ // dooc.yaml.
16
+ // The client (src/viewer/edit-client.ts) is bundled in memory with esbuild on
17
+ // first request (cached) and served.
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { createServer } from "node:http";
21
+ import { dirname, join, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { parseContentSource } from "../compiler/code-blocks.js";
24
+ import { compileDraft, createCompiler, } from "../compiler/compile.js";
25
+ import { figureName } from "../compiler/figures.js";
26
+ import { ICONS_DIR, squareSvg } from "../compiler/icons.js";
27
+ import { contentLanguageCode, contentLanguages, isContentLanguage, webLanguage, } from "../compiler/language-codes.js";
28
+ import { setDocTranslation, siteContentLanguages, translationFile, translationFiles, } from "../compiler/locales.js";
29
+ import { readManifest, writeManifest } from "../compiler/manifest.js";
30
+ import { assembleDoc, createDocOps, isOpError } from "../compiler/ops.js";
31
+ import { walkBlocks } from "../core/blocks.js";
32
+ import { docIdOfFile } from "../core/content-metadata.js";
33
+ import { figureFrameHtml, mountFigureFrames } from "../core/figure-frame.js";
34
+ import { blocksHtml, docHtml, escapeHtml, termsHtml } from "../core/html.js";
35
+ import { DEFAULT_LANG, isLangTag, resolveLanguage, } from "../core/language.js";
36
+ import { defaultPreviewSettings, parsePreviewSettings, } from "../core/previewers.js";
37
+ import { ancestorsOf, childrenOf, docPath, resolveDocPath } from "../core/routing.js";
38
+ import { mountTables } from "../core/table-ui.js";
39
+ import { CODE_LANGS, docDisplayName } from "../core/taxonomy.js";
40
+ import { isTermFile, termAnchor, termFileUrl } from "../core/term-reference.js";
41
+ import { mountTerms } from "../core/terms-ui.js";
42
+ import { isRemoteTextfile, textfileName } from "../core/textfile.js";
43
+ import { mountThemeVariants } from "../core/theme-variants.js";
44
+ import { mountToc } from "../core/toc-ui.js";
45
+ import { mountVideos } from "../core/video-ui.js";
46
+ import { parseWorkspaceSource, workspaceLabel } from "../core/workspace.js";
47
+ import { readViewerAsset, prebuilt as usePrebuiltAssets } from "./assets.js";
48
+ import { mountBundleNetwork } from "./bundle-network.js";
49
+ import { codeFileResponse } from "./code-file.js";
50
+ import { isLocalToolRequest, runCodeTool, validateCodeTools, validateTextfileCode, } from "./editor-tools.js";
51
+ import { fileReferences } from "./file-references.js";
52
+ import { mountFontPickers } from "./font-picker.js";
53
+ import { createFontSettings } from "./font-settings.js";
54
+ import { fontCss, readFont } from "./fonts.js";
55
+ import { createGitHandler, validateGitOrigin } from "./git.js";
56
+ import { gitBaseBody, gitBaseText } from "./git-diff.js";
57
+ import { createHmrHub, HMR_PATH, hmrClientScript, watchProject } from "./hmr.js";
58
+ import { translate } from "./i18n.js";
59
+ import { DEFAULT_SITE_LANGUAGES, loadSiteLanguages } from "./languages.js";
60
+ import { focusNavTree } from "./nav-focus.js";
61
+ import { loadViewerPlugins } from "./plugin-config.js";
62
+ import { createPluginHandler } from "./plugins.js";
63
+ import { mountPanelResize, mountToolsResize } from "./resize.js";
64
+ import { resourceIndex, resourceLibraryHtml, resourceTabs } from "./resource-library.js";
65
+ import { mountSearch, SEARCH_LABELS, searchIndex, searchSections } from "./search.js";
66
+ import { siteIconResponse } from "./site-icon.js";
67
+ import { previewTextfiles, textfileResponse } from "./textfile.js";
68
+ import { mountThemePickers } from "./theme-picker.js";
69
+ import { readWorkspaceSource } from "./workspace.js";
70
+ // pkgRoot = the viewer itself (src/ for the client bundles). docsRoot = the docs folder that
71
+ // follows the layout convention (content/ figures/).
72
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
73
+ const root = resolve(process.env.DOCS_ROOT ?? process.cwd());
74
+ const CONTENT_PREFIX = "content/";
75
+ const toEntry = (doc, source) => ({
76
+ id: doc.id,
77
+ ...(doc.parent == null ? {} : { parent: doc.parent }),
78
+ file: `${doc.id}.md`,
79
+ sourceFile: doc.file,
80
+ frontmatter: source.frontmatter,
81
+ lang: doc.lang,
82
+ body: doc.body,
83
+ blocks: doc.blocks,
84
+ toc: doc.toc,
85
+ blockLines: source.lines,
86
+ raw: source.raw,
87
+ errors: source.errors,
88
+ ...(doc.icon == null ? {} : { icon: doc.icon }),
89
+ figureRefs: doc.refs.figures,
90
+ termRefs: doc.refs.terms,
91
+ terms: source.terms,
92
+ });
93
+ // Recompile source files on every request. The compiler's cache
94
+ // skips re-parsing files unchanged on disk, so it stays cheap even when called on every keystroke
95
+ // (POST /api/render). Replaced in startServer once docs.config.mjs (language adapters)
96
+ // has been loaded.
97
+ let siteLanguages = DEFAULT_SITE_LANGUAGES;
98
+ let compiler = createCompiler(root);
99
+ const ops = createDocOps(root);
100
+ /** Compile result → viewer Site (shared by the server's per-request path and one-off publish). */
101
+ export const siteFrom = ({ title, icon, docs, terms, figures, sources, siteErrors, defaultLang, previews, contentLanguages, themes, }, languages = DEFAULT_SITE_LANGUAGES.languages) => {
102
+ return {
103
+ previews,
104
+ ...(contentLanguages ? { contentLanguages } : {}),
105
+ ...(themes ? { themes } : {}),
106
+ ...(title === undefined ? {} : { title }),
107
+ ...(icon === undefined ? {} : { icon }),
108
+ entries: docs.flatMap((doc) => {
109
+ const source = sources.get(doc.file);
110
+ return source ? [toEntry(doc, source)] : [];
111
+ }),
112
+ ctx: {
113
+ docLinks: new Map(docs.map((doc) => [doc.file, docPath(docs, doc.id)])),
114
+ figures: new Map(figures.map((f) => [f.name, f])),
115
+ terms,
116
+ termLinks: new Map([
117
+ ...terms.map((term) => [
118
+ term.id,
119
+ `/_files/terms#${encodeURIComponent(termAnchor(term.id))}`,
120
+ ]),
121
+ ...docs.flatMap((doc) => doc.blocks.flatMap((block) => block.type === "term"
122
+ ? [
123
+ [
124
+ block.entry.id,
125
+ `/${doc.id}#${encodeURIComponent(termAnchor(block.entry.id))}`,
126
+ ],
127
+ ]
128
+ : [])),
129
+ ]),
130
+ },
131
+ errors: siteErrors,
132
+ languages,
133
+ defaultLang,
134
+ };
135
+ };
136
+ const localeCompilers = new Map();
137
+ let workspaceSource;
138
+ let applicationPreviews = defaultPreviewSettings();
139
+ let watchTextfiles;
140
+ const watchEntryTextfiles = (entries) => {
141
+ watchTextfiles?.(entries.flatMap((entry) => [
142
+ entry.sourceFile ?? CONTENT_PREFIX + entry.file,
143
+ ...fileReferences(entry.body).map((reference) => reference.file),
144
+ ]));
145
+ };
146
+ const loadSite = (locale) => {
147
+ const previews = applicationPreviews;
148
+ const manifest = readManifest(root);
149
+ const source = contentLanguageCode(manifest.defaultLang ?? DEFAULT_LANG);
150
+ locale ??= source;
151
+ let selected = compiler;
152
+ if (locale !== source) {
153
+ selected = localeCompilers.get(locale) ?? createCompiler(root, { locale, previews });
154
+ localeCompilers.set(locale, selected);
155
+ }
156
+ const site = siteFrom(selected.compile(), siteLanguages.languages);
157
+ const sourceLanguage = contentLanguageCode(site.defaultLang);
158
+ for (const entry of site.entries) {
159
+ entry.translatedLanguages = [sourceLanguage];
160
+ entry.iconFile = docIconFile(entry.id, manifest.files);
161
+ }
162
+ try {
163
+ const translated = new Set(translationFiles(root));
164
+ for (const language of siteContentLanguages(root, manifest).filter((language) => language !== sourceLanguage)) {
165
+ for (const entry of site.entries) {
166
+ if (translated.has(translationFile(sourceDocFile(entry.id, manifest.files), language)))
167
+ entry.translatedLanguages?.push(language);
168
+ }
169
+ }
170
+ }
171
+ catch (error) {
172
+ site.errors.push(String(error));
173
+ }
174
+ watchEntryTextfiles(site.entries);
175
+ const siteIconFile = manifest.icon;
176
+ if (siteIconFile)
177
+ watchTextfiles?.([siteIconFile]);
178
+ return {
179
+ ...site,
180
+ fonts: loadProjectFonts(root),
181
+ workspace: workspaceSource ?? readWorkspaceSource(root),
182
+ filePaths: manifest.errors.length
183
+ ? undefined
184
+ : filesSnapshot(root).files.map((file) => file.path),
185
+ codeLanguages: siteLanguages.codeLanguages,
186
+ codeGrammars: siteLanguages.codeGrammars,
187
+ contentLanguage: locale,
188
+ };
189
+ };
190
+ const draftContext = (site) => ({
191
+ previews: site.previews,
192
+ root,
193
+ docs: site.entries.map((e) => ({
194
+ id: e.id,
195
+ ...(e.parent == null ? {} : { parent: e.parent }),
196
+ file: e.sourceFile ?? CONTENT_PREFIX + e.file,
197
+ toc: e.toc,
198
+ })),
199
+ figures: site.ctx.figures,
200
+ defaultLang: site.defaultLang,
201
+ });
202
+ // fallbackTitle stands in for the h1 when frontmatter is missing. Each doc renders with its own
203
+ // language's adapter (term ordering) and `<article lang>`.
204
+ const docLinks = (site, html) => html.replace(/href="\/([a-z0-9][a-z0-9-]*)(?=["?#])/g, (whole, id) => site.entries.some((entry) => entry.id === id)
205
+ ? `href="${docPath(site.entries, id)}`
206
+ : whole);
207
+ const renderEntry = (site, entry, fallbackTitle) => docLinks(site, docHtml(entry.frontmatter ?? { title: fallbackTitle }, entry.blocks, [...site.errors, ...entry.errors], {
208
+ ...site.ctx,
209
+ terms: entry.terms ?? site.ctx.terms,
210
+ lang: webLanguage(entry.lang),
211
+ tocTitle: translate(entry.lang, "Contents"),
212
+ sectionUrl: `${docPath(site.entries, entry.id)}?lang=${encodeURIComponent(site.contentLanguage ?? entry.lang)}`,
213
+ language: resolveLanguage(entry.lang, site.languages),
214
+ }, entry.toc));
215
+ /** Compiles one source (unsaved or on disk; file = `<id>.md`) in the site context into an Entry. */
216
+ const draftEntry = (site, file, raw, textfiles) => {
217
+ const known = site.entries.find((entry) => entry.file === file);
218
+ const { doc, source } = compileDraft({ ...draftContext(site), defaultLang: known?.lang ?? site.defaultLang, textfiles }, known?.sourceFile ?? CONTENT_PREFIX + file, raw);
219
+ const entry = toEntry(doc, source);
220
+ watchEntryTextfiles([entry]);
221
+ return entry;
222
+ };
223
+ export const filePreviewResults = (entry, site) => {
224
+ const results = new Map();
225
+ const bodyOffset = entry.raw.slice(0, entry.raw.length - entry.body.length).split("\n").length - 1;
226
+ for (const { line, file, kind } of fileReferences(entry.body, site.previews)) {
227
+ const errors = results.get(file) ?? [];
228
+ errors.push(...entry.errors.filter((error) => error.startsWith(`L${bodyOffset + line}:`)));
229
+ if (kind === "term") {
230
+ errors.push(...entry.errors.filter((error) => error.startsWith(`${file}:`)));
231
+ }
232
+ else if (kind === "textfile") {
233
+ errors.push(...entry.errors.filter((error) => error.startsWith(`${file}:`)));
234
+ for (const block of walkBlocks(entry.blocks)) {
235
+ if (block.type !== "code" || block.filename?.replace(/^(\.\/)+/, "") !== file)
236
+ continue;
237
+ if (block.unavailable)
238
+ errors.push(block.unavailable === "missing"
239
+ ? "File not found"
240
+ : "File could not be loaded");
241
+ errors.push(...entry.errors.filter((error) => error.startsWith(`${block.filename}${block.range ?? ""}:`)));
242
+ }
243
+ }
244
+ else {
245
+ if (site.ctx.figures.get(figureName(file))?.file !== file)
246
+ errors.push("File not found");
247
+ errors.push(...site.errors.filter((error) => error.startsWith(`${file}:`)));
248
+ }
249
+ results.set(file, [...new Set(errors)]);
250
+ }
251
+ return Object.fromEntries(results);
252
+ };
253
+ // Render the raw being edited (unsaved draft) to body html — shared by the save / render APIs.
254
+ const entryGitBase = (entry, site) => site.workspace?.protocol === "git"
255
+ ? gitBaseBody(root, entry.sourceFile ?? CONTENT_PREFIX + entry.file)
256
+ : null;
257
+ const renderDraft = (site, file, raw, textfiles) => {
258
+ const entry = draftEntry(site, file, raw, textfiles);
259
+ return {
260
+ gitBase: entryGitBase(entry, site),
261
+ sourceFile: entry.sourceFile,
262
+ html: renderEntry(site, entry, ""),
263
+ errors: [...site.errors, ...entry.errors],
264
+ toc: entry.toc,
265
+ blockLines: entry.blockLines,
266
+ filePreviews: filePreviewResults(entry, site),
267
+ previews: site.previews,
268
+ };
269
+ };
270
+ // Render result cache — opening the same doc repeatedly (page GET / nav switch) doesn't re-render.
271
+ // The compiler's cache returns the same blocks reference while disk is unchanged, so check that,
272
+ // plus that the id list (affects document file link resolution) / terms / figures / errors haven't
273
+ // changed. Rebuild if any of them did.
274
+ const renderSignature = (site, entry) => JSON.stringify([site.ctx.terms, site.errors, entry.errors, entry.frontmatter, entry.lang]) +
275
+ site.entries
276
+ .map((e) => [e.sourceFile ?? CONTENT_PREFIX + e.file, docPath(site.entries, e.id)])
277
+ .sort()
278
+ .join("\n");
279
+ const entryHtmlCache = new Map();
280
+ /** Renders only the requested doc, not every doc in site.entries. */
281
+ export const renderEntryHtml = (site, entry) => {
282
+ const sig = renderSignature(site, entry);
283
+ const cached = entryHtmlCache.get(entry.file);
284
+ if (cached &&
285
+ cached.blocks === entry.blocks &&
286
+ cached.sig === sig &&
287
+ cached.figures === site.ctx.figures) {
288
+ return cached.value;
289
+ }
290
+ const value = {
291
+ html: renderEntry(site, entry, entry.id),
292
+ errors: [...site.errors, ...entry.errors],
293
+ };
294
+ entryHtmlCache.set(entry.file, {
295
+ blocks: entry.blocks,
296
+ sig,
297
+ figures: site.ctx.figures,
298
+ value,
299
+ });
300
+ return value;
301
+ };
302
+ /** Compiles + renders one source (its current content on disk) (implements `/api/doc`). */
303
+ export const renderDocFile = (site, file, raw) => {
304
+ const entry = draftEntry(site, file, raw);
305
+ return {
306
+ gitBase: entryGitBase(entry, site),
307
+ sourceFile: entry.sourceFile,
308
+ raw,
309
+ frontmatter: entry.frontmatter,
310
+ body: entry.body,
311
+ html: renderEntry(site, entry, ""),
312
+ errors: [...site.errors, ...entry.errors],
313
+ toc: entry.toc,
314
+ blockLines: entry.blockLines,
315
+ filePreviews: filePreviewResults(entry, site),
316
+ previews: site.previews,
317
+ };
318
+ };
319
+ /** Response for `GET /api/doc?file=<id>.md` (status + body). mode === "edit" only. */
320
+ export const docQueryResponse = (site, file) => {
321
+ const target = resolveContentFile(file);
322
+ if (!target) {
323
+ return { status: 400, body: { error: "file must be <id>.md" } };
324
+ }
325
+ if (!existsSync(target.abs)) {
326
+ return { status: 404, body: { error: `${target.name} not found` } };
327
+ }
328
+ return {
329
+ status: 200,
330
+ body: renderDocFile(site, target.name, site.entries.find((entry) => entry.file === target.name)?.raw ??
331
+ readFileSync(target.abs, "utf8")),
332
+ };
333
+ };
334
+ export { assembleDoc };
335
+ const asString = (v) => (typeof v === "string" ? v : "");
336
+ /** Client JSON → Frontmatter (keeps supported fields and drops empty optional values). */
337
+ export const sanitizeFrontmatter = (v) => {
338
+ const o = (typeof v === "object" && v != null ? v : {});
339
+ const lang = asString(o.lang).trim();
340
+ return { title: asString(o.title).trim(), ...(lang ? { lang } : {}) };
341
+ };
342
+ /**
343
+ * Compares the base the editor started from (the disk source the client last read) with the
344
+ * current disk source to tell whether it was rewritten externally (direct edit / git etc.)
345
+ * without the editor knowing.
346
+ * A client that sends no base (no baseRaw) can't be judged, so false (= overwrite).
347
+ */
348
+ export const isExternallyModified = (baseRaw, currentRaw) => typeof baseRaw === "string" && baseRaw !== currentRaw;
349
+ /**
350
+ * Validates the write target path — rejects anything other than `<id>.md` (directly under
351
+ * content/), including traversal.
352
+ */
353
+ export const resolveContentFile = (rel) => {
354
+ const id = typeof rel === "string" ? docIdOfFile(rel) : undefined;
355
+ return id == null
356
+ ? undefined
357
+ : {
358
+ id,
359
+ name: `${id}.md`,
360
+ abs: textfilePath(root, sourceDocFile(id, readManifest(root).files)),
361
+ };
362
+ };
363
+ // Default palette — resolves the figures' var(--doc-*) and color-mix() following the theme. The
364
+ // theme has 3 states: default = follow the OS (prefers-color-scheme), explicitly overridden via
365
+ // <html data-theme="light|dark">.
366
+ // Maps the viewer's --c-* palette onto the body's --doc-* tokens. Unmapped tokens use defaults.
367
+ const DARK_C = `
368
+ color-scheme: dark;
369
+ --c-bg0:#101316; --c-bg1:#16191d; --c-surface:#1b1f24; --c-raised:#22272d;
370
+ --c-line:#2a3037; --c-line-soft:#22272c;
371
+ --c-fg:#f5f6f7; --c-fg-muted:#b4bac0; --c-fg-faint:#8a939b; --fw-body:400;
372
+ --c-accent:#e65100; --c-ok:#4f9e6a; --c-danger:#c65b52; --c-warn:#c99a3a; --c-info:#6b8fbf;
373
+ --c-shadow:rgba(0,0,0,.4);
374
+ --doc-fg-4:#525a66; --doc-accent-2:#4a8bff; --doc-accent-fg:#ffffff;
375
+ --doc-ok-2:#56d364; --doc-warn-2:#ffc14d; --doc-danger-2:#ff7a7a; --doc-info-2:#79b0ff;`;
376
+ const LIGHT_C = `
377
+ color-scheme: light;
378
+ --c-bg0:#ffffff; --c-bg1:#f5f7f9; --c-surface:#fbfcfd; --c-raised:#e9edf2;
379
+ --c-line:#d5dae0; --c-line-soft:#e4e8ec;
380
+ --c-fg:#1a1e23; --c-fg-muted:#5a636c; --c-fg-faint:#7b848e; --fw-body:300;
381
+ --c-accent:#c24800; --c-ok:#2f7d4f; --c-danger:#b23c31; --c-warn:#8a6516; --c-info:#3a6ea5;
382
+ --c-shadow:rgba(15,23,32,.14);
383
+ --doc-fg-4:#9099a8; --doc-accent-2:#0277bd; --doc-accent-fg:#ffffff;
384
+ --doc-ok-2:#2ea043; --doc-warn-2:#f59f00; --doc-danger-2:#f04848; --doc-info-2:#4a8bff;`;
385
+ const DOC_FROM_C = `
386
+ --doc-bg:var(--c-bg0); --doc-bg-2:var(--c-bg1); --doc-bg-3:var(--c-surface);
387
+ --doc-panel:var(--c-surface); --doc-panel-2:var(--c-raised); --doc-rail:var(--c-bg1);
388
+ --doc-border:var(--c-line-soft); --doc-border-2:var(--c-line);
389
+ --doc-fg:var(--c-fg); --doc-fg-2:var(--c-fg-muted); --doc-fg-3:var(--c-fg-faint);
390
+ --doc-accent:var(--c-accent); --doc-ok:var(--c-ok); --doc-warn:var(--c-warn);
391
+ --doc-danger:var(--c-danger); --doc-info:var(--c-info);`;
392
+ // Default for code and editing; browser display settings can override each independently.
393
+ const MONO_FONT = `"UDEV Gothic", monospace`;
394
+ const SANS_FONT = `"IBM Plex Sans JP", -apple-system, "Segoe UI", "Noto Sans JP", sans-serif`;
395
+ // Viewer icons (20x20 stroke glyphs).
396
+ const icon = (paths, size = 18) => `<svg class="nav-icon" viewBox="0 0 20 20" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
397
+ const SIDEBAR_ICON = icon('<rect x="3" y="4" width="14" height="12" rx="1.5"/><path d="M8 4v12"/>');
398
+ 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.3v9c0 .7.6 1.3 1.3 1.3 1.8 0 3.5.5 4.7 1.5"/><path d="M10 5.2c1.2-1 2.9-1.5 4.7-1.5.7 0 1.3.6 1.3 1.3v9c0 .7-.6 1.3-1.3 1.3-1.8 0-3.5.5-4.7 1.5Z"/><path d="M10 5.2v11.6"/>');
399
+ const CHEVRON_ICON = icon('<path d="M8 5.5 12.5 10 8 14.5"/>', 14);
400
+ 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
+ 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"/>');
402
+ 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
+ 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
+ const workspaceName = (site) => site.workspace?.protocol === "storage" ? workspaceLabel(site.workspace) : "Files";
405
+ const workspaceText = (site) => `<span${site.workspace?.protocol === "storage" ? "" : ' data-i18n="Files"'}>${escapeHtml(workspaceName(site))}</span>`;
406
+ const workspaceIcon = (site) => {
407
+ if (site.workspace?.protocol === "git")
408
+ return icon('<path d="M2 5h6l2 2h8v10H2Z"/>');
409
+ return site.workspace?.icon
410
+ ? svgImg(site.workspace.icon, "nav-icon workspace-icon")
411
+ : STORAGE_ICON;
412
+ };
413
+ const UPLOAD_ICON = icon('<path d="M11.5 3H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6.5L11.5 3Z"/><path d="M11.5 3v3.5H15M10 14V9M7.8 11.2 10 9l2.2 2.2"/>', 24);
414
+ const svgDataUri = (svg) => `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
415
+ /**
416
+ * Full svg → <img> (data URI; inline svgs would let the ids / styles of multiple icons collide,
417
+ * so each is confined to an img).
418
+ */
419
+ const svgImg = (svg, cls) => {
420
+ const uri = svgDataUri(svg);
421
+ const img = `<img class="${cls}" alt="" src="${uri}">`;
422
+ return cls.includes("doc-icon")
423
+ ? `<span class="doc-icon-frame" style="--doc-icon:url('${uri}')">${img}</span>`
424
+ : img;
425
+ };
426
+ const GEAR_ICON = icon('<path d="M4 6h7M15 6h1M4 14h1M9 14h7"/><circle cx="13" cy="6" r="2"/><circle cx="7" cy="14" r="2"/>', 16);
427
+ const DELETE_ICON = icon('<path d="M4.5 6h11M8 6V4.5h4V6M6 6l.7 9.5h6.6L14 6M8.7 9v4M11.3 9v4"/>', 15);
428
+ const PLUS_ICON = icon('<path d="M10 4.5v11M4.5 10h11"/>', 15);
429
+ const THEME_ICONS = {
430
+ "": icon('<circle cx="10" cy="10" r="6.5"/><path d="M10 3.5a6.5 6.5 0 0 0 0 13Z" fill="currentColor" stroke="none"/>'),
431
+ light: icon('<circle cx="10" cy="10" r="3.4"/><path d="M10 2.6v1.8M10 15.6v1.8M2.6 10h1.8M15.6 10h1.8M4.8 4.8l1.3 1.3M13.9 13.9l1.3 1.3M15.2 4.8l-1.3 1.3M6.1 13.9l-1.3 1.3"/>'),
432
+ dark: icon('<path d="M15.5 12.4A6.2 6.2 0 0 1 7.6 4.5a6.5 6.5 0 1 0 7.9 7.9Z"/>'),
433
+ };
434
+ const fontButton = (target) => {
435
+ const label = target === "editor" ? "Editor font" : "Viewer font";
436
+ return `<button class="pbtn font-control" type="button" data-font-picker="${target}" aria-haspopup="menu" aria-expanded="false" aria-label="${label}" title="${label}" disabled>${icon('<path d="M2.5 15 7 4l4.5 11M4 11h6M13 10.5c0-2.5 5-2.5 5 0V15M18 12h-2.5c-3.5 0-3.5 4 0 3L18 13.5"/>')}</button>`;
437
+ };
438
+ const CSS = `
439
+ :root { ${DARK_C} ${DOC_FROM_C} --doc-mono: ${MONO_FONT}; --font-sans: ${SANS_FONT}; }
440
+ @media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) { ${LIGHT_C} } }
441
+ :root[data-theme="light"] { ${LIGHT_C} }
442
+ body.edit :is(#sidebar, #panel, .resources-pane), .preview-pane[data-theme], .theme-picker[data-theme] { ${DARK_C} ${DOC_FROM_C} }
443
+ @media (prefers-color-scheme: light) {
444
+ :root:not([data-editor-theme="dark"]) body.edit :is(#sidebar, #panel, .resources-pane),
445
+ :is(.preview-pane, .theme-picker)[data-theme="auto"] { ${LIGHT_C} }
446
+ }
447
+ :root[data-editor-theme="light"] body.edit :is(#sidebar, #panel, .resources-pane),
448
+ :is(.preview-pane, .theme-picker)[data-theme="light"] { ${LIGHT_C} }
449
+ .preview-pane { background: var(--c-bg0); color: var(--c-fg); }
450
+ .theme-picker { position: fixed; margin: 0; padding: 5px; width: 148px; border: 1px solid var(--c-line);
451
+ border-radius: 6px; background: var(--c-bg1); color: var(--c-fg); box-shadow: 0 8px 24px var(--c-shadow); }
452
+ .theme-picker button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 8px;
453
+ border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-size: 12px; text-align: left; cursor: pointer; }
454
+ .theme-picker button:hover, .theme-picker button:focus-visible { background: var(--c-raised); }
455
+ .theme-picker [aria-checked="true"]::after { content: "✓"; margin-left: auto; color: var(--c-accent); }
456
+ .font-control:focus:not(:focus-visible), .font-picker button:focus:not(:focus-visible) { outline: none; }
457
+ .font-control:focus-visible, .font-picker button:focus-visible { outline: 1px solid var(--c-accent); outline-offset: 1px; }
458
+ .font-control:disabled { opacity: .5; cursor: wait; }
459
+ .font-picker { width: min(240px, calc(100vw - 16px)); overflow-y: auto; }
460
+ .font-picker button { overflow-wrap: anywhere; }
461
+ .font-picker [aria-checked="true"]::after { flex: none; }
462
+ .font-status { padding: 8px; font-size: 11px; color: var(--c-danger); }
463
+ main { --doc-mono: var(--viewer-font, ${MONO_FONT}); }
464
+ .resources-pane { --doc-mono: var(--editor-font, ${MONO_FONT}); background: var(--c-bg1); color: var(--c-fg); font-family: var(--doc-mono); }
465
+ .page-actions, .pane-actions { position: sticky; display: flex; align-items: center; justify-content: flex-end;
466
+ gap: 2px; height: 36px; padding: 0 24px; border-bottom: 1px solid var(--c-line); background: var(--c-bg0); z-index: 5; }
467
+ .page-actions { top: 0; margin: -20px -24px 20px; }
468
+ .pbtn { flex: none; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px;
469
+ padding: 0; color: var(--c-fg-faint); background: none; border: none; border-radius: 5px; cursor: pointer;
470
+ transition: color 120ms ease, background 120ms ease; }
471
+ .pbtn:hover, .pbtn:focus-visible, #gear.open > .pbtn { color: var(--c-fg); background: var(--c-raised); }
472
+ * { box-sizing: border-box; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--c-fg-muted) 42%, transparent) transparent; }
473
+ ::-webkit-scrollbar { width: 7px; height: 7px; }
474
+ ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
475
+ ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--c-fg-muted) 42%, transparent); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
476
+ ::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--c-fg-muted) 68%, transparent); }
477
+ @supports selector(::-webkit-scrollbar) { * { scrollbar-width: auto; scrollbar-color: auto; } }
478
+ input[type="checkbox"] { appearance: none; position: relative; display: inline-block; flex: none; width: 15px; height: 15px; margin: 0; vertical-align: middle; border: 1px solid var(--c-fg-muted); border-radius: 4px; background: transparent; cursor: pointer; }
479
+ input[type="checkbox"]:checked { border-color: var(--c-accent); }
480
+ input[type="checkbox"]:checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid var(--c-accent); border-width: 0 2px 2px 0; transform: rotate(45deg); }
481
+ input[type="checkbox"]:indeterminate::after { content: ""; position: absolute; left: 3px; top: 6px; width: 7px; border-top: 2px solid var(--c-accent); }
482
+ input[type="checkbox"]:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 3px; }
483
+ input[type="checkbox"]:disabled { opacity: .5; cursor: default; }
484
+ @media (forced-colors: active) { input[type="checkbox"] { appearance: auto; } input[type="checkbox"]::after { display: none; } }
485
+ body { margin: 0; display: flex; min-height: 100vh; background: var(--c-bg0); color: var(--c-fg);
486
+ font-family: var(--font-sans); font-weight: var(--fw-body); font-size: 13px; line-height: 1.55;
487
+ overflow-x: hidden; }
488
+ button { font-family: inherit; font-size: inherit; font-weight: var(--fw-body); }
489
+ :focus-visible { outline: 1px solid var(--c-accent); outline-offset: 1px; }
490
+
491
+ #doc-search { position: fixed; top: 12px; left: 0; right: 0; z-index: 20;
492
+ width: min(600px, calc(100vw - 24px)); min-height: 56px; max-height: min(420px, calc(100dvh - 24px)); margin: 0 auto;
493
+ padding: 0 10px 10px; color: var(--c-fg); background: color-mix(in srgb, var(--c-bg1) 82%, transparent);
494
+ border: 1px solid color-mix(in srgb, var(--c-fg-muted) 55%, transparent);
495
+ backdrop-filter: blur(16px); border-radius: 8px; box-shadow: 0 8px 24px #0003; overflow: hidden; }
496
+ #doc-search[open] { display: flex; flex-direction: column; }
497
+ #doc-search :focus-visible { outline-color: var(--c-fg-muted); }
498
+ .search-head { display: flex; flex: none; align-items: center; gap: 10px; padding: 8px;
499
+ color: var(--c-fg-muted); }
500
+ .search-head svg { flex: none; }
501
+ #doc-search.search-filled .search-head svg { display: none; }
502
+ #doc-search-input { width: 100%; min-width: 0; padding: 4px 0; font: inherit; font-size: 15px;
503
+ color: var(--c-fg); background: none; border: 0; outline: none; }
504
+ #doc-search-input::-webkit-search-cancel-button { appearance: none; }
505
+ .search-status { padding: 8px 16px; color: var(--c-fg-muted); font-size: 12px; }
506
+ .search-status:empty { display: none; }
507
+ .search-count { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden;
508
+ clip-path: inset(50%); white-space: nowrap; }
509
+ #doc-search-results { overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
510
+ #doc-search-results a { display: flex; flex: none; flex-direction: column; gap: 6px;
511
+ border: 1px solid var(--c-line); border-radius: 5px; color: var(--c-fg); text-decoration: none;
512
+ background: color-mix(in srgb, var(--c-bg0) 50%, transparent); overflow-wrap: anywhere; }
513
+ #doc-search-results a:hover, #doc-search-results a[aria-selected="true"] { border-color: var(--c-fg-muted); }
514
+ .search-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px;
515
+ padding: 5px 10px; border-bottom: 1px solid var(--c-line); color: var(--c-fg-muted); }
516
+ .search-path { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
517
+ #doc-search-results strong { margin: 2px 10px 0; font-weight: 500; }
518
+ #doc-search-results span { margin: 0 10px 10px; color: var(--c-fg-muted); }
519
+ .search-grip { position: absolute; top: 8px; bottom: 18px; width: 10px; cursor: grab; touch-action: none; }
520
+ .search-grip-left { left: 0; }
521
+ .search-grip-right { right: 0; }
522
+ .search-grip:active { cursor: grabbing; }
523
+ .search-grip::after { content: ""; position: absolute; top: 35%; bottom: 35%; left: 4px;
524
+ width: 2px; border-radius: 2px; background: var(--c-line); }
525
+ .search-grip:hover::after, .search-grip:focus-visible::after { background: var(--c-fg-muted); }
526
+ .search-resize { position: absolute; bottom: 0; right: 0; width: 18px; height: 18px;
527
+ cursor: nwse-resize; touch-action: none; }
528
+ .search-resize::after { content: ""; position: absolute; right: 4px; bottom: 4px; width: 7px; height: 7px;
529
+ border-right: 1px solid var(--c-fg-muted); border-bottom: 1px solid var(--c-fg-muted); }
530
+
531
+ /* ── sidebar ── */
532
+ #sidebar { width: var(--sidebar-width, 260px); height: 100vh; position: sticky; top: 0; flex: none;
533
+ display: flex; flex-direction: column; background: var(--c-bg1);
534
+ border-right: 1px solid var(--c-line); overflow: hidden; }
535
+ body.sidebar-closed #sidebar { width: 52px; }
536
+ body.sidebar-closed #sidebar-resize { display: none; }
537
+ .panel-resize { position: absolute; top: 0; bottom: 0; width: 8px; cursor: col-resize; z-index: 6;
538
+ touch-action: none; transition: background 120ms ease; }
539
+ #sidebar-resize { right: 0; }
540
+ #resize { left: 0; }
541
+ .panel-resize:hover, .panel-resize:focus-visible, .panel-resize.drag {
542
+ background: color-mix(in srgb, var(--c-accent) 60%, transparent); }
543
+ body.resizing, body.resizing * { cursor: col-resize !important; user-select: none !important; }
544
+ body.sidebar-closed .site-title { display: none; }
545
+ #sidebar-head { position: relative; flex: none; display: flex; align-items: center; min-width: 0; min-height: 36px;
546
+ border-bottom: 1px solid var(--c-line); }
547
+ #sidebar-toggle { flex: none; display: flex; align-items: center; padding: 8px 16px;
548
+ background: transparent; border: none; color: var(--c-fg-muted); cursor: pointer; }
549
+ #sidebar-toggle:hover { color: var(--c-fg); }
550
+ #site-icon-slot { position: relative; flex: none; width: 24px; height: 24px; margin: 0 8px 0 16px; color: var(--c-fg-muted); }
551
+ #site-icon, #site-icon-slot > .nav-icon { display: block; width: 100%; height: 100%; object-fit: contain; }
552
+ #site-icon[hidden], #site-icon-slot:has(#site-icon:not([hidden])) > .nav-icon { display: none; }
553
+ body.sidebar-closed #site-icon-slot { margin: 0 auto; }
554
+ body.sidebar-closed #sidebar-toggle { position: absolute; inset: 0; width: 100%; justify-content: center; padding: 0; }
555
+ body.sidebar-closed #sidebar-toggle svg { visibility: hidden; }
556
+ .site-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
557
+ padding-right: 12px; color: var(--c-fg); white-space: nowrap; }
558
+ #sidebar-content { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden;
559
+ scrollbar-gutter: stable;
560
+ padding: 10px 0 8px; }
561
+ .nav-link { position: relative; display: flex; align-items: center; gap: 14px; padding: 10px 16px;
562
+ color: var(--c-fg-muted); text-decoration: none; background: transparent; border: none;
563
+ font: inherit; text-align: left; cursor: pointer; min-width: 0; }
564
+ .nav-link::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
565
+ background: transparent; transition: background 120ms ease-out; }
566
+ .nav-link:hover { color: var(--c-fg); background: var(--c-raised); }
567
+ .nav-link:hover::before { background: var(--c-line); }
568
+ .nav-link.cur { color: var(--c-fg); }
569
+ .nav-link.cur::before { background: var(--c-accent); }
570
+ .nav-icon { flex: none; }
571
+ .workspace-icon { width: 18px; height: 18px; object-fit: contain; }
572
+ .nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
573
+ /* The doc tree. Top-level docs are .nav-link with an icon,
574
+ descendants are .nav-tree. --nav-depth is set on the children list (top level's children = 2),
575
+ --nav-pad = where that level's label starts, --nav-line = x of that level's guide line. Each
576
+ item draws a vertical line (the vertical of ├), and only the last item stops it at the row's
577
+ middle to form └. The path down to the current doc is accented.
578
+ The data has no root / child distinction (any doc can have children) — only the look changes
579
+ per level. */
580
+ .nav-row { position: relative; display: flex; align-items: center; min-width: 0; }
581
+ .nav-row > .nav-link { flex: 1; min-width: 0; padding: 9px 16px; }
582
+ .nav-row > .nav-link.cur { background: var(--c-raised); }
583
+ .nav-item:not(.open) > .nav-list { display: none; }
584
+ .nav-tree-toggle { flex: none; display: flex; align-items: center; justify-content: center;
585
+ width: 28px; height: 28px; margin-right: 6px; padding: 0; background: transparent; border: none;
586
+ border-radius: 4px; color: var(--c-fg-faint); cursor: pointer; }
587
+ .nav-tree-toggle:hover { color: var(--c-fg); background: var(--c-raised); }
588
+ .nav-tree-toggle svg { transition: transform 120ms ease-out; }
589
+ .nav-item.open > .nav-row > .nav-tree-toggle svg { transform: rotate(90deg); }
590
+ .nav-tree { --nav-pad: calc(48px + (var(--nav-depth, 2) - 2) * 29px);
591
+ --nav-line: calc(var(--nav-pad) - 23px); margin: 0; padding-bottom: 4px; }
592
+ .nav-tree:empty { padding-bottom: 0; }
593
+ .nav-tree > .nav-item { position: relative; }
594
+ .nav-tree > .nav-item:not(:last-child)::before, .nav-tree > .nav-item > .nav-row::before,
595
+ .nav-tree > .nav-item:last-child > .nav-row::after, .nav-tree > .nav-item:has(.cur) > .nav-row::after {
596
+ content: ""; position: absolute; z-index: 1; pointer-events: none; left: var(--nav-line);
597
+ background: var(--c-line); }
598
+ /* vertical of ├ / └ */
599
+ .nav-tree > .nav-item:not(:last-child)::before { top: 0; bottom: 0; width: 1px; z-index: 2; }
600
+ .nav-tree > .nav-item:last-child > .nav-row::after, .nav-tree > .nav-item:has(.cur) > .nav-row::after {
601
+ top: 0; height: calc(50% + 1px); width: 1px; z-index: 3; }
602
+ /* ─ (up to 6px before the label) */
603
+ .nav-tree > .nav-item > .nav-row::before { top: 50%; width: 17px; height: 1px; }
604
+ .nav-tree > .nav-item:has(~ .nav-item .cur)::before, .nav-tree > .nav-item:has(.cur) > .nav-row::before,
605
+ .nav-tree > .nav-item:has(.cur) > .nav-row::after { background: var(--c-accent); }
606
+ .nav-ancestor { --nav-pad: 12px; --nav-line: var(--nav-rail, 21px); }
607
+ .nav-list:has(> .nav-ancestor) > .nav-item:not(.nav-ancestor) { height: 0; opacity: 0; overflow: hidden; visibility: hidden; }
608
+ #nav-groups { --nav-motion: 260ms cubic-bezier(.2, .8, .2, 1); }
609
+ #nav-groups :is(.nav-tree-link, .nav-doc-row) { transition: padding-left var(--nav-motion); }
610
+ #nav-groups :is(.nav-create, .nav-new) { transition: margin-left var(--nav-motion); }
611
+ #nav-groups .nav-item::before, #nav-groups .nav-row::before, #nav-groups .nav-row::after {
612
+ transition: left var(--nav-motion), width var(--nav-motion), height var(--nav-motion), top var(--nav-motion); }
613
+ /* A connector changing orientation must stay 1px thick instead of morphing into a rectangle. */
614
+ #nav-groups .nav-row::before { transition-property: left, top; }
615
+ @media (prefers-reduced-motion: reduce) { #nav-groups { --nav-motion: 0s; } }
616
+ .nav-ancestor > .nav-tree > .nav-item::before,
617
+ .nav-ancestor > .nav-tree > .nav-item > .nav-row::before,
618
+ .nav-ancestor > .nav-tree > .nav-item > .nav-row::after { display: none; }
619
+ /* One rail reaches the next ancestor, then stops at the selected row. */
620
+ .nav-ancestor > .nav-tree > .nav-item:has(~ .nav-item .cur)::before {
621
+ display: block; left: var(--nav-rail, 21px); background: var(--c-accent); }
622
+ .nav-root > .nav-ancestor.open > .nav-row::after,
623
+ .nav-ancestor > .nav-tree > .nav-ancestor.open > .nav-row::after {
624
+ content: ""; display: block; position: absolute; left: var(--nav-rail, 21px); top: calc(50% + var(--nav-icon-half, 10px)); bottom: 0;
625
+ width: 1px; height: auto; background: var(--c-accent); pointer-events: none; }
626
+ .nav-ancestor > .nav-tree > .nav-ancestor > .nav-row::before {
627
+ display: block; left: var(--nav-rail, 21px); top: 0; width: 1px; height: calc(50% - var(--nav-icon-half, 10px)); background: var(--c-accent); }
628
+ .nav-ancestor > .nav-tree > .nav-item:has(> .nav-row > a.cur) { --nav-line: var(--nav-rail, 21px); }
629
+ .nav-ancestor > .nav-tree > .nav-item:has(> .nav-row > a.cur) > .nav-row::before { display: block; width: calc(42px - var(--nav-rail, 21px)); }
630
+ .nav-ancestor > .nav-tree > .nav-item:has(> .nav-row > a.cur) > .nav-row::after { display: block; }
631
+ .nav-tree-link { position: relative; flex: 1; min-width: 0; display: flex; align-items: center;
632
+ padding: 6px 8px 6px var(--nav-pad); color: var(--c-fg-muted); font-size: 13px; text-decoration: none; }
633
+ .nav-tree-link:hover:not(.cur) { color: var(--c-fg); background: var(--c-raised); }
634
+ /* The accented guide line marks the current doc, so no left-edge vertical bar (the top-level
635
+ marker). */
636
+ .nav-tree-link.cur { color: var(--c-fg); background: var(--c-raised); }
637
+ /* Doc-specific icon (icons/<id>.svg, 1:1). Top level: in place of the book icon; descendants:
638
+ before the label */
639
+ img.doc-icon { flex: none; width: 18px; height: 18px; object-fit: contain; }
640
+ .doc-icon-frame { position: relative; display: inline-flex; flex: none; width: 18px; height: 18px; }
641
+ .doc-icon-frame > img { width: 100%; height: 100%; }
642
+ .nav-tree-link > .doc-icon-frame, .nav-tree-link > .nav-icon { width: 15px; height: 15px; margin-right: 7px; }
643
+ .nav-row > a.cur :is(.nav-icon, .doc-icon-frame), .nav-doc-row:has(> a.cur) > .nav-doc-icon { color: var(--c-accent); }
644
+ .nav-row > a.cur .doc-icon-frame > img, .nav-doc-row:has(> a.cur) > .nav-doc-icon .doc-icon-frame > img { visibility: hidden; }
645
+ .nav-row > a.cur .doc-icon-frame::after, .nav-doc-row:has(> a.cur) > .nav-doc-icon .doc-icon-frame::after {
646
+ 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
+ body.sidebar-closed #sidebar-content { display: block; overflow-y: auto; scrollbar-width: none; padding: 6px 0; }
649
+ body.sidebar-closed #nav-groups { display: block; }
650
+ 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),
652
+ body.sidebar-closed #nav-status { display: none; }
653
+ body.sidebar-closed #nav-groups .nav-tree { display: block; padding: 0; margin: 0; }
654
+ body.sidebar-closed #nav-groups .nav-row { position: relative; padding: 0; gap: 0; min-height: 36px; }
655
+ body.sidebar-closed #nav-groups .nav-row > a { width: 100%; height: 36px; padding: 0; justify-content: center; }
656
+ body.sidebar-closed #nav-groups .nav-doc-icon { position: absolute; left: 16px; z-index: 1; pointer-events: none; }
657
+ body.sidebar-closed #nav-groups .nav-row > a .nav-icon, body.sidebar-closed #nav-groups .nav-row > a .doc-icon-frame { margin: 0; width: 18px; height: 18px; }
658
+ body.sidebar-closed #nav-groups :is(.nav-item, .nav-tree, .nav-row, a)::before,
659
+ body.sidebar-closed #nav-groups :is(.nav-item, .nav-tree, .nav-row, a)::after { display: none; }
660
+ body.sidebar-closed :is(#nav-files, #nav-git) { border: 0; border-top: 1px solid var(--c-line); border-radius: 0; margin: 6px 0 0; padding: 6px 0 0; overflow: visible; }
661
+ body.edit #sidebar-content { display: flex; flex-direction: column; overflow: hidden; scrollbar-gutter: auto; }
662
+ body.edit #nav-groups { flex: 1; min-height: 0; display: flex; flex-direction: column; }
663
+ #nav-docs { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
664
+ #nav-tools { position: relative; flex: none; display: flex; flex-direction: column; min-height: 0; height: var(--tools-height, 220px); margin: 10px 8px 4px; padding: 12px 4px 4px; border: 1px solid var(--c-line); border-radius: 8px; background: var(--c-bg1); }
665
+ #tools-resize { position: absolute; top: -4px; left: 0; right: 0; height: 8px; z-index: 2; cursor: row-resize; touch-action: none; border-radius: 6px; }
666
+ #tools-resize:hover, #tools-resize:focus-visible, body.resizing-tools #tools-resize { background: color-mix(in srgb, var(--c-fg-muted) 20%, transparent); }
667
+ body.resizing-tools, body.resizing-tools * { cursor: row-resize !important; user-select: none !important; }
668
+ .nav-tool-cards { flex: 1; min-height: 0; overflow: auto; }
669
+ #nav-tools .nav-resources { height: 36px; margin: 0; border: 0; border-radius: 4px; }
670
+ .workspace-location { position: absolute; top: -1px; left: 10px; z-index: 3; transform: translateY(-50%); display: flex; align-items: center; gap: 4px; max-width: calc(100% - 32px); color: var(--c-fg-muted); font: 400 10px/16px var(--font-sans); cursor: default; }
671
+ .workspace-location > .nav-icon { flex: none; width: 14px; height: 14px; padding: 1px; background: var(--c-bg1); }
672
+ .workspace-address { min-width: 0; padding: 0 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--c-bg1); }
673
+ body.sidebar-closed #nav-tools { height: auto; margin: 4px 0; padding: 0; border: 0; }
674
+ body.sidebar-closed :is(#tools-resize, .workspace-location) { display: none; }
675
+ .nav-resources { position: relative; container-type: size; flex: none; height: 42px; margin: 8px 8px 4px;
676
+ border: 1px solid var(--c-line); border-radius: 8px; overflow: hidden;
677
+ background: var(--c-bg1); font-family: var(--font-sans); }
678
+ .nav-resources > .nav-item, .nav-resources .nav-row { height: 100%; }
679
+ .nav-resources .nav-row > .nav-link { height: 100%; padding: 8px 10px; gap: 8px; }
680
+ .nav-resources .nav-row { cursor: default; }
681
+ .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;
693
+ justify-content: center; gap: 6px; padding: 6px; pointer-events: none; text-align: center; }
694
+ .files-drop-hint > svg { width: 28px; height: 28px; }
695
+ .files-drop-hint ul { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 8px;
696
+ margin: 0; padding: 0; list-style: none; font: 11px var(--doc-mono); color: var(--c-fg-muted); }
697
+ @container (max-height: 140px) { .files-drop-hint ul { display: none; } }
698
+ 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; }
701
+ body.files-over #nav-files { background: color-mix(in srgb, var(--c-accent) 10%, var(--c-bg1)); }
702
+ #nav-status { min-height: 16px; margin: 6px 16px 0; color: var(--c-fg-faint); font-size: 11px;
703
+ line-height: 1.35; }
704
+ #nav-status.err { color: var(--c-danger); }
705
+
706
+ /* ── main (document body) ── */
707
+ main { flex: 1; min-width: 0; padding: 20px 24px 50vh; }
708
+ article { max-width: 860px; margin: 0 auto; font-family: var(--viewer-font, system-ui, sans-serif); }
709
+ .external-embed { margin: 18px 0; overflow-wrap: anywhere; }
710
+ .plugin { position: relative; min-width: 0; border: 2px solid var(--doc-border); border-top: 0; }
711
+ .plugin-toolbar { position: absolute; top: 0; left: -2px; right: -2px; transform: translateY(-50%);
712
+ display: flex; align-items: center; gap: 4px; }
713
+ .plugin-toolbar::before, .plugin-toolbar::after { content: ""; border-top: 2px solid var(--doc-border); }
714
+ .plugin-toolbar::before { flex: 1; min-width: 12px; }
715
+ .plugin-toolbar::after { flex: 0 0 8px; }
716
+ .plugin-action[data-state="copied"] { color: var(--doc-ok); }
717
+ .plugin-action .ic-ok, .plugin-action[data-state="copied"] svg:not(.ic-ok) { display: none; }
718
+ .plugin-action[data-state="copied"] .ic-ok { display: block; }
719
+ .plugin.collapsed [data-plugin-action="fold"] svg { transform: rotate(-90deg); }
720
+ .plugin.collapsed [data-plugin-result], .plugin.collapsed [data-plugin-controls],
721
+ .plugin.collapsed [data-plugin-status], .plugin[data-ready="true"]:not(.collapsed) .plugin-info,
722
+ .plugin[data-ready="true"]:not(.collapsed) .plugin-header:has([data-plugin-controls][hidden]) { display: none; }
723
+ .plugin:not(.collapsed) [data-plugin-collapsed],
724
+ .plugin.collapsed:has([data-plugin-collapsed]:not(:empty)) .plugin-header { display: none; }
725
+ .plugin-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
726
+ gap: 8px; padding: 12px; }
727
+ .plugin-info { flex: 1 1 240px; min-width: 0; }
728
+ .plugin-info h2 { margin: 0; padding: 0; border: 0; font-size: 12px; overflow-wrap: anywhere; }
729
+ [data-plugin-status] { margin: 6px 0 0; color: var(--doc-fg-3); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
730
+ [data-plugin-status] a { color: var(--doc-accent); text-underline-offset: 3px; }
731
+ [data-plugin-controls] { display: flex; flex-wrap: wrap; gap: 6px; }
732
+ [data-plugin-controls][hidden], [data-plugin-status]:empty { display: none; }
733
+ [data-plugin-controls] button { min-height: 26px; padding: 3px 8px; border: 1px solid var(--doc-border);
734
+ border-radius: 4px; background: var(--doc-bg); color: var(--doc-fg); font: inherit; font-size: 11px; cursor: pointer; }
735
+ [data-plugin-controls] button[data-plugin-action="read"] { background: var(--doc-accent); border-color: transparent; color: var(--doc-accent-fg); }
736
+ [data-plugin-controls] button[data-plugin-action="disconnect"] { background: transparent; border-color: transparent; color: var(--doc-fg-3); }
737
+ [data-plugin-controls] button:hover { box-shadow: inset 0 0 0 1px currentColor; }
738
+ [data-plugin-controls] button:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: 3px; }
739
+ [data-plugin-controls] button:disabled { opacity: .55; cursor: wait; }
740
+ .plugin-content { padding: 12px; overflow: auto; }
741
+ .plugin-content:has(> iframe) { padding: 0; }
742
+ .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
+ .plugin-content :is(th, td) { padding: 6px 8px; text-align: start; border-bottom: 1px solid var(--doc-border); }
750
+ .plugin-content th { background: var(--doc-bg-2); color: var(--doc-fg-2); font-size: 12px; font-weight: 600; }
751
+ .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
+ .plugin-resize { display: none; height: 12px; align-items: center; justify-content: center;
757
+ cursor: row-resize; touch-action: none; user-select: none; }
758
+ .plugin[data-ready="true"]:not(.collapsed) > .plugin-resize { display: flex; }
759
+ .plugin-resize::before { content: ""; width: 32px; height: 3px; border-radius: 2px; background: var(--doc-border); }
760
+ .plugin-resize:hover::before, .plugin-resize:focus-visible::before, .plugin-resize.drag::before { background: var(--doc-accent); }
761
+ .plugin-resize:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: -2px; }
762
+ body:has(.plugin-resize.drag), body:has(.plugin-resize.drag) * { cursor: row-resize !important; user-select: none !important; }
763
+ body:has(.plugin-resize.drag) iframe { pointer-events: none; }
764
+
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; }
772
+ .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); }
774
+ .plugin-heading::before { content: ""; border-top: 2px solid var(--doc-border); width: 8px; flex: none; }
775
+ .plugin-heading a { display: flex; align-items: center; gap: 6px; color: var(--doc-fg-2); text-decoration: none; flex: none; }
776
+ .plugin-heading img { display: block; width: 16px; height: 16px; }
777
+ .plugin-heading-context { color: var(--doc-fg-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
778
+ .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; }
785
+ .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; }
788
+ .plugin-block-text:has(p, pre, ul, ol) { white-space: normal; }
789
+ .plugin-blocks p { margin: 6px 0; white-space: pre-wrap; }
790
+ .plugin-blocks strong { font-weight: 600; }
791
+ .plugin-blocks a { color: var(--doc-accent); text-underline-offset: 3px; }
792
+ .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; }
795
+ .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; }
799
+ .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; }
801
+ .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; }
803
+ .plugin-block-section > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--doc-fg-2); padding: 4px 0; }
804
+ .plugin-block-note { color: var(--doc-warn); font-size: 11px; border-left: 2px solid currentColor; padding-left: 10px; }
805
+ .plugin-block-table { overflow: auto; width: 100%; margin: 8px 0; }
806
+ .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; }
808
+ .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
+ .plugin-block-diff code > span { display: inline-block; min-width: 100%; }
810
+ .plugin-block-diff [data-tone="success"] { color: var(--doc-ok); background: color-mix(in srgb, var(--doc-ok) 8%, transparent); }
811
+ .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); }
813
+ .plugin-block-message:last-child { border-bottom: 0; }
814
+ .plugin-message-byline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
815
+ .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; }
817
+ .plugin-task { display: flex; align-items: baseline; gap: 8px; list-style: none; }
818
+ .plugin-message-actions svg { display: block; width: 14px; height: 14px; }
819
+ .plugin-message-actions .plugin-block-button { padding: 4px; border: 0; color: var(--doc-fg-3); }
820
+ .plugin-message-actions { margin-left: auto; display: flex; gap: 4px; }
821
+ .plugin-message-body, .plugin-reactions { margin: 6px 0 0 36px; }
822
+ .plugin-reactions { display: flex; flex-wrap: wrap; gap: 5px; font-size: 11px; }
823
+ .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
+ .plugin-reactions [aria-pressed="true"] { border-color: var(--doc-accent); color: var(--doc-accent); background: color-mix(in srgb, var(--doc-accent) 8%, transparent); }
825
+ .plugin-badge { display: inline-flex; padding: 1px 7px; border-radius: 5px; font-size: 11px; color: var(--doc-fg-2); background: var(--doc-bg-2); }
826
+ .plugin-badge[data-tone="accent"] { color: var(--doc-accent); background: color-mix(in srgb, var(--doc-accent) 10%, var(--doc-bg)); }
827
+ .plugin-badge[data-tone="success"] { color: var(--doc-ok); background: color-mix(in srgb, var(--doc-ok) 10%, var(--doc-bg)); }
828
+ .plugin-badge[data-tone="warning"] { color: var(--doc-warn); background: color-mix(in srgb, var(--doc-warn) 10%, var(--doc-bg)); }
829
+ .plugin-badge[data-tone="danger"] { color: var(--doc-danger); background: color-mix(in srgb, var(--doc-danger) 10%, var(--doc-bg)); }
830
+ .plugin-copy-value { display: inline-flex; align-items: center; gap: 3px; max-width: 100%; }
831
+ .plugin-copy { width: 22px; height: 22px; padding: 3px; border: 0; background: transparent; color: var(--doc-fg-3); cursor: pointer; flex: none; }
832
+ .plugin-copy svg { width: 14px; height: 14px; }
833
+ .plugin-block-progress { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--doc-fg-3); margin: 12px 0; }
834
+ .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; }
836
+ .plugin-block-actions:has([data-block-composer]) { display: block; }
837
+ .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); }
839
+ .plugin-block-button:disabled { opacity: .5; cursor: wait; }
840
+ .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; }
848
+ .plugin-editor .plugin-submit { background: var(--doc-accent); color: var(--doc-accent-fg); border-color: transparent; }
849
+ .plugin-editor-notice { color: var(--doc-danger); font-size: 11px; }
850
+ .plugin-editor-notice:empty { display: none; }
851
+ .plugin [hidden] { display: none !important; }
852
+ @container (max-width: 600px) {
853
+ .plugin-block-columns { grid-template-columns: minmax(0, 1fr); gap: 14px; }
854
+ .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)); }
856
+ .plugin-heading-context { display: none; }
857
+ .plugin-blocks .plugin-block-heading { font-size: 18px; }
858
+ .plugin-message-body, .plugin-reactions { margin-left: 0; }
859
+ }
860
+
861
+ article, article * { font-weight: 400; }
862
+ h1, h2, h3 { font-weight: 400; }
863
+ article h1 { font-size: 26px; margin: 0 0 2px; }
864
+ article :is(h1, h2, h3, h4, h5, h6):has(> .section-copy) { position: relative; padding-right: 32px; }
865
+ .section-copy { position: absolute; right: 0; top: 0; display: flex; align-items: center; justify-content: center;
866
+ width: 28px; height: 28px; padding: 0; border: 0; border-radius: 4px; background: transparent;
867
+ color: var(--c-fg-faint); cursor: pointer; }
868
+ .section-copy:hover, .section-copy:focus-visible { background: var(--c-raised); color: var(--c-fg); }
869
+ .section-copy svg { flex: none; width: 16px; height: 16px; }
870
+ .section-copy .ic-ok, .section-copy.ok svg:not(.ic-ok), .section-copy.error svg { display: none; }
871
+ .section-copy.ok .ic-ok { display: block; color: var(--doc-ok); }
872
+ .section-copy.error::after { content: "!"; color: var(--c-danger); }
873
+ article h2 { font-size: 20px; margin-top: 24px; margin-bottom: 8px; border-bottom: 1px solid
874
+ color-mix(in srgb, currentColor 20%, transparent); padding-bottom: 3px; }
875
+ article h3 { font-size: 16px; margin-top: 16px; margin-bottom: 4px; }
876
+ h2[id], h3[id] { scroll-margin-top: 12px; }
877
+ p { margin: 8px 0; }
878
+ ul, ol { margin: 4px 0; line-height: 1.3; }
879
+ li { margin: 0; }
880
+ li > ul, li > ol { margin: 0; }
881
+ .fullname { opacity: .7; margin-bottom: 8px; }
882
+ code { background: color-mix(in srgb, currentColor 10%, transparent);
883
+ padding: 1px 5px; border-radius: 4px; font-size: .9em; font-family: var(--doc-mono); }
884
+ :not(pre) > code { white-space: break-spaces; }
885
+ pre { background: color-mix(in srgb, currentColor 6%, transparent);
886
+ padding: 10px 14px; overflow-x: auto; line-height: 1.5; font-family: var(--doc-mono); }
887
+ pre code { background: none; padding: 0; font-size: 13.5px; }
888
+ pre code.code-foldable { display: block; padding-left: 18px; }
889
+ .code-line { position: relative; }
890
+ .code-line[hidden] { display: none; }
891
+ .code-section-fold { position: absolute; left: -17px; top: 1px; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 16px; padding: 0; border: 0; border-radius: 3px; background: transparent; color: var(--doc-fg-3); cursor: pointer; }
892
+ .code-section-fold:hover { color: var(--doc-accent); background: var(--doc-bg-2); }
893
+ .code-section-fold:focus-visible { outline: 2px solid var(--doc-accent); }
894
+ .code-section-fold[aria-expanded="false"] svg { transform: rotate(-90deg); }
895
+ .code-line-folded > .code-line-text::after { content: " …"; color: var(--doc-fg-3); }
896
+ .fig { margin: 10px 0; }
897
+ /* Only direct svg / img children (= figures) follow the width; icon svgs inside html fragments
898
+ are left alone */
899
+ .fig > svg, .fig > img, .fig > video { max-width: 100%; height: auto; display: block; }
900
+ .video-player { container-type: inline-size; width: 100%; min-width: 0; color: var(--doc-fg-2); }
901
+ .video-player > video { display: block; width: 100%; height: auto; border-radius: 8px; background: #000; }
902
+ .video-player > video:fullscreen { max-height: none; border-radius: 0; object-fit: contain; }
903
+ .video-controls { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
904
+ .video-controls > button { display: grid; place-items: center; flex: none; width: 28px; height: 28px; padding: 0;
905
+ border: 0; border-radius: 5px; color: inherit; background: transparent; cursor: pointer; }
906
+ .video-controls > button:hover, .video-controls > button[aria-expanded="true"] { color: var(--doc-fg); background: var(--doc-bg-3); }
907
+ .video-controls > button:disabled { opacity: .4; cursor: default; }
908
+ .video-controls :is(button, input):focus-visible, .video-settings input:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: 2px; }
909
+ :is(.video-controls, .video-settings) input { appearance: none; height: 24px; padding: 0; margin: 0; border: 0; background: transparent; cursor: pointer; }
910
+ :is(.video-controls, .video-settings) input:disabled { opacity: .4; cursor: default; }
911
+ :is(.video-controls, .video-settings) input::-webkit-slider-runnable-track { height: 2px; border-radius: 1px;
912
+ background: linear-gradient(to right, var(--doc-accent) var(--video-progress, 0%), var(--doc-border-2) var(--video-progress, 0%)); }
913
+ :is(.video-controls, .video-settings) input::-webkit-slider-thumb { appearance: none; width: 8px; height: 8px; margin-top: -3px;
914
+ border: 0; border-radius: 50%; background: var(--doc-accent); }
915
+ :is(.video-controls, .video-settings) input::-moz-range-track { height: 2px; border-radius: 1px; background: var(--doc-border-2); }
916
+ :is(.video-controls, .video-settings) input::-moz-range-progress { height: 2px; border-radius: 1px; background: var(--doc-accent); }
917
+ :is(.video-controls, .video-settings) input::-moz-range-thumb { width: 8px; height: 8px; border: 0; border-radius: 50%; background: var(--doc-accent); }
918
+ .video-controls .video-seek { flex: 1; min-width: 24px; width: 0; }
919
+ .video-controls .video-volume { flex: 0 0 60px; width: 60px; }
920
+ .video-time { flex: none; font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
921
+ .video-settings { position: fixed; inset: auto; box-sizing: border-box; width: min(240px, calc(100vw - 16px)); margin: 0;
922
+ padding: 12px; border: 1px solid var(--doc-border); border-radius: 8px; color: var(--doc-fg);
923
+ background: var(--doc-bg-2); box-shadow: 0 8px 24px var(--c-shadow); }
924
+ .video-settings label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px 12px; font-size: 12px; }
925
+ .video-speed-value { color: var(--doc-fg-2); font-variant-numeric: tabular-nums; }
926
+ .video-settings .video-speed { grid-column: 1 / -1; width: 100%; }
927
+ .video-status { color: var(--doc-danger); font-size: 12px; margin: 6px 0; }
928
+ .video-player [hidden] { display: none; }
929
+ @container (max-width: 420px) { .video-controls .video-volume { display: none; } }
930
+ .fig-frame { display: block; max-width: 100%; border: 0; background: transparent; }
931
+ /* Code and file blocks share a border label; clicking it collapses the content. */
932
+ .codeblk { position: relative; margin: 12px 0; border: 2px solid var(--doc-border); border-top: 0; }
933
+ .codeblk > pre { margin: 0; background: transparent; padding: 8px 12px 6px; }
934
+ .codeblk-head { position: absolute; top: 0; left: -2px; right: -2px; transform: translateY(-50%);
935
+ display: flex; align-items: center; gap: 4px; cursor: pointer; }
936
+ .codeblk-head::before, .codeblk-head::after, .codeblk-lang::before { content: ""; border-top: 2px solid var(--doc-border); }
937
+ .codeblk-head::before { flex: 1; min-width: 12px; }
938
+ .codeblk-head::after { order: 2; flex: 0 0 8px; }
939
+ .codeblk-lang { order: -1; display: flex; align-items: center; gap: 4px;
940
+ font-family: var(--doc-mono); font-size: 11px; color: var(--doc-fg-2); }
941
+ .codeblk-lang::before { flex: 0 0 8px; }
942
+ .codeblk-filename { margin: 0; min-width: 0; max-width: 70%;
943
+ font-family: var(--doc-mono); font-size: 11px; color: var(--doc-fg-2);
944
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
945
+ .codefold svg { transition: transform 150ms ease; }
946
+ .codeblk.collapsed .codefold svg { transform: rotate(-90deg); }
947
+ @media (prefers-reduced-motion: reduce) { .codefold svg { transition: none; } }
948
+ .codeblk.collapsed > pre { display: none; }
949
+ .codeblk.collapsed { min-height: 1px; border-bottom: 0; margin-bottom: 18px; }
950
+ .codeblk :is(.codecopy, .codefold), .plugin-action { display: flex; align-items: center; justify-content: center;
951
+ flex-shrink: 0; width: 16px; height: 16px; padding: 0; border: none; border-radius: 6px; background: none;
952
+ color: var(--doc-fg-3); cursor: pointer; }
953
+ .codeblk :is(.codecopy, .codefold):hover, .codeblk :is(.codecopy, .codefold):focus-visible,
954
+ .plugin-action:hover, .plugin-action:focus-visible { color: var(--doc-accent);
955
+ background: color-mix(in srgb, currentColor 8%, transparent); }
956
+ .codeblk .codecopy .ic-ok { display: none; }
957
+ .codeblk .codecopy.ok .ic-copy { display: none; }
958
+ .textfile-unavailable { margin: 0; padding: 12px; color: var(--doc-fg-2); font-size: 12px; }
959
+ .textfile.unavailable .codeblk-head { cursor: default; }
960
+ .codeblk .codecopy.ok .ic-ok { display: block; color: var(--doc-ok); }
961
+ table { border-collapse: collapse; font-size: 14px; }
962
+ th, td { border: 1px solid color-mix(in srgb, currentColor 25%, transparent); padding: 4px 10px; }
963
+ .data-table { position: relative; min-width: 0; width: 100%; margin: 12px 0; }
964
+ .data-table button { color: inherit; background: transparent; font: inherit; cursor: pointer; }
965
+ .data-table button:focus-visible, .data-table-scroll:focus-visible { outline: 2px solid var(--doc-accent); outline-offset: -2px; }
966
+ .data-table-scroll { overflow: auto; max-height: min(480px, 65vh); border: 1px solid var(--doc-border); border-radius: 8px; box-sizing: border-box; }
967
+ .data-table-resize { position: absolute; bottom: -5px; left: 8px; right: 8px; height: 10px; z-index: 3; cursor: ns-resize; touch-action: none; }
968
+ .data-table-resize::after { content: ""; position: absolute; top: 4px; left: calc(50% - 14px); width: 28px; height: 2px; background: var(--doc-border-2); border-radius: 2px; }
969
+ .data-table-resize:hover::after, .data-table-resize:focus-visible::after { background: var(--doc-accent); }
970
+ .data-table-resize:focus-visible { outline: 2px solid var(--doc-accent); border-radius: 4px; }
971
+ .data-table table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; line-height: 1.4; }
972
+ .data-table :is(th, td) { border: 0; border-bottom: 1px solid color-mix(in srgb, var(--doc-border) 65%, transparent); padding: 5px 12px; text-align: left; white-space: pre-wrap; min-width: 90px; max-width: 480px; overflow-wrap: anywhere; }
973
+ .data-table thead th { position: sticky; top: 0; z-index: 2; background: var(--doc-bg-2, var(--doc-bg)); color: var(--doc-fg-2); font-size: 12px; font-weight: 500; }
974
+ .data-table[data-table-mode="header"] thead th { border-bottom: 3px double var(--doc-border-2); }
975
+ .data-table thead th:has(button) { padding: 0; }
976
+ .data-table thead button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; text-align: left; padding: 5px 12px; border: 0; font-weight: inherit; }
977
+ .data-table th:first-child { min-width: 24px; width: 24px; color: var(--doc-fg-3); font-size: 11px; font-weight: 400; text-align: right; font-variant-numeric: tabular-nums; }
978
+ .data-table-sort-mark { flex: none; opacity: 0; }
979
+ .data-table thead button:is(:hover, :focus-visible) .data-table-sort-mark { opacity: .5; }
980
+ .data-table th[aria-sort] { color: var(--doc-accent); }
981
+ .data-table th[aria-sort] .data-table-sort-mark { opacity: 1; }
982
+ .data-table tbody tr:last-child > * { border-bottom: 0; }
983
+ .data-table tbody tr:hover { background: color-mix(in srgb, var(--doc-fg) 3%, transparent); }
984
+
985
+ article a { color: var(--doc-accent); }
986
+ .ph { border: 1px dashed color-mix(in srgb, currentColor 40%, transparent);
987
+ border-radius: 8px; padding: 18px; text-align: center; margin: 16px 0; }
988
+ .ph span { opacity: .5; font-size: 13px; }
989
+ .cap { opacity: .7; font-size: 13px; margin-top: 6px; white-space: pre-line; }
990
+ .callout { border: 1px solid var(--doc-border); border-radius: 8px; padding: 8px 14px; margin: 10px 0;
991
+ background: color-mix(in srgb, var(--doc-accent) 3%, var(--doc-bg-2)); overflow-wrap: anywhere; }
992
+ .callout-title { display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
993
+ color: var(--doc-fg); font-size: 13px; font-weight: 650; line-height: 1.5; }
994
+ .callout-title::before { content: ""; flex: none; width: 18px; height: 2px; background: var(--doc-accent); }
995
+ .callout-body { color: var(--doc-fg-2); font-size: 14px; line-height: 1.5; }
996
+ .callout-body > p { margin: 6px 0; }
997
+ .callout-body > p:first-child { color: var(--doc-fg); font-size: 15px; }
998
+ .callout-body > :is(ul, ol) { display: grid; gap: 2px; margin: 6px 0; padding-left: 20px; }
999
+ .callout-body > :is(ul, ol) > li { padding-left: 4px; }
1000
+ .callout-body > :is(ul, ol) > li::marker { color: var(--doc-accent); }
1001
+ .callout-body > :first-child { margin-top: 0; }
1002
+ .callout-body > :last-child { margin-bottom: 0; }
1003
+ .errors { border: 1px solid var(--doc-danger); border-radius: 8px; padding: 10px 16px;
1004
+ margin-bottom: 16px; color: var(--doc-danger); font-size: 13px; }
1005
+ .in-toc { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; margin: 0 10px 0 0; }
1006
+ .toc-current { position: relative; flex: none; width: 120px; min-width: 0; max-width: calc(100% - 36px); padding: 4px 20px 4px 4px;
1007
+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; border: 0; border-radius: 4px;
1008
+ background: transparent; color: var(--doc-fg-2); font: 12px/20px var(--font-sans); text-align: left; cursor: pointer; }
1009
+ .toc-current::after { content: ""; position: absolute; right: 6px; top: 10px; width: 5px; height: 5px;
1010
+ border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(45deg); }
1011
+ .toc-current:hover, .toc-current[aria-expanded="true"] { background: var(--c-raised); color: var(--doc-accent); }
1012
+ .toc-options { position: fixed; margin: 0; padding: 6px; max-height: min(420px, calc(100dvh - 60px)); overflow: auto;
1013
+ list-style: none; border: 1px solid var(--doc-border); border-radius: 6px; background: var(--doc-bg); color: var(--doc-fg);
1014
+ box-shadow: 0 8px 24px var(--c-shadow); }
1015
+ .in-toc a { display: block; padding: 7px 10px; border-radius: 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
1016
+ text-decoration: none; font: 12px/1.5 var(--font-sans); color: var(--doc-fg-2); }
1017
+ .in-toc .lvl2 a { padding-left: 20px; }
1018
+ .in-toc .lvl3 a { padding-left: 30px; }
1019
+ .in-toc a:hover, .in-toc a[aria-current] { color: var(--doc-accent); background: color-mix(in srgb, var(--doc-accent) 8%, transparent); }
1020
+ .toc-seek { flex: 1; appearance: none; width: 0; min-width: 24px; height: 24px; margin: 0; padding: 0; background: transparent; cursor: ew-resize; }
1021
+ .toc-seek::-webkit-slider-runnable-track { height: 2px; background: linear-gradient(to right, var(--doc-accent) var(--toc-progress, 0%), var(--doc-border) var(--toc-progress, 0%)); }
1022
+ .toc-seek::-moz-range-track { height: 2px; background: linear-gradient(to right, var(--doc-accent) var(--toc-progress, 0%), var(--doc-border) var(--toc-progress, 0%)); }
1023
+ .toc-seek::-webkit-slider-thumb { appearance: none; width: 8px; height: 8px; margin-top: -3px; border: 0; border-radius: 50%; background: var(--doc-accent); }
1024
+ .toc-seek::-moz-range-thumb { width: 8px; height: 8px; border: 0; border-radius: 50%; background: var(--doc-accent); }
1025
+ .toc-seek:disabled { opacity: .35; cursor: default; }
1026
+ html:has(.page-actions .toc-seek), .preview-pane:has(> .pane-actions .toc-seek) { scrollbar-width: none; }
1027
+ html:has(.page-actions .toc-seek)::-webkit-scrollbar,
1028
+ .preview-pane:has(> .pane-actions .toc-seek)::-webkit-scrollbar { display: none; }
1029
+ main :is(h1, h2, h3)[id] { scroll-margin-top: 52px; }
1030
+ article strong { font-weight: 700; }
1031
+ article em { font-style: italic; }
1032
+ article blockquote { margin: 16px 0; padding: 4px 16px; border-left: 3px solid var(--doc-border-2); color: var(--doc-fg-2); }
1033
+ article blockquote > :first-child { margin-top: 0; }
1034
+ article blockquote > :last-child { margin-bottom: 0; }
1035
+ ul.idx { list-style: none; padding: 0; }
1036
+ ul.idx li { margin: 10px 0; }
1037
+ details summary { cursor: pointer; opacity: .8; }
1038
+ /* Figure and term file links show previews on hover. */
1039
+ .figref, .dictref { color: var(--doc-accent-2); text-decoration: none;
1040
+ border-bottom: 1px dotted currentColor; position: relative; cursor: pointer; }
1041
+ .figref .figpop, .dictref .dictpop { display: none; position: absolute; left: 50%;
1042
+ transform: translateX(-50%); top: calc(100% + 6px); z-index: 5;
1043
+ width: min(560px, 78vw); padding: 12px 14px;
1044
+ background: var(--doc-panel); border: 1px solid var(--doc-border);
1045
+ border-radius: 8px; box-shadow: 0 4px 16px color-mix(in srgb, var(--doc-fg) 18%, transparent);
1046
+ color: var(--doc-fg); font-size: 13px; line-height: 1.7; }
1047
+ .figref:hover .figpop, .figref:focus-visible .figpop, .dictref:hover .dictpop { display: block; }
1048
+ .figpop svg { max-width: 100%; height: auto; display: block; }
1049
+ .figpop .fig-frame { width: 100% !important; }
1050
+ .dictref .dictpop { width: min(420px, 70vw); }
1051
+ .dictpop-description { display: block; margin-top: 8px; padding-top: 8px;
1052
+ border-top: 1px solid var(--doc-border); color: var(--doc-fg-3); font-size: 12px; }
1053
+ .dictref:focus-visible .dictpop { display: block; }
1054
+ .terms { margin: 18px 0; }
1055
+ .terms .dict-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; color: var(--doc-fg-3); font-size: 12px; }
1056
+ .terms .dict-toolbar label { display: flex; align-items: center; gap: 8px; }
1057
+ .terms select { font: inherit; color: var(--doc-fg); background: var(--doc-panel); border: 1px solid var(--doc-border); border-radius: 4px; padding: 4px 8px; }
1058
+ .terms .dict { display: grid; gap: 8px; margin: 0; }
1059
+ .terms .dict-card { margin: 0; padding: 0 12px 6px; border: 1px solid var(--doc-border);
1060
+ border-radius: 4px; min-width: 0; overflow-wrap: anywhere; }
1061
+ .terms legend { display: flex; align-items: center; gap: 6px; max-width: 100%; padding: 0 4px; font-size: 15px; font-weight: 600;
1062
+ line-height: 1.5; color: var(--doc-fg); scroll-margin-top: 16px; }
1063
+ .terms .term-definition { margin: 2px 0 0; font-size: 13.5px;
1064
+ line-height: 1.6; white-space: pre-line; }
1065
+ .terms .dict-card:has(:target) { border-color: var(--doc-accent-2); }
1066
+ .terms .term-icon { flex: none; color: var(--doc-fg-3); }
1067
+ .terms .term-name { position: relative; min-width: 0; text-decoration: none; overflow-wrap: anywhere; }
1068
+ .terms .term-name:hover { text-decoration: underline; text-underline-offset: 3px; }
1069
+ .terms .term-description { display: none; position: absolute; left: 0; top: 100%; z-index: 6;
1070
+ width: max-content; max-width: min(360px, calc(100vw - 96px)); box-sizing: border-box;
1071
+ padding: 10px 14px; margin-top: 8px; border: 1px solid var(--doc-border); border-radius: 6px;
1072
+ background: var(--doc-panel); color: var(--doc-fg); font-family: var(--font-sans);
1073
+ font-size: 13px; font-weight: 400; line-height: 1.7; white-space: pre-line;
1074
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--doc-fg) 12%, transparent); }
1075
+ .terms .term-description::before { content: ""; position: absolute; left: 0; right: 0; top: -9px; height: 9px; }
1076
+ .terms .term-name:hover .term-description,
1077
+ .terms .term-name:focus .term-description { display: block; }
1078
+ .resource-library { width: 100%; max-width: none; }
1079
+ .git-page { padding-top: 8px; font: 12px/1.55 var(--font-sans); }
1080
+ .git-controls { min-width: 0; margin: 0; padding: 0; border: 0; }
1081
+ .git-toolbar, .git-actions, .git-branches { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-height: 30px; }
1082
+ .git-toolbar { border-bottom: 1px solid var(--c-line); margin: 4px 0 8px; padding-inline: 6px; }
1083
+ .git-toolbar strong { font-size: 13px; font-weight: 500; }
1084
+ .git-branches { gap: 12px; margin-bottom: 8px; }
1085
+ .git-branches > form { flex: 1; }
1086
+ .git-branches input { width: 150px; flex: 1; }
1087
+ .git-actions { margin: 6px 0; }
1088
+ .git-page :is(input:not([type=checkbox]), textarea, select) { box-sizing: border-box; min-width: 0; max-width: 100%; border: 1px solid var(--c-line); border-radius: 4px; padding: 4px 8px; background: var(--c-bg1); color: var(--c-fg); font: inherit; }
1089
+ .git-page :is(input:not([type=checkbox]), select) { height: 28px; }
1090
+ .git-branches :is(input, select), .git-actions select { border-color: transparent; background: transparent; }
1091
+ .git-page :is(input, textarea, select):focus-visible { outline: 1px solid var(--c-fg-muted); outline-offset: -1px; }
1092
+ .git-page select option { background: var(--c-bg1); color: var(--c-fg); }
1093
+ .git-page .git-text-button { width: auto; padding: 0 8px; font: inherit; }
1094
+ .git-page .pbtn:disabled { opacity: .45; cursor: default; }
1095
+ .git-page .pbtn:disabled:hover { background: transparent; color: var(--c-fg-muted); }
1096
+ .git-page .resource-item > summary .pbtn { width: 24px; height: 24px; }
1097
+ .git-field { display: flex; flex-direction: column; gap: 4px; color: var(--c-fg-muted); }
1098
+ .git-field textarea { resize: vertical; }
1099
+ .git-form { display: flex; flex-direction: column; gap: 8px; }
1100
+ .git-form > button { align-self: flex-start; }
1101
+ .git-commit { margin: 10px 0 18px; }
1102
+ .git-commit textarea { height: 54px; }
1103
+ .git-caption, .git-notice { margin: 6px 0; font-size: 11px; color: var(--c-fg-muted); white-space: pre-wrap; overflow-wrap: anywhere; }
1104
+ .git-notice:empty { display: none; }
1105
+ .git-status { width: 2ch; flex: none; white-space: pre; color: var(--c-fg-muted); }
1106
+ .git-diff { max-height: 320px; overflow: auto; padding: 8px; background: var(--c-surface); font: 11px/1.6 var(--editor-font, ${MONO_FONT}); }
1107
+ .git-checkbox { display: flex; align-items: center; gap: 6px; }
1108
+ .resource-tabs { display: flex; gap: 4px; overflow-x: auto; border-bottom: 1px solid var(--doc-border); margin-bottom: 18px; }
1109
+ .resource-tabs a { padding: 9px 15px; color: var(--doc-fg-3); text-decoration: none; white-space: nowrap; font-size: 13px; border-bottom: 2px solid transparent; }
1110
+ .resource-tabs a:hover { color: var(--doc-fg); background: color-mix(in srgb, var(--doc-fg) 4%, transparent); }
1111
+ .resource-tabs a[aria-current] { color: var(--doc-accent); border-bottom-color: currentColor; }
1112
+ .resource-toolbar { display: flex; align-items: center; gap: 10px; height: 30px; padding: 0 6px;
1113
+ border-bottom: 1px solid var(--c-line); color: var(--c-fg-muted); font-size: 11px; }
1114
+ .resource-toolbar > span { flex: none; }
1115
+ .resource-search {display:flex;align-items:center;gap:5px;flex:0 1 140px;min-width:0;height:24px;border-bottom:1px solid transparent;color:var(--c-fg-faint);}
1116
+ .resource-search > svg {flex:none;width:13px;height:13px;}
1117
+ .resource-search input {min-width:0;width:100%;height:22px;padding:0;border:0;border-radius:0;background:transparent;color:var(--c-fg);font:inherit;}
1118
+ .resource-search input::placeholder {color:var(--c-fg-faint);}
1119
+ .resource-search:focus-within {border-bottom-color:var(--c-accent);color:var(--c-fg-muted);}
1120
+ .resource-search input:focus-visible {outline:none;}
1121
+ .resource-toolbar select { flex: 0 1 auto; min-width: 50px; max-width: 110px; height: 24px; padding: 0 4px;
1122
+ border: 0; border-radius: 0; background: transparent; color: var(--c-fg-muted); font: inherit; }
1123
+ .resource-toolbar select option { background: var(--c-bg1); color: var(--c-fg); }
1124
+ .resource-count { margin-left: auto; color: var(--doc-fg-3); font-variant-numeric: tabular-nums; }
1125
+ .resource-items { display: flex; flex-direction: column; }
1126
+ .resource-item { min-width: 0; margin: 0; padding: 0; border: 0; }
1127
+ .resource-item[hidden], .resource-empty[hidden] { display: none; }
1128
+ .resource-item > summary { display: flex; align-items: center; gap: 8px; min-width: 0; height: 28px; padding: 0 6px;
1129
+ list-style: none; color: var(--doc-fg); font-size: 13px; cursor: pointer; }
1130
+ .resource-item > summary::-webkit-details-marker { display: none; }
1131
+ .resource-item > summary::before { content: ""; flex: none; width: 5px; height: 5px; margin: 0 4px 0 2px;
1132
+ border-right: 1px solid var(--c-fg-faint); border-bottom: 1px solid var(--c-fg-faint); transform: rotate(-45deg); }
1133
+ .resource-item[open] > summary::before { transform: rotate(45deg); }
1134
+ .resource-item > summary:hover { background: var(--c-surface); }
1135
+ .resource-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1136
+ .resource-item a.resource-name { color: inherit; text-decoration: none; }
1137
+ .resource-item a.resource-name:hover { color: var(--doc-accent); text-decoration: underline; }
1138
+ .resource-body { min-width: 0; padding: 6px 8px 10px 24px; }
1139
+ .resource-source { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; max-width: 100%;
1140
+ overflow: hidden; white-space: nowrap; color: var(--doc-fg-3); font-family: var(--doc-mono); font-size: 11px; text-decoration: none; }
1141
+ .resource-item > summary > .resource-source { margin-left: auto; max-width: 50%; }
1142
+ .resource-source:hover { color: var(--doc-accent); text-decoration: underline; }
1143
+ .resource-source span { flex: none; opacity: .7; }
1144
+ .resource-source .resource-source-file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 1; }
1145
+ .resource-badge { flex: none; color: var(--doc-fg-2); font: 11px var(--doc-mono); }
1146
+ .resource-item .term-definition { margin: 0; white-space: pre-wrap; font-size: 13px; }
1147
+ .resource-description { color: var(--doc-fg-3); white-space: pre-wrap; font-size: 12px; margin: 6px 0 0; }
1148
+ .resource-item .codeblk { margin: 0; }
1149
+ .resource-item .codeblk > pre { max-height: 260px; overflow: auto; }
1150
+ .resource-usage { flex: none; margin-left: auto; color: var(--doc-fg-3); font-size: 11px; font-variant-numeric: tabular-nums; }
1151
+ .resource-sources { display: flex; flex-direction: column; gap: 4px; }
1152
+ .resource-empty { padding: 20px 6px; color: var(--doc-fg-3); font-size: 12px; }
1153
+ .resource-errors { margin: 0 0 16px; color: var(--doc-danger); font-size: 12px; overflow-wrap: anywhere; }
1154
+ .resource-errors summary { cursor: pointer; }
1155
+ .resource-library .resource-tabs { margin-bottom: 8px; }
1156
+ .resource-library .files-viewport { max-height: calc(100dvh - 76px); min-height: 0; }
1157
+ .resource-library .files-table :is(th, td) { border: 0; }
1158
+ .resource-library .files-table thead th { position: sticky; top: 0; z-index: 1; border-bottom: 1px solid var(--c-line); background: var(--c-bg1); font-size: 11px; color: var(--c-fg-muted); font-weight: 500; }
1159
+ @media(max-width:600px) { .resource-tabs a { padding-inline: 10px; } .resource-toolbar { gap: 8px; } }
1160
+ `;
1161
+ // edit mode only (right panel / new doc form). Uses the viewer's shared background / rules and
1162
+ // button / input styles. To keep CM as wide as possible the fm is compact, and the toggles
1163
+ // (autosave / correspondence display / fm visibility) are gathered in the header gear's hover
1164
+ // menu. Width is drag-resizable.
1165
+ const EDIT_CSS = `
1166
+ body.git-diff-hidden .cm-lineNumbers :is(.cm-git-added, .cm-git-modified, .cm-git-deleted-start, .cm-git-deleted-end) { box-shadow: none; }
1167
+ body.git-diff-hidden .cm-line:is(.cm-git-added, .cm-git-modified, .cm-git-deleted-start, .cm-git-deleted-end) { background-color: transparent; }
1168
+ #site-icon-slot input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
1169
+ #site-icon-slot:hover, #site-icon-slot:focus-within, #sidebar-head.over #site-icon-slot { background: var(--c-line); border-radius: 3px; }
1170
+ #site-icon-slot:focus-within, #sidebar-head.over #site-icon-slot { outline: 1px solid var(--c-fg-muted); outline-offset: 2px; }
1171
+ body.sidebar-closed #site-icon-slot input { display: none; }
1172
+ /* Right panel = header / properties / editor stacked on one surface, separated by rules (no
1173
+ nested frames). */
1174
+ #panel { width: var(--panel-width, min(760px, 52vw)); flex-shrink: 0; position: sticky; top: 0; height: 100vh;
1175
+ display: flex; flex-direction: column; font-size: 12.5px; line-height: 1.6;
1176
+ border-left: 1px solid var(--c-line); background: var(--c-bg1); }
1177
+ body.textfile-open #phead :is(.fm-language, #status, #validate, #save, #split-preview) { display: none; }
1178
+ #file-editor { display: flex; flex-direction: column; flex: 1; min-height: 0; color: var(--doc-fg); background: var(--doc-bg); }
1179
+ .file-editor-heading { display: flex; align-items: center; flex: 1; min-width: 0; gap: 6px; margin-left: 6px; color: var(--c-fg-muted); }
1180
+ .file-editor-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--doc-mono); }
1181
+ .file-editor-heading .cm-textfile-controls { display: inline-flex; align-items: center; flex: none; gap: 2px; }
1182
+ .file-editor-heading .cm-textfile-save-status { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1183
+ .file-editor-heading .cm-inline-fold { display: none; }
1184
+ .file-editor-heading [hidden] { display: none; }
1185
+ #file-editor > .cm-textfile { display: flex; flex-direction: column; flex: 1; min-height: 0; }
1186
+ #file-editor .cm-textfile-editor { flex: 1; min-height: 0; }
1187
+ #file-editor .cm-editor { height: 100%; }
1188
+ #file-editor .cm-scroller { overflow: auto; }
1189
+ #file-editor .cm-textfile-status { padding: 8px 12px; white-space: pre-wrap; }
1190
+ #file-editor .cm-textfile-status[hidden] { display: none; }
1191
+ #file-editor .cm-textfile-status.error { color: var(--doc-danger); }
1192
+
1193
+ /* header band (same height and rule as sidebar-head) */
1194
+ #phead { flex: none; display: flex; align-items: center; gap: 2px; height: 36px; padding: 0 10px 0 14px;
1195
+ border-bottom: 1px solid var(--c-line); }
1196
+ /* Document metadata is edited in the tree, without changing the row's size. */
1197
+ body.edit #nav-groups { --nav-rail: 22px; --nav-icon-half: 9px; }
1198
+ .nav-doc-row { padding-left: 12px; gap: 3px; min-height: 38px; }
1199
+ .nav-tree .nav-doc-row { padding-left: var(--nav-pad); }
1200
+ .nav-doc-row:has(> a.cur) { background: var(--c-raised); }
1201
+ .nav-row.nav-doc-row > a { padding: 4px 2px; background: transparent; }
1202
+ .nav-doc-row > a::before { display: none; }
1203
+ .nav-doc-name { display: flex; flex-direction: column; min-width: 0; line-height: 17px; }
1204
+ .nav-doc-icon { position: relative; flex: none; display: flex; align-items: center; justify-content: center;
1205
+ width: 20px; height: 24px; cursor: pointer; border-radius: 3px; color: var(--c-fg-faint); }
1206
+ .nav-doc-icon:hover, .nav-doc-icon.over, .nav-doc-icon:focus-within { background: var(--c-line); color: var(--c-fg); }
1207
+ .nav-doc-icon input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
1208
+ .nav-doc-icon > :is(.nav-icon, .doc-icon-frame) { width: 18px; height: 18px; }
1209
+ .nav-icon-clear { position: absolute; right: -4px; top: -4px; width: 13px; height: 13px; padding: 0;
1210
+ border: 1px solid var(--c-line); border-radius: 50%; font: 10px/11px sans-serif;
1211
+ color: var(--c-fg); background: var(--c-surface); cursor: pointer; visibility: hidden; }
1212
+ .nav-doc-icon:hover .nav-icon-clear, .nav-doc-icon:focus-within .nav-icon-clear { visibility: visible; }
1213
+ .nav-icon-clear:hover { color: var(--c-danger); }
1214
+ .nav-rename-form { position: absolute; top: 0; bottom: 0; z-index: 4; display: flex; flex-direction: column; justify-content: center;
1215
+ background: var(--c-bg0); }
1216
+ .nav-rename-form input { width: 100%; min-width: 0; box-sizing: border-box; color: var(--c-fg);
1217
+ background: transparent; border: 0; border-radius: 2px; padding: 0 2px; font: inherit; line-height: 17px; }
1218
+ .nav-rename-form input[name="name"] { font: 10px/12px var(--doc-mono); color: var(--c-fg-faint); }
1219
+ .nav-rename-form input:invalid { outline: 1px solid var(--c-danger); }
1220
+ .nav-rename-form input:focus, .nav-new input:focus { outline: 1px solid var(--c-line); }
1221
+ @media (forced-colors: active) { .nav-rename-form input:focus, .nav-new input:focus { outline: 1px solid CanvasText; } }
1222
+ .nav-doc-row.renaming > a { visibility: hidden; }
1223
+ .nav-delete:hover, .nav-delete:focus-visible { color: var(--c-danger); }
1224
+ #status { flex: 1; min-width: 0; padding: 0 8px; text-align: right; color: var(--c-fg-faint);
1225
+ font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1226
+ #status.err { color: var(--c-danger); }
1227
+ .cm-file-preview { display: inline-flex; align-items: center; justify-content: center;
1228
+ width: 20px; height: 20px; margin-left: 4px; vertical-align: middle; color: var(--c-fg-faint); }
1229
+ .cm-file-preview[data-state="valid"] { color: var(--c-ok); }
1230
+ .cm-file-preview[data-state="error"] { color: var(--c-danger); }
1231
+ .cm-file-preview[data-state="loading"] { color: var(--c-accent); }
1232
+ .cm-file-preview[data-state="loading"] svg { animation: preview-spin 1s linear infinite; }
1233
+ @keyframes preview-spin { to { transform: rotate(360deg); } }
1234
+ @media (prefers-reduced-motion: reduce) { .cm-file-preview[data-state="loading"] svg { animation: none; } }
1235
+ .app-dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 22px;
1236
+ color: var(--c-fg); background: color-mix(in srgb, var(--c-bg1) 90%, transparent);
1237
+ border: 1px solid var(--c-fg-faint); border-radius: 10px; box-shadow: 0 16px 48px #0004; backdrop-filter: blur(16px); }
1238
+ .app-dialog::backdrop { background: #0005; backdrop-filter: blur(2px); }
1239
+ .app-dialog h2 { margin: 0 0 14px; font-size: 16px; overflow-wrap: anywhere; }
1240
+ .app-dialog p { margin: 0 0 22px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--c-fg-muted); }
1241
+ .dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
1242
+ .app-dialog button { padding: 7px 14px; color: inherit; background: var(--c-bg0); border: 1px solid var(--c-line); border-radius: 5px; cursor: pointer; }
1243
+ .app-dialog button:hover { background: var(--c-raised); border-color: var(--c-fg-muted); }
1244
+ .app-dialog button:focus-visible { outline-color: var(--c-fg-muted); }
1245
+ .app-dialog button.danger { color: var(--c-danger); }
1246
+ .app-dialog:has(.conflict-columns) { width: min(1000px, calc(100vw - 32px)); }
1247
+ .conflict-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
1248
+ .conflict-columns h3 { font-size: 13px; margin: 0; }
1249
+ .conflict-columns pre { max-height: 45vh; overflow: auto; font-size: 12px; padding: 10px; border: 1px solid var(--c-line); }
1250
+ @media (max-width: 600px) { .conflict-columns { grid-template-columns: 1fr; } .conflict-columns pre { max-height: 25vh; } }
1251
+ #gear { position: relative; flex: none; }
1252
+ #gearmenu { display: none; position: absolute; right: 0; top: calc(100% + 6px); z-index: 8;
1253
+ min-width: 220px; padding: 4px; flex-direction: column; gap: 1px; color: var(--c-fg);
1254
+ background: var(--c-surface); border: 1px solid var(--c-line); border-radius: 6px;
1255
+ box-shadow: 0 10px 28px var(--c-shadow); font-size: 12.5px; }
1256
+ #gear:hover #gearmenu, #gear:focus-within #gearmenu, #gear.open #gearmenu { display: flex; }
1257
+ #gearmenu label { display: flex; gap: 10px; align-items: center; cursor: pointer;
1258
+ padding: 7px 10px; border-radius: 4px; }
1259
+ #gearmenu label:hover { background: var(--c-raised); }
1260
+ #gearmenu input { accent-color: var(--c-accent); margin: 0; }
1261
+
1262
+ .fm-language { display: flex; align-items: center; flex: 0 1 220px; min-width: 0; gap: 4px; }
1263
+ .fm-language > .language-select { flex: 1; width: 0; min-width: 0; }
1264
+ .language-select, .language-picker { font: 13px/18px var(--font-sans); font-weight: var(--fw-body); }
1265
+ .language-select { position: relative; }
1266
+ .language-select-input { width: 100%; min-width: 0; height: 28px; padding: 4px 26px 4px 8px;
1267
+ border: 1px solid var(--c-line); border-radius: 5px; color: var(--c-fg);
1268
+ background: var(--c-surface); font: inherit; text-overflow: ellipsis; }
1269
+ .language-select::after { content: ""; position: absolute; right: 10px; top: 9px; width: 6px; height: 6px; pointer-events: none;
1270
+ border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(45deg); }
1271
+ .language-select-input:hover { background: var(--c-raised); }
1272
+ .language-select-input:focus-visible, .language-picker :focus-visible { outline: 1px solid var(--c-fg-muted); outline-offset: 1px; }
1273
+ .language-select-input[aria-disabled="true"] { opacity: .5; cursor: wait; }
1274
+ .fm-language > .nav-icon { color: var(--c-fg-muted); }
1275
+ .fm-language .language-select::after { display: none; }
1276
+ .fm-language .language-select-input { padding: 4px; border: 0; border-bottom: 1px solid var(--c-line);
1277
+ border-radius: 0; background: transparent; }
1278
+ .fm-language .language-select-input:hover { border-bottom-color: var(--c-fg-muted); }
1279
+ .fm-language .language-select-input:focus-visible { outline: none; border-bottom-color: var(--c-accent); }
1280
+ .language-picker { position: fixed; margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--c-line);
1281
+ border-radius: 5px; color: var(--c-fg); background: var(--c-surface); box-shadow: 0 8px 24px var(--c-shadow); }
1282
+ .language-picker:popover-open { display: flex; flex-direction: column; }
1283
+ .language-options { overflow-y: auto; min-height: 0; }
1284
+ .language-options button { display: block; width: 100%; padding: 6px 8px; border: 0;
1285
+ color: inherit; background: transparent; font: inherit; text-align: left;
1286
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
1287
+ .language-options button:hover, .language-options button[data-active="true"] { background: var(--c-raised); }
1288
+ .language-options button[aria-selected="true"] { color: var(--c-fg); }
1289
+ .language-toggle { display: inline-block; width: 24px; margin: -6px 0 -6px -8px; padding: 6px 0; text-align: center; }
1290
+ .language-toggle:hover { color: var(--c-accent); background: var(--c-line); }
1291
+ .language-options button:disabled { opacity: .5; cursor: wait; }
1292
+ .language-empty { padding: 8px; color: var(--c-fg-muted); }
1293
+
1294
+ main.editor-workspace { padding: 0; height: 100vh; display: flex; flex-direction: column; position: sticky; top: 0; }
1295
+ #split-preview[aria-pressed="true"] { color: var(--c-accent); background: var(--c-raised); }
1296
+ .pane-actions { top: -20px; margin: -20px -24px 20px; }
1297
+ #preview-panes { display: flex; flex: 1; min-height: 0; }
1298
+ #preview-panes[hidden] { display: none; }
1299
+ .preview-pane.resources-pane { min-height: 0; padding-bottom: 24px; }
1300
+ .preview-pane.resources-pane:has(.resource-library) { padding-top: 0; }
1301
+ .resources-pane .resource-tabs { margin-inline: -24px; padding: 0 12px; }
1302
+ .preview-pane { flex: 1; min-width: 0; overflow: auto; padding: 20px 24px 50vh; border-top: 2px solid transparent; }
1303
+ #preview-panes.split .preview-pane { padding: 16px; }
1304
+ #preview-panes.split .pane-actions { top: -16px; margin: -16px -16px 16px; padding-inline: 10px; }
1305
+ #preview-panes.split .preview-pane + .preview-pane { border-left: 1px solid var(--c-line); }
1306
+ #preview-panes.split .preview-pane.active { border-top-color: var(--c-accent); }
1307
+ .preview-pane:focus-visible { outline: 2px solid var(--c-accent); outline-offset: -2px; }
1308
+ #preview-language-menu { position: fixed; z-index: 9; min-width: 180px; max-height: min(360px, calc(100vh - 64px)); overflow-y: auto; padding: 6px; background: var(--c-surface); border: 1px solid var(--c-line); border-radius: 10px; box-shadow: 0 10px 28px var(--c-shadow); transform-origin: top right; }
1309
+ .language-option { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--c-fg); text-align: left; font: inherit; font-size: 13px; cursor: pointer; }
1310
+ .language-option::before { content: ""; width: 14px; flex: none; }
1311
+ .language-option[aria-checked="true"] { color: var(--c-accent); }
1312
+ .language-option[aria-checked="true"]::before { content: "✓"; }
1313
+ .language-option:hover, .language-option:focus-visible { background: var(--c-raised); outline: none; }
1314
+ .language-option:disabled { opacity: .5; cursor: wait; }
1315
+ #language-setting { flex: none; display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--c-line); }
1316
+ #language-setting .language-select { width: 160px; }
1317
+ .cm-code-diagnostic { text-decoration:underline wavy var(--c-warn); text-underline-offset:3px; }
1318
+ #validate[data-state="error"], .cm-textfile-validate[data-state="error"] { color:var(--c-danger); }
1319
+ #validate[data-state="valid"], .cm-textfile-validate[data-state="valid"] { color:var(--c-ok); }
1320
+ #validate[data-state="pending"], .cm-textfile-validate[data-state="pending"] { color:var(--c-fg-faint); }
1321
+ #validate[data-state="checking"], .cm-textfile-validate[data-state="checking"] { color:var(--c-info); }
1322
+ #save[data-state="error"], .cm-textfile-save-status[data-state="error"] { color:var(--c-danger); }
1323
+ #save[data-state="saved"] { color:var(--c-ok); }
1324
+ #save[data-state="pending"] { color:var(--c-accent); }
1325
+ .cm-textfile-save-status { font-size: 10px; color: var(--c-fg-faint); margin-left: 4px; }
1326
+ .cm-textfile-save-status[hidden], .cm-textfile-retry[hidden] { display: none; }
1327
+ .cm-textfile-retry { width: auto; padding: 0 5px; font-size: 11px; }
1328
+ #cm { flex: 1; min-height: 200px; overflow: hidden; }
1329
+ #cm .cm-inline-fold { display:inline-flex; align-items:center; justify-content:center; vertical-align:middle; width:16px; height:16px; margin-left:2px; padding:0; border:0; border-radius:3px; background:transparent; color:var(--c-fg-faint); cursor:pointer; }
1330
+ #cm .cm-inline-fold:hover, #cm .cm-inline-fold:focus-visible { color:var(--c-fg); background:var(--c-raised); }
1331
+ #cm .cm-inline-fold:focus-visible { outline:1px solid var(--c-accent); outline-offset:1px; }
1332
+ #cm .cm-inline-fold[aria-expanded="false"] svg { transform:rotate(-90deg); }
1333
+ #cm .cm-editor { height: 100%; }
1334
+ #cm .cm-editor.cm-focused { outline: none; }
1335
+ #cm .cm-textfile-controls { display: inline-flex; align-items: center; gap: 1px;
1336
+ vertical-align: middle; margin-left: 4px; user-select: none; }
1337
+ #cm .cm-textfile-controls .pbtn { width: 20px; height: 20px; border-radius: 3px; }
1338
+ #cm .cm-textfile-controls .pbtn[hidden] { display: none; }
1339
+ #cm .cm-textfile-controls .pbtn svg { width: 14px; height: 14px; }
1340
+ #cm .cm-textfile-controls .pbtn:disabled { opacity: .45; cursor: default; }
1341
+ #cm .cm-textfile { margin: 2px 6px 8px; border: 1px solid var(--c-line);
1342
+ border-radius: 4px; min-width: 0; overflow: hidden; }
1343
+ #cm .cm-textfile-status { padding: 4px 8px; color: var(--c-fg-muted); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; }
1344
+ #cm .cm-textfile-status.error { color: var(--c-danger); }
1345
+ #cm .cm-textfile-editor > .cm-editor { height: auto; max-height: 360px; background: transparent; }
1346
+ #cm .cm-textfile-editor .cm-content { padding: 6px 0; }
1347
+ #cm .cm-textfile-editor .cm-gutters { background: transparent; border: 0; }
1348
+ #cm .cm-textfile-editor .cm-scroller { overflow: auto; }
1349
+ #cm.dropping { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--c-accent) 70%, transparent);
1350
+ background: color-mix(in srgb, var(--c-accent) 5%, transparent); }
1351
+
1352
+ /* Correspondence display (body element ↔ source line) — body.corr when enabled in the gear menu */
1353
+ body.corr main article > [data-ln] { cursor: pointer; }
1354
+ body.corr main article > [data-ln].corr-hi {
1355
+ background: color-mix(in srgb, var(--c-accent) 12%, transparent);
1356
+ box-shadow: -6px 0 0 color-mix(in srgb, var(--c-accent) 55%, transparent); border-radius: 2px; }
1357
+
1358
+ .files-toolbar {display:flex;gap:10px;align-items:center;min-width:0;}
1359
+ .files-toolbar > span {flex:none;}
1360
+ .files-toolbar button {flex:none;position:relative;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;margin-left:auto;cursor:pointer;}
1361
+ .files-toolbar button:disabled {opacity:.5;cursor:default;}.files-toolbar button[data-state=loading] {color:transparent;opacity:1;}
1362
+ .files-toolbar button[data-state=loading]:after {content:"";position:absolute;left:calc(50% - 6px);top:calc(50% - 6px);width:12px;height:12px;border:1px solid var(--c-line);border-top-color:var(--c-accent);border-radius:50%;animation:files-spin .8s linear infinite;}
1363
+ @keyframes files-spin {to{transform:rotate(360deg);}}
1364
+ @media(prefers-reduced-motion:reduce){.files-toolbar button[data-state=loading]:after{animation:none;}}
1365
+ .files-toolbar button[data-state=error] {color:var(--c-danger);}
1366
+ .files-viewport {overflow:auto;min-height:360px;max-height:70vh;}
1367
+ .files-table {width:100%;border-collapse:collapse;table-layout:fixed;font-size:13px;}
1368
+ .files-table th,.files-table td {height:26px;padding:0 6px;text-align:left;}
1369
+ .files-table th {height:30px;border-bottom:1px solid var(--c-line);}
1370
+ .files-table th:nth-child(2) {width:76px;}.files-table th:last-child {width:28px;}
1371
+ .files-table th:nth-child(2),.files-table td:nth-child(2) {text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;}
1372
+ .files-table td:nth-child(2) {font-size:11px;color:var(--c-fg-faint);}
1373
+ .files-table tbody tr:hover {background:var(--c-surface);}
1374
+ .files-table tbody {user-select:none;}
1375
+ .files-table tr.selected {background:var(--c-raised);}
1376
+ .files-table button {border:0;background:none;color:inherit;cursor:pointer;padding:3px;}
1377
+ .files-name {display:flex;min-width:0;}
1378
+ .files-name a,.files-name .files-folder {position:relative;display:flex;align-items:center;gap:6px;min-width:0;white-space:nowrap;width:100%;height:26px;padding:0 0 0 16px;color:var(--c-fg);font:inherit;text-align:left;text-decoration:none;}
1379
+ .files-folder:before {content:"";position:absolute;left:3px;top:9px;width:5px;height:5px;border-right:1px solid var(--c-fg-faint);border-bottom:1px solid var(--c-fg-faint);transform:rotate(45deg);}
1380
+ .files-folder[aria-expanded=false]:before {transform:rotate(-45deg);}
1381
+ .files-name span {display:flex;flex:none;}
1382
+ .files-name svg {width:15px;height:15px;color:var(--c-fg-muted);}
1383
+ .files-name .files-basename {display:block;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;}
1384
+ .files-copy {display:flex;opacity:0;}
1385
+ .files-copy svg {width:14px;height:14px;color:var(--c-fg-faint);}
1386
+ .files-table tr:hover .files-copy,.files-table tr:focus-within .files-copy {opacity:1;}
1387
+ @media(hover:none){.files-copy{opacity:1;}}
1388
+ .files-table [data-drop=before] {box-shadow:inset 0 2px var(--c-accent);}.files-table [data-drop=after] {box-shadow:inset 0 -2px var(--c-accent);}.files-table [data-drop=into] {outline:2px solid var(--c-accent);outline-offset:-2px;}
1389
+ .files-table .files-placeholder {height:96px;text-align:center;vertical-align:middle;}
1390
+ .app-dialog textarea {display:block;box-sizing:border-box;width:100%;min-height:64px;max-height:65vh;resize:both;font:inherit;}
1391
+ @media(max-width:600px){.files-table {min-width:0;}.files-table th:nth-child(2){width:65px;}.files-name{max-width:100%;}}
1392
+
1393
+ @media(max-width:400px){.files-table th:nth-child(2),.files-table td:nth-child(2){display:none;}}
1394
+
1395
+ /* Files pages */
1396
+ .res-page h1 { margin-bottom: 6px; }
1397
+ .files-page h1 { display: flex; align-items: center; gap: 10px; overflow-wrap: anywhere; }
1398
+ .res-crumbs { color: var(--c-fg-faint); font-size: 12.5px; margin: 0 0 20px; }
1399
+ .res-crumbs a { color: var(--c-fg-muted); text-decoration: none; }
1400
+ .res-crumbs a:hover { color: var(--c-fg); }
1401
+ .res-section h2 { font-size: 15px; border: 0; margin: 28px 0 10px; }
1402
+ .res-section h2 a { color: inherit; text-decoration: none; }
1403
+ .res-sub { color: var(--c-fg-faint); font-size: 11.5px; font-weight: 400; }
1404
+ .res-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
1405
+ .res-card { display: flex; flex-direction: column; border: 1px solid var(--c-line); border-radius: 6px;
1406
+ overflow: hidden; color: inherit; text-decoration: none; background: var(--c-bg1);
1407
+ transition: border-color 120ms ease; }
1408
+ .res-card:hover { border-color: var(--c-fg-faint); }
1409
+ .res-thumb { aspect-ratio: 4 / 3; display: flex; align-items: center; justify-content: center; padding: 10px;
1410
+ background: var(--c-bg0); overflow: hidden; }
1411
+ .res-thumb .res-img { max-width: 100%; max-height: 100%; object-fit: contain; }
1412
+ .res-thumb .res-icon { width: 48px; height: 48px; }
1413
+ .res-thumb .fig-frame { width: 100% !important; height: 100% !important; aspect-ratio: auto !important;
1414
+ pointer-events: none; }
1415
+ .res-preview .res-svg { max-height: 480px; }
1416
+ .res-preview .res-html { width: 100% !important; }
1417
+ .res-thumb .res-frame { width: 100%; height: 100%; border: 0; pointer-events: none; background: #fff; }
1418
+ .res-meta { display: flex; flex-direction: column; gap: 1px; padding: 7px 10px; border-top: 1px solid var(--c-line); }
1419
+ .res-name { font-family: var(--doc-mono); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1420
+ .res-preview { display: flex; align-items: center; justify-content: center; min-height: 160px; padding: 16px;
1421
+ border: 1px solid var(--c-line); border-radius: 6px; background: var(--c-bg0); }
1422
+ .res-preview .res-img { max-width: 100%; max-height: 480px; }
1423
+ .res-preview.square .res-img { width: 160px; height: 160px; outline: 1px dashed var(--c-line); }
1424
+ .res-preview .res-frame { width: 100%; height: 420px; border: 0; background: #fff; }
1425
+ .res-markdown { width: 100%; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
1426
+ .res-thumb .res-markdown { max-height: 100%; overflow: hidden; font-size: 11px; }
1427
+ .res-preview .res-markdown { max-height: 480px; overflow: auto; }
1428
+ .res-empty { color: var(--c-fg-faint); }
1429
+ .res-dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 16px 0 0; font-size: 13px; }
1430
+ .res-dl dt { color: var(--c-fg-faint); }
1431
+ .res-dl dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
1432
+ .resources-pane .preview-content, .res-file-page { min-width: 0; width: 100%; max-width: none; }
1433
+ .resources-pane .res-file-page > h1 { margin: 8px 0 14px; font-size: 17px; overflow-wrap: anywhere; }
1434
+ .res-file-page .res-preview { display: block; min-width: 0; padding: 0; border: 0; background: transparent; }
1435
+ .res-file-page .res-preview > :is(img, video) { display: block; max-width: 100%; max-height: calc(100vh - 160px); margin: auto; object-fit: contain; }
1436
+ .res-file-page .video-player > video:not(:fullscreen) { max-height: calc(100vh - 210px); object-fit: contain; }
1437
+ .res-file-page .res-preview > :is(.codeblk, .data-table, pre) { margin-top: 0; width: 100%; min-width: 0; box-sizing: border-box; }
1438
+ .res-file-page .res-preview .codeblk > pre { max-height: calc(100vh - 180px); overflow: auto; }
1439
+ .res-file-page .res-preview .fig-frame { max-width: 100%; min-height: 300px; }
1440
+ .res-details { border-top: 1px solid var(--c-line); margin-top: 16px; padding-top: 8px; font-size: 12px; }
1441
+ .res-details > summary { cursor: pointer; color: var(--c-fg-muted); }
1442
+
1443
+
1444
+ /* Draft placeholders, the in-sidebar create form, and drag moves */
1445
+ .nav-row :is(.new, .nav-delete, .nav-create) { flex: none; width: 20px; height: 24px; display: flex;
1446
+ align-items: center; justify-content: center; border: none; border-radius: 4px; padding: 0;
1447
+ background: transparent; color: var(--c-fg-muted); cursor: pointer; opacity: 0; }
1448
+ .nav-row:hover :is(.new, .nav-delete), .nav-row:focus-within :is(.new, .nav-delete),
1449
+ .nav-row.renaming :is(.new, .nav-delete) { opacity: 1; }
1450
+ .nav-row :is(.new, .nav-delete, .nav-create):is(:hover, :focus-visible) { background: var(--c-line); }
1451
+ .nav-doc-row .nav-tree-toggle, .nav-tree-spacer { width: 18px; margin-right: 3px; }
1452
+ .nav-tree-spacer { flex: none; height: 28px; }
1453
+ @media (hover: none) { .nav-row :is(.new, .nav-delete) { opacity: 1; } }
1454
+ .nav-row > .nav-create { margin: 7px 12px; opacity: 1; }
1455
+ .nav-add-hover { visibility: hidden; }
1456
+ #sidebar:hover .nav-add-hover, #sidebar:focus-within .nav-add-hover { visibility: visible; }
1457
+ #sidebar .nav-list:has(> .nav-new-item) > .nav-placeholder { display: none; }
1458
+ /* Keep the title and filename together in a compact create form. */
1459
+ .nav-row > .nav-new { position: relative; display: flex; flex: 1; min-width: 0;
1460
+ flex-direction: column; justify-content: center; align-items: stretch; cursor: auto;
1461
+ margin: 4px 0; height: 42px; padding: 5px 38px 5px 36px;
1462
+ border: 1px dashed var(--c-line); border-radius: 6px; background: var(--c-raised); }
1463
+ .nav-tree .nav-new { margin-left: calc(var(--nav-pad) - 12px); }
1464
+ .nav-new > .nav-icon { position: absolute; left: 12px; top: 10px; color: var(--c-fg-faint); }
1465
+ .nav-new input { font: inherit; color: var(--c-fg); background: transparent; border: none;
1466
+ padding: 0; min-width: 0; outline: none; }
1467
+ .nav-new input::placeholder { color: var(--c-fg-faint); }
1468
+ .nav-new input[name="title"] { font-size: 13px; line-height: 17px; }
1469
+ .nav-new-file { display: flex; align-items: center; color: var(--c-fg-faint);
1470
+ font: 10px/12px var(--doc-mono); }
1471
+ .nav-new-file input { flex: 1; font-family: inherit; font-size: inherit; color: var(--c-fg-muted); }
1472
+ .nav-new-go { position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
1473
+ display: flex; align-items: center; justify-content: center; width: 24px;
1474
+ height: 24px; padding: 0; font-size: 11px; color: var(--c-fg-faint); background: none;
1475
+ border: 1px solid var(--c-line); border-radius: 4px; cursor: pointer; }
1476
+ .nav-new-go:hover { color: var(--c-accent); border-color: var(--c-accent); }
1477
+ .nav-new .err { position: absolute; top: 100%; left: 0; right: 0; z-index: 5; padding: 4px;
1478
+ background: var(--c-surface); color: var(--c-danger); font-size: 11px; line-height: 1.4; }
1479
+ .nav-new .err:empty { display: none; }
1480
+ .nav-tree > .nav-new-item > .nav-row::before { width: calc(var(--nav-pad) - 12px - var(--nav-line)); }
1481
+ .nav-ancestor > .nav-tree > .nav-new-item { --nav-line: var(--nav-rail, 21px); }
1482
+ .nav-ancestor > .nav-tree > .nav-new-item > .nav-row::before,
1483
+ .nav-ancestor > .nav-tree > .nav-new-item > .nav-row::after { display: block; }
1484
+ .nav-ancestor > .nav-tree > .nav-item:has(~ .nav-new-item)::before {
1485
+ display: block; left: var(--nav-rail, 21px); background: var(--c-line); }
1486
+ body.edit .nav-row { cursor: grab; }
1487
+ .nav-item.dragging { opacity: .35; }
1488
+ .nav-row.drop-before { box-shadow: inset 0 2px 0 var(--c-accent); }
1489
+ .nav-row.drop-after { box-shadow: inset 0 -2px 0 var(--c-accent); }
1490
+ .nav-row.drop-into > a { background: color-mix(in srgb, var(--c-accent) 18%, transparent); }
1491
+ `;
1492
+ // Contents of #nav-groups (the doc tree). Used by both page()'s initial render and
1493
+ // `GET /api/nav` (edit mode; the client replaces just the innerHTML after an md change)
1494
+ // (= the same value isn't declared in two places).
1495
+ // Each item is a page row and its children list, emitted even when empty
1496
+ // (it's the insertion target for the sidebar create form / drag).
1497
+ export const navGroupsHtml = (mode, site, currentId, currentRes) => {
1498
+ const open = new Set(currentId == null
1499
+ ? []
1500
+ : [...ancestorsOf(site.entries, currentId).map((e) => e.id), currentId]);
1501
+ const list = (parent, depth) => {
1502
+ const items = childrenOf(site.entries, parent)
1503
+ .map((e) => {
1504
+ const label = escapeHtml(docDisplayName(e.frontmatter, e.id));
1505
+ const cur = e.id === currentId ? " cur" : "";
1506
+ // Top level = nav-link with an icon, descendants = nav-tree-link with guide lines.
1507
+ // A doc-specific icon (icons/<id>.svg), if any, replaces the book icon at top
1508
+ // level and goes before the label below that.
1509
+ let link = depth === 0
1510
+ ? `<a class="nav-link${cur}" href="/${e.id}" title="${label}">${e.icon ? svgImg(e.icon, "nav-icon doc-icon") : DOCS_ICON}<span class="nav-label">${label}</span></a>`
1511
+ : `<a class="nav-tree-link${cur}" href="/${e.id}" title="${label}">${e.icon ? svgImg(e.icon, "doc-icon") : DOCS_ICON}<span class="nav-label">${label}</span></a>`;
1512
+ let actions = "";
1513
+ let editableIcon = "";
1514
+ if (mode === "edit") {
1515
+ link = `<a class="${depth === 0 ? "nav-link" : "nav-tree-link"}${cur}" href="/${e.id}" title="${label}" aria-keyshortcuts="F2"><span class="nav-doc-name"><span class="nav-label">${label}</span></span></a>`;
1516
+ editableIcon = `<span class="nav-doc-icon" title="icon" data-i18n-title="icon">${e.icon ? svgImg(e.icon, "doc-icon") : DOCS_ICON}<input type="file" accept=".svg,image/svg+xml" aria-label="icon" data-i18n-aria-label="icon">${e.icon ? '<button class="nav-icon-clear" type="button" title="Remove icon" data-i18n-title="Remove icon" aria-label="Remove icon" data-i18n-aria-label="Remove icon">×</button>' : ""}</span>`;
1517
+ actions = `<button class="new" type="button" data-parent="${escapeHtml(e.id)}" title="Add doc" data-i18n-title="Add doc" aria-label="Add doc" data-i18n-aria-label="Add doc">${PLUS_ICON}</button><button class="nav-delete" type="button" data-doc-action="delete" title="Delete doc" data-i18n-title="Delete doc" aria-label="Delete doc" data-i18n-aria-label="Delete doc">${DELETE_ICON}</button>`;
1518
+ }
1519
+ const branch = childrenOf(site.entries, e.id).length > 0;
1520
+ const toggle = branch
1521
+ ? `<button class="nav-tree-toggle" type="button" aria-label="Toggle ${label}">${CHEVRON_ICON}</button>`
1522
+ : mode === "edit"
1523
+ ? '<span class="nav-tree-spacer" aria-hidden="true"></span>'
1524
+ : "";
1525
+ return `<div class="nav-item${open.has(e.id) ? " open" : ""}" data-doc-id="${escapeHtml(e.id)}"><div class="nav-row${mode === "edit" ? " nav-doc-row" : ""}"${mode === "edit" ? ' draggable="true"' : ""}>${editableIcon}${link}${actions}${toggle}</div>${list(e.id, depth + 1)}</div>`;
1526
+ })
1527
+ .join("");
1528
+ const placeholder = mode === "edit" && depth === 0
1529
+ ? `<div class="nav-item nav-placeholder${items ? " nav-add-hover" : ""}"><div class="nav-row"><button class="nav-create" type="button" data-parent="" title="Add doc" data-i18n-title="Add doc" aria-label="Add doc" data-i18n-aria-label="Add doc">${PLUS_ICON}</button></div></div>`
1530
+ : "";
1531
+ return depth === 0
1532
+ ? `<div class="nav-list nav-root" data-parent=""${mode === "edit" ? ' id="nav-docs"' : ""}>${items}${placeholder}</div>`
1533
+ : `<div class="nav-list nav-tree" data-parent="${escapeHtml(parent ?? "")}" style="--nav-depth:${depth + 1}">${items}${placeholder}</div>`;
1534
+ };
1535
+ const html = list(undefined, 0) + (mode === "edit" ? navFilesHtml(site, currentRes) : "");
1536
+ return docLinks(site, site.contentLanguage ? languageLinks(html, site.contentLanguage) : html);
1537
+ };
1538
+ // ── Resources (file management in edit mode; linked figures and terms also in preview) ──
1539
+ // Structure: Resources (list page of everything) › kind (list pages for figures / html / icons) › individual file.
1540
+ // Route is `/_files[/<kind>[/<file>]]` (doc ids can't start with `_`, so no collision).
1541
+ const RESOURCE_KINDS = ["markdown", "figures", "html", "icons", "terms"];
1542
+ const siteResources = (site) => [
1543
+ ...site.entries.map((entry) => ({
1544
+ kind: "markdown",
1545
+ file: entry.file,
1546
+ path: entry.sourceFile ?? CONTENT_PREFIX + entry.file,
1547
+ })),
1548
+ ...site.ctx.terms.map((term) => ({
1549
+ kind: "terms",
1550
+ file: term.id,
1551
+ path: isTermFile(term.id)
1552
+ ? term.id
1553
+ : CONTENT_PREFIX +
1554
+ (site.entries.find((entry) => entry.blocks.some((block) => block.type === "term" && block.entry.id === term.id))?.file ?? ""),
1555
+ })),
1556
+ ...[...site.ctx.figures.values()].map((f) => ({
1557
+ kind: f.kind === "html" ? "html" : "figures",
1558
+ file: f.file.replace(/^figures\//, ""),
1559
+ path: f.file,
1560
+ })),
1561
+ ...site.entries
1562
+ .filter((e) => e.icon != null)
1563
+ .map((e) => ({
1564
+ kind: "icons",
1565
+ file: `${e.id}.svg`,
1566
+ path: e.iconFile ?? `${ICONS_DIR}/${e.id}.svg`,
1567
+ })),
1568
+ ];
1569
+ const resourceHref = (kind, file) => `/_files${kind ? `/${kind}` : ""}${file ? `/${encodeURIComponent(file)}` : ""}`;
1570
+ const navFilesHtml = (site, currentRes) => {
1571
+ const source = site.workspace;
1572
+ const statics = currentRes?.startsWith("_statics") ||
1573
+ ["dictionary", "code", "urls"].includes(currentRes ?? "");
1574
+ 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
+ const hint = `<div class="files-drop-hint" aria-hidden="true">${UPLOAD_ICON}<span data-i18n="Upload files">Upload files</span></div>`;
1577
+ const location = source?.protocol === "git"
1578
+ ? `<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>`
1579
+ : `<div class="workspace-location" id="nav-storage" data-workspace-protocol="storage" title="${escapeHtml(workspaceName(site))}">${workspaceIcon(site)}<span class="workspace-address">${escapeHtml(source?.folder ?? "Files")} / ${escapeHtml(source?.service ?? "Local")}</span></div>`;
1580
+ const git = source?.protocol === "git"
1581
+ ? `<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
+ : "";
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>`;
1584
+ };
1585
+ const formatSize = (bytes) => bytes < 1024
1586
+ ? `${bytes} B`
1587
+ : bytes < 1024 * 1024
1588
+ ? `${(bytes / 1024).toFixed(1)} KB`
1589
+ : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1590
+ const sizeOf = (path) => {
1591
+ try {
1592
+ return formatSize(statSync(join(root, path)).size);
1593
+ }
1594
+ catch {
1595
+ return "—";
1596
+ }
1597
+ };
1598
+ // Thumbnail in the lists / preview on the individual page. svg is confined to an img (avoids id
1599
+ // collisions), raster is already <img data:>, and html figures are shown
1600
+ // isolated in a sandboxed iframe.
1601
+ const resourcePreview = (site, r) => {
1602
+ if (r.kind === "markdown") {
1603
+ const entry = site.entries.find((e) => e.file === r.file);
1604
+ return `<pre class="res-markdown">${escapeHtml(entry?.raw ?? "")}</pre>`;
1605
+ }
1606
+ if (r.kind === "terms")
1607
+ return termsHtml(site.ctx.terms.filter((term) => term.id === r.file));
1608
+ if (r.kind === "figures" || r.kind === "html") {
1609
+ const f = site.ctx.figures.get(figureName(r.path));
1610
+ if (!f) {
1611
+ return "";
1612
+ }
1613
+ // svg figures are inline, as in the body (figures.ts has already namespaced the ids and
1614
+ // embedded the theme).
1615
+ return f.kind === "image"
1616
+ ? f.markup.replace("<img ", '<img class="res-img" ')
1617
+ : figureFrameHtml(f, `fig-frame res-${f.kind}`);
1618
+ }
1619
+ const e = site.entries.find((x) => `${x.id}.svg` === r.file);
1620
+ return e?.icon ? svgImg(e.icon, "res-img res-icon") : "";
1621
+ };
1622
+ const resourceUsage = (site, r) => r.kind === "markdown"
1623
+ ? site.entries.filter((e) => e.file === r.file)
1624
+ : r.kind === "figures" || r.kind === "html"
1625
+ ? site.entries.filter((e) => e.figureRefs?.includes(figureName(r.path)))
1626
+ : r.kind === "icons"
1627
+ ? site.entries.filter((e) => `${e.id}.svg` === r.file)
1628
+ : site.entries.filter((e) => e.termRefs?.includes(r.file));
1629
+ const resourceCards = (site, list) => list.length === 0
1630
+ ? ""
1631
+ : `<div class="res-grid">${list
1632
+ .map((r) => {
1633
+ const used = resourceUsage(site, r).length;
1634
+ return `<a class="res-card" href="${resourceHref(r.kind, r.file)}"><div class="res-thumb">${resourcePreview(site, r)}</div><div class="res-meta"><span class="res-name">${escapeHtml(r.file)}</span><span class="res-sub">${sizeOf(r.path)} · used by ${used}</span></div></a>`;
1635
+ })
1636
+ .join("")}</div>`;
1637
+ /** Path after `/_files` → main content (unknown → undefined = 404). */
1638
+ const textfilePreview = (site, file, source) => {
1639
+ const language = (file.split(".").at(-1) ?? "text").toLowerCase();
1640
+ const parsed = parseContentSource(language, source, file);
1641
+ const errors = parsed?.errors.map(({ message }) => message) ?? [];
1642
+ if (errors.length)
1643
+ return { html: "", errors };
1644
+ try {
1645
+ return {
1646
+ html: blocksHtml(parsed?.blocks ?? [
1647
+ { type: "code", lang: language, lines: source.split(/\r?\n/), filename: file },
1648
+ ], site.ctx),
1649
+ errors,
1650
+ };
1651
+ }
1652
+ catch (error) {
1653
+ return { html: "", errors: [error instanceof Error ? error.message : String(error)] };
1654
+ }
1655
+ };
1656
+ export const textfilePreviewHtml = (site, file, source) => {
1657
+ const { html, errors } = textfilePreview(site, file, source);
1658
+ return errors.length
1659
+ ? `<div class="errors" role="status">${errors.map(escapeHtml).join("<br>")}</div>`
1660
+ : html;
1661
+ };
1662
+ export const textfilePreviewErrors = (site, file, source) => {
1663
+ const errors = textfilePreview(site, file, source).errors;
1664
+ if (/\.(md|markdown)$/i.test(file)) {
1665
+ const { doc, source: parsed } = compileDraft(draftContext(site), file, source);
1666
+ errors.push(...parsed.errors);
1667
+ try {
1668
+ blocksHtml(doc.blocks, site.ctx);
1669
+ }
1670
+ catch (error) {
1671
+ errors.push(error instanceof Error ? error.message : String(error));
1672
+ }
1673
+ }
1674
+ return errors;
1675
+ };
1676
+ export const resourcesMain = (site, rest) => {
1677
+ if (rest === "_git")
1678
+ return site.workspace?.protocol === "git"
1679
+ ? {
1680
+ title: `${site.workspace.repo ?? "repo"} [${site.workspace.branch}]`,
1681
+ main: '<article class="res-page resource-library git-page"><div id="git-manager"></div></article>',
1682
+ }
1683
+ : undefined;
1684
+ if (rest === "_statics" || rest.startsWith("_statics/")) {
1685
+ const view = rest.slice("_statics".length).replace(/^\//, "") || "dictionary";
1686
+ return ["dictionary", "code", "urls"].includes(view)
1687
+ ? resourcesMain(site, view)
1688
+ : undefined;
1689
+ }
1690
+ const all = siteResources(site);
1691
+ const [kind, ...tail] = rest === "" ? [] : rest.split("/");
1692
+ const file = tail.length > 0 ? decodeURIComponent(tail.join("/")) : undefined;
1693
+ if (kind == null) {
1694
+ return {
1695
+ title: workspaceName(site),
1696
+ main: `<article class="res-page files-page resource-library"><div id="files-manager"></div></article>`,
1697
+ };
1698
+ }
1699
+ if (!file && (kind === "dictionary" || kind === "code" || kind === "urls")) {
1700
+ const index = resourceIndex(root, site.filePaths ?? filesSnapshot(root).files.map((item) => item.path));
1701
+ return {
1702
+ title: "Statics",
1703
+ main: `<article class="res-page files-page resource-library">${resourceTabs(kind)}${resourceLibraryHtml(index, kind)}</article>`,
1704
+ };
1705
+ }
1706
+ if (kind === "file" && file) {
1707
+ const resource = all.find((item) => item.path === file);
1708
+ if (resource)
1709
+ return resourcesMain(site, `${resource.kind}/${encodeURIComponent(resource.file)}`);
1710
+ try {
1711
+ if (!filesSnapshot(root).files.some((item) => item.path === file))
1712
+ return undefined;
1713
+ const content = readFileSync(textfilePath(root, file));
1714
+ let preview = "";
1715
+ let sourceFile;
1716
+ if (/\.(svg|png|jpe?g|gif|webp)$/i.test(file)) {
1717
+ const extension = file.split(".").pop()?.toLowerCase();
1718
+ const mime = extension === "svg" ? "svg+xml" : extension === "jpg" ? "jpeg" : extension;
1719
+ preview = `<img alt="" src="data:image/${mime};base64,${content.toString("base64")}">`;
1720
+ if (extension === "svg")
1721
+ sourceFile = file;
1722
+ }
1723
+ else if (/\.(mp4|webm|ogg)$/i.test(file)) {
1724
+ preview = `<video data-dooc-video controls playsinline preload="metadata" aria-label="Video preview" src="data:video/${file.split(".").pop()};base64,${content.toString("base64")}"></video>`;
1725
+ }
1726
+ else {
1727
+ try {
1728
+ const source = new TextDecoder("utf-8", { fatal: true }).decode(content);
1729
+ if (source.includes("\0"))
1730
+ throw new Error("Binary file");
1731
+ sourceFile = file;
1732
+ preview = textfilePreviewHtml(site, file, source);
1733
+ }
1734
+ catch {
1735
+ preview = BOX_ICON;
1736
+ }
1737
+ }
1738
+ return {
1739
+ title: file,
1740
+ sourceFile,
1741
+ main: `<article class="res-page res-file-page"><nav class="res-crumbs"><a href="/_files">${workspaceText(site)}</a></nav><h1>${escapeHtml(file)}</h1><div class="res-preview${sourceFile ? " res-text-preview" : ""}">${preview}</div></article>`,
1742
+ };
1743
+ }
1744
+ catch {
1745
+ return undefined;
1746
+ }
1747
+ }
1748
+ if (!RESOURCE_KINDS.includes(kind)) {
1749
+ return undefined;
1750
+ }
1751
+ const k = kind;
1752
+ const crumbs = `<nav class="res-crumbs"><a href="/_files">${workspaceText(site)}</a> › ${file ? `<a href="${resourceHref(k)}" data-i18n="${k}">${k}</a> › ${escapeHtml(file)}` : k}</nav>`;
1753
+ if (file == null) {
1754
+ return {
1755
+ title: `${k} — ${workspaceName(site)}`,
1756
+ main: `<article class="res-page">${k === "terms" ? "" : crumbs}<h1 data-i18n="${k}">${translate("eng", k)}</h1>${k === "terms"
1757
+ ? termsHtml(site.ctx.terms, resolveLanguage(site.contentLanguage ?? site.defaultLang, site.languages), true)
1758
+ : resourceCards(site, all.filter((r) => r.kind === k))}</article>`,
1759
+ };
1760
+ }
1761
+ const r = all.find((x) => x.kind === k && x.file === file);
1762
+ if (!r) {
1763
+ return undefined;
1764
+ }
1765
+ const fig = k === "figures" || k === "html" ? site.ctx.figures.get(figureName(r.path)) : undefined;
1766
+ const used = resourceUsage(site, r);
1767
+ const snippet = k === "terms"
1768
+ ? isTermFile(r.file)
1769
+ ? `[](${termFileUrl(r.file)})`
1770
+ : undefined
1771
+ : fig
1772
+ ? `![](${r.path})`
1773
+ : undefined;
1774
+ const rows = [
1775
+ ["path", `<code>${escapeHtml(r.path)}</code>`],
1776
+ ["size", sizeOf(r.path)],
1777
+ ...(fig ? [["kind", fig.kind]] : []),
1778
+ ...(fig?.title ? [["title", escapeHtml(fig.title)]] : []),
1779
+ ...(fig?.desc ? [["desc", escapeHtml(fig.desc)]] : []),
1780
+ ...(snippet ? [["syntax", `<code class="res-snippet">${escapeHtml(snippet)}</code>`]] : []),
1781
+ [
1782
+ k === "icons" || k === "markdown" ? "doc" : "used in",
1783
+ used.length === 0
1784
+ ? `<span class="res-sub" data-i18n="none">none</span>`
1785
+ : used
1786
+ .map((e) => `<a href="/${e.id}">${escapeHtml(docDisplayName(e.frontmatter, e.id))}</a>`)
1787
+ .join(", "),
1788
+ ],
1789
+ ];
1790
+ return {
1791
+ title: `${r.file} — Files`,
1792
+ ...(/\.(?:svg|html|termth|md|markdown)$/i.test(r.path) ? { sourceFile: r.path } : {}),
1793
+ main: `<article class="res-page res-file-page">${crumbs}<h1>${escapeHtml(r.file)}</h1><div class="res-preview${k === "icons" ? " square" : ""}">${resourcePreview(site, r)}</div><details class="res-details"><summary data-i18n="File details">File details</summary><dl class="res-dl">${rows.map(([dt, dd]) => `<dt data-i18n="${dt}">${dt}</dt><dd>${dd}</dd>`).join("")}</dl></details></article>`,
1794
+ };
1795
+ };
1796
+ const figurePage = (site, fig) => {
1797
+ const title = fig.title ?? fig.file;
1798
+ return {
1799
+ title,
1800
+ main: docHtml({ title }, [{ type: "figureRef", name: fig.name }], [], site.ctx),
1801
+ };
1802
+ };
1803
+ // Browser tab title — shared by page() and `/api/page` (doc switching via client-side navigation).
1804
+ const pageTitle = (mode, title, siteTitle) => title === siteTitle ? title : `${title} — ${siteTitle ?? `dooc ${mode}`}`;
1805
+ // Navigation follows the manifest hierarchy via routing.docPath.
1806
+ const page = (mode, site, opts) => {
1807
+ const nav = navGroupsHtml(mode, site, opts.currentId, opts.currentRes);
1808
+ const siteTitle = escapeHtml(site.title ?? "dooc");
1809
+ const siteIcon = `<span id="site-icon-slot"${mode === "edit" ? ' title="Change site icon" data-i18n-title="Change site icon"' : ""}><img id="site-icon" alt=""${site.icon ? ` src="${svgDataUri(site.icon)}"` : " hidden"}>${DOCS_ICON}${mode === "edit" ? '<input type="file" accept=".svg,image/svg+xml" aria-label="Change site icon" data-i18n-aria-label="Change site icon">' : ""}</span>`;
1810
+ const bootScripts = opts.boot != null
1811
+ ? `<script id="boot" type="application/json">${JSON.stringify(opts.boot).replace(/</g, "\\u003c")}</script>
1812
+ <script src="/assets/edit.js"></script>`
1813
+ : !opts.publish
1814
+ ? '<script src="/assets/ui.js"></script>'
1815
+ : "";
1816
+ // Interaction layer of the schema graph (zoom / pan / box drag / enlarged view). Shared by
1817
+ // preview / edit. Figures are readable without JS, so defer is enough (= progressive
1818
+ // enhancement).
1819
+ const main = opts.boot != null
1820
+ ? `<main class="editor-workspace">
1821
+
1822
+
1823
+ <div id="preview-language-menu" role="menu" aria-label="Change language" data-i18n-aria-label="Change language" hidden></div>
1824
+ <div id="preview-panes"><section class="preview-pane active" tabindex="0" aria-label="Active pane" data-i18n-aria-label="Active pane"><div class="pane-actions"><button class="pbtn" type="button" data-theme-picker="document" aria-label="Preview theme"></button>${fontButton("viewer")}<button class="pbtn pane-language" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="preview-language-menu" aria-label="Change language" data-i18n-aria-label="Change language" title="Change language" data-i18n-title="Change language">${GLOBE_ICON}</button></div><div class="preview-content">${opts.main}</div></section></div></main>`
1825
+ : mode === "edit" && opts.currentRes !== undefined
1826
+ ? `<main class="resources-pane">${opts.main}</main>`
1827
+ : `<main><div class="page-actions"><button class="pbtn" type="button" data-theme-picker="document" aria-label="Preview theme"></button>${fontButton("viewer")}</div>${opts.main}</main>`;
1828
+ const graphScript = opts.publish
1829
+ ? `<script>${inlineScript(opts.publish.graphJs)}</script>`
1830
+ : '<script src="/assets/graph.js" defer></script>';
1831
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
1832
+ ${opts.publish ? `<meta name="referrer" content="no-referrer"><meta http-equiv="x-dns-prefetch-control" content="off">${opts.publish.network ? "" : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' data:; img-src data: blob:; media-src data: blob:; font-src data: blob:; frame-src data: blob:; connect-src 'none'; base-uri 'none'; form-action 'none'">`}` : ""}
1833
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1834
+ <title>${escapeHtml(pageTitle(mode, opts.title, site.title))}</title>
1835
+ <script>try{const t=localStorage.getItem("docs-preview:theme");if(t)document.documentElement.dataset.theme=t;const e=localStorage.getItem("docs-editor:theme");if(e)document.documentElement.dataset.editorTheme=e}catch{}</script>
1836
+ <style>${fontCss(Boolean(opts.publish))}${CSS}${mode === "edit" ? EDIT_CSS : ""}</style></head>
1837
+ <body${mode === "edit" ? ' class="edit"' : ""}><nav id="sidebar"><div id="sidebar-resize" class="panel-resize" role="separator" tabindex="0" aria-orientation="vertical" aria-controls="sidebar" aria-label="Resize sidebar" data-i18n-aria-label="Resize sidebar" title="Resize sidebar" data-i18n-title="Resize sidebar"></div><div id="sidebar-head">${siteIcon}<div id="site-title" class="site-title" title="${siteTitle}">${siteTitle}</div><button id="sidebar-toggle" type="button" aria-controls="sidebar-content" aria-expanded="true" title="Close sidebar" data-i18n-title="Close sidebar">${SIDEBAR_ICON}</button></div><div id="sidebar-content"><div id="nav-groups">${nav}</div><div id="nav-status" role="status" aria-live="polite"></div></div></nav>${main}${opts.aside ?? ""}
1838
+ ${bootScripts}${graphScript}
1839
+ <script id="code-grammars" type="application/json">${JSON.stringify(site.codeGrammars ?? []).replace(/</g, "\\u003c")}</script>
1840
+ ${opts.publish ? `<script>${inlineScript(opts.publish.codeJs)}</script>` : '<script src="/assets/code.js" defer></script>'}
1841
+ <script>
1842
+ // Functions toString'd under tsx (esbuild keepNames) contain __name(...) calls; define a no-op.
1843
+ var __name = (f) => f;
1844
+ const contentLanguage = () => JSON.parse(document.getElementById("boot")?.textContent || "null")?.contentLanguage || ${JSON.stringify(site.contentLanguage ?? site.defaultLang)};
1845
+ const localeFetch = (url, init = {}) => fetch(url, { ...init, headers: { ...init.headers, "x-docs-language": contentLanguage() } });
1846
+ (${mountTerms.toString()})(document);
1847
+ (${mountTables.toString()})(document);
1848
+ (${mountToc.toString()})(document);
1849
+ (${mountVideos.toString()})(document);
1850
+ (${mountFigureFrames.toString()})(document);
1851
+ (${mountThemeVariants.toString()})(document);
1852
+ (${mountThemePickers.toString()})(${JSON.stringify(THEME_ICONS)}, ${JSON.stringify(site.themes)});
1853
+ void (${mountFontPickers.toString()})(${createFontSettings.toString()}, ${JSON.stringify(site.fonts ?? []).replace(/</g, "\\u003c")});
1854
+ (() => {
1855
+ const KEY = "docs:sidebar-open";
1856
+ const toggle = document.getElementById("sidebar-toggle");
1857
+ if (!toggle) return;
1858
+ const apply = (open) => {
1859
+ document.body.classList.toggle("sidebar-closed", !open);
1860
+ toggle.setAttribute("aria-expanded", String(open));
1861
+ toggle.dataset.i18nTitle = open ? "Close sidebar" : "Open sidebar";
1862
+ toggle.title = window.__docsTranslate ? window.__docsTranslate(toggle.dataset.i18nTitle) : toggle.dataset.i18nTitle;
1863
+ };
1864
+ const narrow = matchMedia("(max-width: 600px)");
1865
+ let preferred;
1866
+ try { const saved = localStorage.getItem(KEY); if (saved !== null) preferred = saved !== "0"; } catch {}
1867
+ let open = !narrow.matches && preferred !== false;
1868
+ apply(open);
1869
+ narrow.addEventListener("change", () => { open = !narrow.matches && preferred !== false; apply(open); });
1870
+ toggle.addEventListener("click", () => {
1871
+ open = !open;
1872
+ preferred = open;
1873
+ try { localStorage.setItem(KEY, open ? "1" : "0"); } catch {}
1874
+ apply(open);
1875
+ });
1876
+ document.getElementById("sidebar-content")?.addEventListener("click", (event) => {
1877
+ if (open || !(event.target instanceof Element) || !event.target.closest(".nav-row")) return;
1878
+ event.preventDefault();
1879
+ event.stopImmediatePropagation();
1880
+ toggle.click();
1881
+ }, true);
1882
+ })();
1883
+ (${mountPanelResize.toString()})();
1884
+ (${mountToolsResize.toString()})();
1885
+ (() => {
1886
+ // Open/close: by default "the branch containing the current doc is open" (the server adds
1887
+ // .open), overridden by explicit user actions (sessionStorage = per tab only).
1888
+ const OPEN_KEY = "docs:nav-open";
1889
+ const readOpen = () => { try { return JSON.parse(sessionStorage.getItem(OPEN_KEY) || "{}"); } catch { return {}; } };
1890
+ const applyOpen = () => {
1891
+ const o = readOpen();
1892
+ for (const it of document.querySelectorAll("#nav-groups .nav-item")) {
1893
+ const key = it.dataset.docId || it.dataset.nodeId;
1894
+ if (key in o) it.classList.toggle("open", o[key]);
1895
+ for (const button of it.querySelectorAll(":scope > .nav-row > [aria-expanded]")) button.setAttribute("aria-expanded", String(it.classList.contains("open")));
1896
+ }
1897
+ (${focusNavTree.toString()})(document.getElementById("nav-groups"));
1898
+ };
1899
+ window.__docsNavOpen = (id, v) => {
1900
+ const o = readOpen();
1901
+ o[id] = v;
1902
+ try { sessionStorage.setItem(OPEN_KEY, JSON.stringify(o)); } catch {}
1903
+ applyOpen();
1904
+ };
1905
+ document.addEventListener("click", (ev) => {
1906
+ const t = ev.target instanceof Element ? ev.target.closest(".nav-tree-toggle") : null;
1907
+ if (!t) return;
1908
+ ev.preventDefault();
1909
+ const it = t.closest(".nav-item");
1910
+ window.__docsNavOpen(it.dataset.docId || it.dataset.nodeId, !it.classList.contains("open"));
1911
+ });
1912
+ applyOpen();
1913
+ window.__docsNavRestore = applyOpen;
1914
+ const nav = document.getElementById("nav-groups");
1915
+ new ResizeObserver(() => (${focusNavTree.toString()})(nav)).observe(nav);
1916
+ })();
1917
+ ${mode === "edit"
1918
+ ? `(() => {
1919
+ // Sidebar tree editing (delegated to document — keeps working when HMR swaps #nav-groups).
1920
+ // drag: top 1/4 of a row = before, bottom 1/4 = after, middle = move into that doc's children
1921
+ // (at the end). The DOM isn't moved, only a marker is shown; on drop, POST /api/move →
1922
+ // re-fetch the nav regardless of the outcome (= the display is always a copy of
1923
+ // dooc.yaml; no rollback logic).
1924
+ // The root placeholder and row buttons open the create form.
1925
+ const status = document.getElementById("nav-status");
1926
+ const setStatus = (message, error) => {
1927
+ if (!status) return;
1928
+ status.textContent = message;
1929
+ status.classList.toggle("err", Boolean(error));
1930
+ };
1931
+ const refresh = async () => {
1932
+ if (window.__docsRefreshNav) return window.__docsRefreshNav();
1933
+ const cur = document.querySelector("#nav-groups .nav-row a.cur");
1934
+ 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;
1936
+ const res = await localeFetch("/api/nav?current=" + encodeURIComponent(id) + (rp == null ? "" : "&res=" + encodeURIComponent(rp)));
1937
+ if (res.ok) document.getElementById("nav-groups").innerHTML = (await res.json()).html;
1938
+ if (window.__docsNavRestore) window.__docsNavRestore();
1939
+ document.dispatchEvent(new Event("docs:ui-refresh"));
1940
+ };
1941
+ const post = async (path, body) => {
1942
+ // Tell HMR, so the watch notification for our own write (dooc.yaml etc.) doesn't reload.
1943
+ const res = await localeFetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1944
+ const data = await res.json().catch(() => ({}));
1945
+ if (!res.ok) throw new Error(data.error || String(res.status));
1946
+ return data;
1947
+ };
1948
+
1949
+ const docAction = (item, action) => {
1950
+ if (window.__docsDocAction) {
1951
+ void window.__docsDocAction(item.dataset.docId, action);
1952
+ } else {
1953
+ const url = new URL(item.querySelector(":scope > .nav-row > a").href);
1954
+ url.searchParams.set("doc-action", action);
1955
+ location.href = url.href;
1956
+ }
1957
+ };
1958
+ document.addEventListener("click", (event) => {
1959
+ const button = event.target instanceof Element ? event.target.closest("[data-doc-action]") : null;
1960
+ if (!button) return;
1961
+ event.preventDefault();
1962
+ docAction(button.closest(".nav-item"), button.dataset.docAction);
1963
+ });
1964
+ const editLabel = (event) => {
1965
+ if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
1966
+ const target = event.target instanceof Element ? event.target : null;
1967
+ if (!target || target.closest("button, input, form, .nav-doc-icon")) return;
1968
+ const row = target.closest("#nav-groups [data-doc-id] > .nav-row");
1969
+ if (!row) return;
1970
+ event.preventDefault();
1971
+ docAction(row.closest("[data-doc-id]"), "rename");
1972
+ };
1973
+ document.addEventListener("dblclick", editLabel);
1974
+ document.addEventListener("keydown", (event) => { if (event.key === "F2") editLabel(event); });
1975
+ const setIcon = async (slot, file) => {
1976
+ if (file && !/\\.svg$/i.test(file.name) && file.type !== "image/svg+xml") {
1977
+ setStatus("Icon must be svg", true);
1978
+ return;
1979
+ }
1980
+ const id = slot.closest(".nav-item")?.dataset.docId;
1981
+ try {
1982
+ const data = file ? btoa(Array.from(new Uint8Array(await file.arrayBuffer()), (byte) => String.fromCharCode(byte)).join("")) : null;
1983
+ const result = await post(id ? "/api/icon" : "/api/site-icon", { file: id ? id + ".md" : undefined, data });
1984
+ if (!id) {
1985
+ const logo = document.getElementById("site-icon");
1986
+ logo.src = result.icon;
1987
+ logo.hidden = false;
1988
+ }
1989
+ setStatus("", false);
1990
+ await refresh();
1991
+ } catch (error) {
1992
+ setStatus(error.message, true);
1993
+ }
1994
+ };
1995
+ document.addEventListener("change", (event) => {
1996
+ const input = event.target;
1997
+ if (!(input instanceof HTMLInputElement) || !input.closest(".nav-doc-icon, #site-icon-slot")) return;
1998
+ const file = input.files[0];
1999
+ input.value = "";
2000
+ if (file) void setIcon(input.closest(".nav-doc-icon, #site-icon-slot"), file);
2001
+ });
2002
+ document.addEventListener("click", (event) => {
2003
+ const clear = event.target instanceof Element ? event.target.closest(".nav-icon-clear") : null;
2004
+ if (!clear) return;
2005
+ event.preventDefault();
2006
+ void setIcon(clear.closest(".nav-doc-icon"), null);
2007
+ });
2008
+ document.addEventListener("dragover", (event) => {
2009
+ const slot = event.target instanceof Element ? event.target.closest(".nav-doc-icon, #sidebar-head") : null;
2010
+ if (!slot || !event.dataTransfer?.types.includes("Files")) return;
2011
+ event.preventDefault();
2012
+ slot.classList.add("over");
2013
+ });
2014
+ document.addEventListener("dragleave", (event) => {
2015
+ const slot = event.target instanceof Element ? event.target.closest(".nav-doc-icon, #sidebar-head") : null;
2016
+ if (slot && !slot.contains(event.relatedTarget)) slot.classList.remove("over");
2017
+ });
2018
+ document.addEventListener("drop", (event) => {
2019
+ const slot = event.target instanceof Element ? event.target.closest(".nav-doc-icon, #sidebar-head") : null;
2020
+ const file = event.dataTransfer?.files[0];
2021
+ if (!slot || !file) return;
2022
+ event.preventDefault();
2023
+ slot.classList.remove("over");
2024
+ void setIcon(slot, file);
2025
+ });
2026
+
2027
+ let dragged = null;
2028
+ let drop = null; // { row, zone: "before" | "after" | "into" }
2029
+ const clearMarker = () => {
2030
+ for (const r of document.querySelectorAll(".nav-row.drop-before, .nav-row.drop-after, .nav-row.drop-into")) {
2031
+ r.classList.remove("drop-before", "drop-after", "drop-into");
2032
+ }
2033
+ };
2034
+ document.addEventListener("dragstart", (event) => {
2035
+ const row = event.target instanceof Element ? event.target.closest(".nav-row") : null;
2036
+ if (!row || !event.dataTransfer) return;
2037
+ if (event.target.closest("input, button, .nav-doc-icon") || row.classList.contains("renaming")) {
2038
+ event.preventDefault();
2039
+ return;
2040
+ }
2041
+ dragged = row.closest(".nav-item");
2042
+ dragged.classList.add("dragging");
2043
+ event.dataTransfer.effectAllowed = "move";
2044
+ event.dataTransfer.setData("application/x-doc-id", dragged.dataset.docId || "");
2045
+ });
2046
+ document.addEventListener("dragend", () => {
2047
+ if (dragged) dragged.classList.remove("dragging");
2048
+ clearMarker();
2049
+ dragged = null;
2050
+ drop = null;
2051
+ });
2052
+ document.addEventListener("dragover", (event) => {
2053
+ if (!dragged || !event.dataTransfer) return;
2054
+ const row = event.target instanceof Element ? event.target.closest("#nav-groups .nav-row") : null;
2055
+ const item = row && row.closest(".nav-item");
2056
+ // Can't drop onto itself or its own descendants (cycle).
2057
+ if (!item || !item.dataset.docId || dragged.contains(item)) { clearMarker(); drop = null; return; }
2058
+ event.preventDefault();
2059
+ event.dataTransfer.dropEffect = "move";
2060
+ const rect = row.getBoundingClientRect();
2061
+ const y = (event.clientY - rect.top) / rect.height;
2062
+ const zone = y < 0.25 ? "before" : y > 0.75 ? "after" : "into";
2063
+ clearMarker();
2064
+ row.classList.add("drop-" + zone);
2065
+ drop = { row, zone };
2066
+ });
2067
+ document.addEventListener("drop", async (event) => {
2068
+ if (!dragged || !drop) return;
2069
+ event.preventDefault();
2070
+ const id = dragged.dataset.docId;
2071
+ const item = drop.row.closest(".nav-item");
2072
+ let parent;
2073
+ let index;
2074
+ if (drop.zone === "into") {
2075
+ parent = item.dataset.docId;
2076
+ index = [...item.querySelector(":scope > .nav-list").children].filter((n) => n.dataset.docId && n !== dragged).length;
2077
+ if (window.__docsNavOpen) window.__docsNavOpen(parent, true);
2078
+ } else {
2079
+ const list = item.parentElement;
2080
+ parent = list.dataset.parent || undefined;
2081
+ const siblings = [...list.children].filter((n) => n.dataset.docId && n !== dragged);
2082
+ index = siblings.indexOf(item) + (drop.zone === "after" ? 1 : 0);
2083
+ }
2084
+ clearMarker();
2085
+ setStatus("Moving…", false);
2086
+ try {
2087
+ await post("/api/move", { id, parent, index });
2088
+ setStatus("Moved", false);
2089
+ } catch (error) {
2090
+ setStatus("Failed to move: " + error.message, true);
2091
+ }
2092
+ await refresh();
2093
+ });
2094
+
2095
+ document.addEventListener("docs:files-changed", () => { void refresh(); });
2096
+
2097
+ // ── In-sidebar create form ──
2098
+ const suggestFileName = ${suggestDocFileName.toString()};
2099
+ const closeForm = () => { for (const f of document.querySelectorAll(".nav-new-item")) f.remove(); };
2100
+ const openForm = (parent) => {
2101
+ closeForm();
2102
+ const host = parent
2103
+ ? document.querySelector('#nav-groups .nav-item[data-doc-id="' + CSS.escape(parent) + '"] > .nav-list')
2104
+ : document.querySelector("#nav-groups > .nav-list");
2105
+ if (!host) return;
2106
+ if (parent) {
2107
+ window.__docsNavOpen(parent, true);
2108
+ }
2109
+ const item = document.createElement("div");
2110
+ item.className = "nav-item nav-new-item";
2111
+ const row = document.createElement("div");
2112
+ row.className = "nav-row";
2113
+ const form = document.createElement("form");
2114
+ form.className = "nav-new";
2115
+ form.innerHTML =
2116
+ ${JSON.stringify(DOCS_ICON)} +
2117
+ '<input name="title" placeholder="title" data-i18n-placeholder="title" required autocomplete="off">' +
2118
+ '<div class="nav-new-file"><input name="name" placeholder="File name" data-i18n-placeholder="File name" aria-label="File name" data-i18n-aria-label="File name" required autocomplete="off" spellcheck="false">' +
2119
+ '<button class="nav-new-go" type="submit" title="Create" data-i18n-title="Create" aria-label="Create" data-i18n-aria-label="Create">↵</button></div>' +
2120
+ '<div class="err"></div>';
2121
+ row.append(form);
2122
+ item.append(row);
2123
+ host.append(item);
2124
+ document.dispatchEvent(new Event("docs:ui-refresh"));
2125
+ const title = form.elements.namedItem("title");
2126
+ const nameInput = form.elements.namedItem("name");
2127
+ const submit = form.querySelector(".nav-new-go");
2128
+ const err = form.querySelector(".err");
2129
+ let nameTouched = false;
2130
+ let busy = false;
2131
+ title.addEventListener("input", () => { if (!nameTouched) nameInput.value = suggestFileName(title.value); });
2132
+ nameInput.addEventListener("input", () => { nameTouched = true; });
2133
+ form.addEventListener("keydown", (e) => { if (e.key === "Escape" && !busy) closeForm(); });
2134
+ // Close when focus moves out with nothing typed (keep partially filled forms).
2135
+ // (remove itself fires focusout, so defer one tick and close only if still there)
2136
+ form.addEventListener("focusout", (e) => {
2137
+ if (busy || form.contains(e.relatedTarget) || title.value.trim() || nameInput.value.trim()) return;
2138
+ setTimeout(() => { if (item.isConnected && !item.contains(document.activeElement)) item.remove(); }, 0);
2139
+ });
2140
+ form.addEventListener("submit", async (e) => {
2141
+ e.preventDefault();
2142
+ if (busy) return;
2143
+ busy = submit.disabled = title.readOnly = nameInput.readOnly = true;
2144
+ submit.setAttribute("aria-busy", "true");
2145
+ err.textContent = "";
2146
+ try {
2147
+ await post("/api/create", { parent: parent || undefined, name: nameInput.value.trim(), title: title.value.trim() });
2148
+ closeForm();
2149
+ await refresh();
2150
+ } catch (error) {
2151
+ err.textContent = error.message;
2152
+ } finally {
2153
+ busy = submit.disabled = title.readOnly = nameInput.readOnly = false;
2154
+ submit.removeAttribute("aria-busy");
2155
+ }
2156
+ });
2157
+ item.scrollIntoView({ block: "nearest" });
2158
+ title.focus();
2159
+ };
2160
+ document.addEventListener("click", (event) => {
2161
+ const btn = event.target instanceof Element ? event.target.closest("#sidebar .new, #sidebar .nav-create") : null;
2162
+ if (!btn) return;
2163
+ event.preventDefault();
2164
+ openForm(btn.dataset.parent || "");
2165
+ });
2166
+ })();`
2167
+ : ""}
2168
+ // code block copy — delegated to document so it keeps working after edit's innerHTML swaps.
2169
+ document.addEventListener("click", async (event) => {
2170
+ const button = event.target instanceof Element ? event.target.closest("button.section-copy") : null;
2171
+ if (!button) return;
2172
+ const label = (key) => window.__docsTranslate ? window.__docsTranslate(key) : key;
2173
+ button.classList.remove("ok", "error");
2174
+ try {
2175
+ await navigator.clipboard.writeText(new URL(button.dataset.sectionUrl, location.href).href);
2176
+ button.classList.add("ok");
2177
+ button.title = label("Copied");
2178
+ } catch {
2179
+ button.classList.add("error");
2180
+ button.title = label("Could not copy URL");
2181
+ }
2182
+ button.setAttribute("aria-label", button.title);
2183
+ setTimeout(() => {
2184
+ button.classList.remove("ok", "error");
2185
+ button.title = label("Copy section URL");
2186
+ button.setAttribute("aria-label", button.title);
2187
+ }, 1600);
2188
+ });
2189
+ document.addEventListener("click", (ev) => {
2190
+ const btn = ev.target instanceof Element ? ev.target.closest("button.codecopy") : null;
2191
+ if (!btn) return;
2192
+ const wrap = btn.closest(".codeblk");
2193
+ const code = wrap && wrap.querySelector("pre code");
2194
+ if (!code) return;
2195
+ navigator.clipboard.writeText(code.textContent || "").catch(() => {});
2196
+ btn.classList.add("ok");
2197
+ setTimeout(() => btn.classList.remove("ok"), 1200);
2198
+ });
2199
+ // Code blocks toggle from their border label.
2200
+ document.addEventListener("click", (ev) => {
2201
+ const head = ev.target instanceof Element ? ev.target.closest(".codeblk-head") : null;
2202
+ if (!head || (ev.target instanceof Element && ev.target.closest("button.codecopy"))) return;
2203
+ const wrap = head.closest(".codeblk");
2204
+ if (!wrap) return;
2205
+ const collapsed = wrap.classList.toggle("collapsed");
2206
+ const button = head.querySelector(".codefold");
2207
+ if (button) {
2208
+ const label = collapsed ? "Expand code" : "Collapse code";
2209
+ button.setAttribute("aria-expanded", String(!collapsed));
2210
+ button.dataset.i18nTitle = label;
2211
+ button.dataset.i18nAriaLabel = label;
2212
+ const text = window.__docsTranslate ? window.__docsTranslate(label) : label;
2213
+ button.title = text;
2214
+ button.setAttribute("aria-label", text);
2215
+ }
2216
+ });
2217
+ </script>
2218
+ <script>${inlineScript(`(${mountSearch.toString()})(${JSON.stringify(opts.publish ? searchIndex(site) : []).replace(/</g, "\\u003c")}, ${searchSections.toString()}, ${JSON.stringify(Object.fromEntries(SEARCH_LABELS.map((key) => [key, translate(site.defaultLang, key)]))).replace(/</g, "\\u003c")}, ${Boolean(opts.publish)});`)}</script>
2219
+ ${opts.publish ? `${opts.publish.pages}<script>${publishRouterScript(opts.publish.first)}${opts.publish.network ? `\n(${mountBundleNetwork.toString()})();` : ""}</script>` : `<script>${hmrClientScript(mode)}</script>`}</body></html>`;
2220
+ };
2221
+ // A `</script` inside an inline <script> would close the script early, so escape it.
2222
+ const inlineScript = (js) => js.replace(/<\/(script)/gi, "<\\/$1");
2223
+ // Router for the published output — keeps each route's main content in <template data-route>
2224
+ // and swaps <main> based on location.hash (`#/<ancestor/.../docId>[#<anchor>]`) (works without a server).
2225
+ const publishRouterScript = (first) => `
2226
+ (() => {
2227
+ const FIRST = ${JSON.stringify(first)};
2228
+ const main = document.querySelector("main");
2229
+ const pages = new Map([...document.querySelectorAll("template[data-route]")].map((t) => [t.dataset.route, t]));
2230
+ const show = () => {
2231
+ const [path, rawAnchor] = location.hash.slice(1).split("#");
2232
+ let anchor = "";
2233
+ try { anchor = decodeURIComponent(rawAnchor || ""); } catch {}
2234
+ const p = (path || "").split("?")[0].replace(/^\\/+|\\/+$/g, "");
2235
+ const route = pages.has(p) ? p : FIRST;
2236
+ const page = pages.get(route);
2237
+ const actions = main.querySelector(":scope > .page-actions");
2238
+ main.innerHTML = page.innerHTML;
2239
+ if (actions) main.prepend(actions);
2240
+ document.title = page.dataset.title;
2241
+ for (const a of document.querySelectorAll("#nav-groups a")) {
2242
+ a.classList.toggle("cur", a.getAttribute("href").split("?")[0] === "#/" + route);
2243
+ }
2244
+ const cur = document.querySelector("#nav-groups a.cur");
2245
+ for (let it = cur && cur.closest(".nav-item").parentElement.closest(".nav-item"); it; it = it.parentElement.closest(".nav-item")) {
2246
+ it.classList.add("open");
2247
+ }
2248
+ (${focusNavTree.toString()})(document.getElementById("nav-groups"));
2249
+ const target = anchor && document.getElementById(anchor);
2250
+ requestAnimationFrame(() => {
2251
+ if (target) target.scrollIntoView(); else window.scrollTo(0, 0);
2252
+ });
2253
+ };
2254
+ window.addEventListener("hashchange", show);
2255
+ show();
2256
+ })();`;
2257
+ // Maps the server's absolute-path links (`/<route>`) and in-body anchors (`#<id>`) to the
2258
+ // published output's hash routes.
2259
+ const toHashLinks = (html, route) => html
2260
+ .replace(/(href|data-section-url)="\/(?!\/)/g, '$1="#/')
2261
+ .replace(/href="#(?!\/)/g, `href="#/${route}#`);
2262
+ /**
2263
+ * Turns the whole Site into a single HTML file readable without a server (everything embedded,
2264
+ * down to figures / terms / schema graph JS). Each doc is held in a
2265
+ * <template> and switched via hash routes.
2266
+ */
2267
+ export const publishHtml = (site, graphJs, title = site.title ?? "dooc", codeJs = "", mode = "static") => {
2268
+ const pages = site.entries.map((e) => ({
2269
+ route: docPath(site.entries, e.id).slice(1),
2270
+ title: docDisplayName(e.frontmatter, e.id),
2271
+ main: entryMain(site, e),
2272
+ }));
2273
+ const first = pages[0]?.route ?? "";
2274
+ if (site.ctx.terms.length > 0) {
2275
+ const terms = resourcesMain(site, "terms");
2276
+ if (terms)
2277
+ pages.push({ route: "_files/terms", ...terms });
2278
+ }
2279
+ const figureRefs = new Set(site.entries.flatMap((entry) => entry.figureRefs ?? []));
2280
+ for (const name of figureRefs) {
2281
+ const fig = site.ctx.figures.get(name);
2282
+ if (!fig)
2283
+ continue;
2284
+ const route = `_files/file/${encodeURIComponent(fig.file)}`;
2285
+ pages.push({ route, ...figurePage(site, fig) });
2286
+ }
2287
+ const templates = pages
2288
+ .map((p) => `<template data-route="${escapeHtml(p.route)}" data-title="${escapeHtml(pageTitle("preview", p.title, title))}">${toHashLinks(p.main, p.route)}</template>`)
2289
+ .join("");
2290
+ return toHashLinks(page("preview", { ...site, title }, {
2291
+ title,
2292
+ main: "",
2293
+ publish: { graphJs, codeJs, pages: templates, first, network: mode === "network" },
2294
+ }), first);
2295
+ };
2296
+ // A doc's main content. A doc with an empty body and children (a chapter-heading-only doc) shows
2297
+ // its children list in place of the body.
2298
+ const entryMain = (site, e) => {
2299
+ const html = renderEntryHtml(site, e).html;
2300
+ const kids = childrenOf(site.entries, e.id);
2301
+ if (e.blocks.length > 0 || kids.length === 0) {
2302
+ return html;
2303
+ }
2304
+ const items = kids
2305
+ .map((k) => `<li><a href="${docPath(site.entries, k.id)}">${escapeHtml(docDisplayName(k.frontmatter, k.id))}</a></li>`)
2306
+ .join("");
2307
+ return html.replace(/<\/article>\s*$/, `<ul class="idx">${items}</ul></article>`);
2308
+ };
2309
+ export const languageFieldHtml = (defaultLang, language = contentLanguageCode(defaultLang), translatedLanguages = [contentLanguageCode(defaultLang)], declaredLanguages) => {
2310
+ const existing = new Set(translatedLanguages);
2311
+ const options = [...contentLanguages]
2312
+ .filter(({ code }) => !declaredLanguages || declaredLanguages.includes(code))
2313
+ .sort((a, b) => Number(existing.has(b.code)) - Number(existing.has(a.code)))
2314
+ .map(({ code, name }) => `<option value="${code}" data-name="${escapeHtml(name)}" data-added="${existing.has(code)}"${code === language ? " selected" : ""}>${escapeHtml(name)} (${code})</option>`)
2315
+ .join("");
2316
+ return `<div class="fm-language"><button class="pbtn" type="button" data-language-open aria-label="Change language" data-i18n-aria-label="Change language" aria-haspopup="listbox" aria-expanded="false" aria-controls="fm-lang-options">${GLOBE_ICON}</button><select id="fm-lang" data-source="${contentLanguageCode(defaultLang)}" aria-label="Change language" data-i18n-aria-label="Change language">${options}</select></div>`;
2317
+ };
2318
+ // The same thing is passed for the initial render (editorAside) and doc switch (`/api/page`)
2319
+ // (= the client only has to handle one boot shape, initially and after a switch).
2320
+ export const editorBoot = (entry, site) => {
2321
+ // Completion candidates are files in the figures/ catalog.
2322
+ const figures = [...site.ctx.figures.values()].map((f) => f.file);
2323
+ const docs = site.entries.map((e) => ({
2324
+ ref: e.sourceFile ?? CONTENT_PREFIX + e.file,
2325
+ title: docDisplayName(e.frontmatter, e.id),
2326
+ }));
2327
+ return {
2328
+ gitBase: entryGitBase(entry, site),
2329
+ ...(site.title === undefined ? {} : { siteTitle: site.title }),
2330
+ ...(site.icon === undefined ? {} : { siteIcon: svgDataUri(site.icon) }),
2331
+ ...(entry.frontmatter?.lang ? { docLanguage: entry.frontmatter.lang } : {}),
2332
+ ...(entry.translatedLanguages ? { translatedLanguages: entry.translatedLanguages } : {}),
2333
+ ...(site.contentLanguage
2334
+ ? {
2335
+ contentLanguage: site.contentLanguage,
2336
+ sourceLanguage: contentLanguageCode(site.defaultLang),
2337
+ }
2338
+ : {}),
2339
+ file: entry.file,
2340
+ sourceFile: entry.sourceFile,
2341
+ docId: entry.id,
2342
+ title: entry.frontmatter?.title ?? "",
2343
+ path: docPath(site.entries, entry.id),
2344
+ body: entry.body,
2345
+ figures,
2346
+ codeLanguages: site.codeLanguages ?? CODE_LANGS,
2347
+ docs,
2348
+ terms: [...site.ctx.terms],
2349
+ blockLines: entry.blockLines,
2350
+ baseRaw: entry.raw,
2351
+ icon: entry.icon ? svgDataUri(entry.icon) : null,
2352
+ defaultLang: site.defaultLang,
2353
+ spellcheckLang: resolveLanguage(entry.lang, site.languages).spellcheckLang ?? entry.lang,
2354
+ filePreviews: filePreviewResults(entry, site),
2355
+ previews: site.previews,
2356
+ };
2357
+ };
2358
+ // Language and editor actions stay in the header; document actions live in the tree.
2359
+ const editorAside = (entry, site) => {
2360
+ const aside = `<aside id="panel"><div id="resize" class="panel-resize" role="separator" tabindex="0" aria-orientation="vertical" aria-controls="panel" aria-label="Resize editor" data-i18n-aria-label="Resize editor" title="Resize editor" data-i18n-title="Resize editor"></div>
2361
+ <div id="phead">
2362
+ <button class="pbtn" type="button" data-theme-picker="editor" aria-label="Editor theme"></button>
2363
+ ${fontButton("editor")}
2364
+ ${languageFieldHtml(site.defaultLang, site.contentLanguage, entry.translatedLanguages, site.contentLanguages)}
2365
+ <span id="status"></span>
2366
+ <button id="validate" class="pbtn" type="button" title="Validate and apply" data-i18n-title="Validate and apply" aria-label="Validate and apply" data-i18n-aria-label="Validate and apply">${icon('<path d="M5 5l-4 5 4 5M15 5l4 5-4 5M7 10l2 2 4-5"/>')}</button>
2367
+ <button id="save" class="pbtn" data-state="saved" type="button" title="Save (Ctrl+S)" data-i18n-title="Save (Ctrl+S)" aria-label="Save" data-i18n-aria-label="Save">${icon('<path d="M3 3h11l3 3v11H3z"/><path d="M6 3v5h7V3M6 17v-6h8v6"/>')}</button>
2368
+ <button id="split-preview" class="pbtn" type="button" aria-pressed="false" aria-label="Split view" data-i18n-aria-label="Split view" title="Split view" data-i18n-title="Split view">${icon('<rect x="3" y="3" width="14" height="14" rx="2"/><path d="M10 3v14"/>')}</button>
2369
+ <div id="gear"><button class="pbtn" type="button" aria-haspopup="true" aria-label="View settings" data-i18n-aria-label="View settings" title="View settings" data-i18n-title="View settings">${GEAR_ICON}</button><div id="gearmenu">
2370
+ <div id="language-setting"><select id="ui-language" aria-label="Editor language" data-i18n-aria-label="Language"><option value="eng">English</option><option value="jpn">日本語</option><option value="zho">中文</option></select></div>
2371
+ <label><input type="checkbox" id="opt-autovalidate"><span data-i18n="Auto validate">Auto validate</span></label>
2372
+ <label><input type="checkbox" id="opt-autosave"><span data-i18n="Auto save">Auto save</span></label>
2373
+ <label><input type="checkbox" id="opt-corr"><span data-i18n="Source mapping">Source mapping</span></label>
2374
+ <label><input type="checkbox" id="opt-git-diff"><span data-i18n="Show Git changes">Show Git changes</span></label>
2375
+ </div></div>
2376
+ </div>
2377
+
2378
+ <div id="cm"></div>
2379
+ </aside>`;
2380
+ return { aside, boot: editorBoot(entry, site) };
2381
+ };
2382
+ /**
2383
+ * Response for `GET /api/page?route=/<ancestor/.../docId>` (status + body). mode === "edit" only.
2384
+ * The diff for doing edit's doc switch via client-side navigation (= keep scroll position /
2385
+ * search term / open state without rebuilding the sidebar DOM); it returns only what the client
2386
+ * replaces: main html + the panel's variable parts (file name / front-matter fields) + the new
2387
+ * doc's boot.
2388
+ * File views replace the central workspace while retaining the current editor.
2389
+ */
2390
+ export const pageDataResponse = (site, path) => {
2391
+ if (typeof path !== "string" || !path.startsWith("/")) {
2392
+ return { status: 400, body: { error: "route must be /<docId>" } };
2393
+ }
2394
+ if (path === "/_git" ||
2395
+ path === "/_statics" ||
2396
+ path.startsWith("/_statics/") ||
2397
+ path === "/_files" ||
2398
+ path.startsWith("/_files/")) {
2399
+ try {
2400
+ const resource = resourcesMain(site, path.startsWith("/_statics")
2401
+ ? path.slice(1)
2402
+ : path === "/_git"
2403
+ ? "_git"
2404
+ : path.slice("/_files".length).replace(/^\/|\/$/g, ""));
2405
+ return resource
2406
+ ? {
2407
+ status: 200,
2408
+ body: {
2409
+ resource: true,
2410
+ path,
2411
+ title: pageTitle("edit", resource.title, site.title),
2412
+ html: docLinks(site, resource.main),
2413
+ sourceFile: resource.sourceFile,
2414
+ },
2415
+ }
2416
+ : { status: 404, body: { error: `${path} is not a file route` } };
2417
+ }
2418
+ catch {
2419
+ return { status: 400, body: { error: "Invalid file route" } };
2420
+ }
2421
+ }
2422
+ const id = resolveDocPath(site.entries, path);
2423
+ const doc = site.entries.find((e) => e.id === id);
2424
+ if (!doc) {
2425
+ return { status: 404, body: { error: `${path} is not a doc route` } };
2426
+ }
2427
+ const html = entryMain(site, doc);
2428
+ const errors = [...site.errors, ...doc.errors];
2429
+ return {
2430
+ status: 200,
2431
+ body: {
2432
+ title: pageTitle("edit", docDisplayName(doc.frontmatter, doc.id), site.title),
2433
+ file: doc.file,
2434
+ html,
2435
+ errors,
2436
+ toc: doc.toc,
2437
+ boot: editorBoot(doc, site),
2438
+ filePreviews: filePreviewResults(doc, site),
2439
+ },
2440
+ };
2441
+ };
2442
+ const readJson = async (req, limit) => {
2443
+ try {
2444
+ const v = JSON.parse(await readBody(req, limit));
2445
+ return typeof v === "object" && v != null ? v : undefined;
2446
+ }
2447
+ catch {
2448
+ return undefined;
2449
+ }
2450
+ };
2451
+ const notFound = (res, path) => {
2452
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
2453
+ res.end(`not found: ${path}`);
2454
+ };
2455
+ const sendJson = (res, code, body) => {
2456
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
2457
+ res.end(JSON.stringify(body));
2458
+ };
2459
+ const sendOp = (res, r) => {
2460
+ if (isOpError(r)) {
2461
+ const { status, ...body } = r;
2462
+ sendJson(res, status, body);
2463
+ }
2464
+ else {
2465
+ sendJson(res, 200, { ok: true, ...r });
2466
+ }
2467
+ };
2468
+ const languageLinks = (html, locale) => html.replace(/href="(\/(?!\/)[^"#?]*)(#[^"]*)?"/g, (_match, path, hash = "") => `href="${path}?lang=${encodeURIComponent(locale)}${hash}"`);
2469
+ const sendPage = (res, mode, site, opts) => {
2470
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
2471
+ const html = docLinks(site, page(mode, site, opts));
2472
+ res.end(site.contentLanguage ? languageLinks(html, site.contentLanguage) : html);
2473
+ };
2474
+ const readBody = (req, limit = 5_000_000) => new Promise((resolve, reject) => {
2475
+ let data = "";
2476
+ req.setEncoding("utf8");
2477
+ req.on("data", (chunk) => {
2478
+ data += chunk;
2479
+ if (data.length > limit) {
2480
+ reject(new Error("body too large"));
2481
+ req.destroy();
2482
+ }
2483
+ });
2484
+ req.on("end", () => resolve(data));
2485
+ req.on("error", reject);
2486
+ });
2487
+ // Preview refresh (/api/render) and save (/api/save) — they differ only in the disk write and
2488
+ // share the response shape (the client can update the left pane straight from a save response).
2489
+ const handleDraft = async (req, res, save, locale) => {
2490
+ let parsedBody;
2491
+ try {
2492
+ parsedBody = JSON.parse(await readBody(req));
2493
+ }
2494
+ catch {
2495
+ sendJson(res, 400, { error: "invalid JSON body" });
2496
+ return;
2497
+ }
2498
+ const { file, frontmatter, body, baseRaw, textfiles } = (parsedBody ?? {});
2499
+ let fileDrafts;
2500
+ try {
2501
+ fileDrafts = previewTextfiles(root, textfiles);
2502
+ }
2503
+ catch (error) {
2504
+ sendJson(res, 400, { error: String(error) });
2505
+ return;
2506
+ }
2507
+ const target = resolveContentFile(file);
2508
+ if (!target) {
2509
+ sendJson(res, 400, { error: "file must be <id>.md" });
2510
+ return;
2511
+ }
2512
+ if (save && !existsSync(target.abs)) {
2513
+ sendJson(res, 404, { error: `${target.name} not found (use + in the nav to create)` });
2514
+ return;
2515
+ }
2516
+ // So that autosave never silently overwrites external changes (direct edits / git pull etc.),
2517
+ // compare the current disk content with the base (the raw the client last read) right before
2518
+ // saving. If they differ, skip the write and return the disk content for the client to load
2519
+ // into the editor (= the md file wins).
2520
+ const site = loadSite(locale);
2521
+ const currentRaw = save
2522
+ ? (site.entries.find((entry) => entry.id === target.id)?.raw ??
2523
+ readFileSync(target.abs, "utf8"))
2524
+ : undefined;
2525
+ if (save && currentRaw !== undefined && isExternallyModified(baseRaw, currentRaw)) {
2526
+ const { raw: _raw, ...doc } = renderDocFile(site, target.name, currentRaw);
2527
+ sendJson(res, 200, { ...doc, saved: false, conflict: true, raw: currentRaw });
2528
+ return;
2529
+ }
2530
+ const fm = sanitizeFrontmatter(frontmatter);
2531
+ const text = typeof body === "string" ? body : "";
2532
+ if (locale !== contentLanguageCode(site.defaultLang))
2533
+ fm.lang = locale;
2534
+ const raw = assembleDoc(fm, text);
2535
+ if (save) {
2536
+ if (locale === contentLanguageCode(site.defaultLang))
2537
+ writeFileSync(target.abs, raw);
2538
+ else {
2539
+ const file = translationFile(sourceDocFile(target.id, readManifest(root).files), locale);
2540
+ const base = existsSync(textfilePath(root, file)) ? (currentRaw ?? null) : null;
2541
+ if (!writeTextfile(root, file, raw, base)) {
2542
+ sendJson(res, 409, { error: "Translation changed. Reload before saving." });
2543
+ return;
2544
+ }
2545
+ }
2546
+ }
2547
+ const draft = renderDraft(loadSite(locale), target.name, raw, fileDrafts);
2548
+ sendJson(res, 200, { ...draft, saved: save, conflict: false, raw });
2549
+ };
2550
+ // Prose lint (/api/lint) = the `lint` of the doc language's adapter (language socket); languages
2551
+ // without one return no findings. Lints the body string (same coordinate system as the CM doc)
2552
+ // and returns findings with offsets — spellchecking is left to the browser (client side).
2553
+ const handleLint = async (req, res) => {
2554
+ let parsedBody;
2555
+ try {
2556
+ parsedBody = JSON.parse(await readBody(req));
2557
+ }
2558
+ catch {
2559
+ sendJson(res, 400, { error: "invalid JSON body" });
2560
+ return;
2561
+ }
2562
+ const { body, lang } = (parsedBody ?? {});
2563
+ const tag = typeof lang === "string" && isLangTag(lang)
2564
+ ? lang
2565
+ : (readManifest(root).defaultLang ?? DEFAULT_LANG);
2566
+ const { lint } = resolveLanguage(tag, siteLanguages.languages);
2567
+ const findings = lint ? await lint(typeof body === "string" ? body : "") : [];
2568
+ sendJson(res, 200, { findings });
2569
+ };
2570
+ // Create a new doc; the sidebar refresh keeps the current document open.
2571
+ const handleCreate = async (req, res) => {
2572
+ const body = await readJson(req);
2573
+ const name = typeof body?.name === "string" ? body.name.trim() : "";
2574
+ const title = typeof body?.title === "string" ? body.title.trim() : "";
2575
+ const parent = typeof body?.parent === "string" && body.parent !== "" ? body.parent : undefined;
2576
+ const result = ops.addFile(name, title, parent);
2577
+ sendOp(res, isOpError(result) ? result : { ...result, path: docPath(loadSite().entries, result.id) });
2578
+ };
2579
+ // Add a figure (sidebar file picker / drag-and-drop). Returns the written file name
2580
+ // (the client inserts ![](figures/<file>)).
2581
+ const handleFigure = async (req, res) => {
2582
+ const body = await readJson(req);
2583
+ if (typeof body?.name !== "string" || typeof body?.data !== "string") {
2584
+ sendJson(res, 400, { error: "specify an svg / png / webp / jpeg / html file" });
2585
+ return;
2586
+ }
2587
+ const result = ops.addFigure(body.name, Buffer.from(body.data, "base64"));
2588
+ sendOp(res, isOpError(result) ? result : { ...result, path: result.path ?? `figures/${result.file}` });
2589
+ };
2590
+ // Doc icon (the header's icon slot). data = base64 of an svg (null = remove). Pads it to 1:1 and
2591
+ // writes icons/<id>.svg. Response icon = the new data URI (null if removed).
2592
+ const handleIcon = async (req, res) => {
2593
+ const body = await readJson(req);
2594
+ const target = resolveContentFile(body?.file);
2595
+ if (!target || !existsSync(target.abs)) {
2596
+ sendJson(res, 404, { error: "target doc not found" });
2597
+ return;
2598
+ }
2599
+ const manifest = readManifest(root);
2600
+ if (manifest.errors.length) {
2601
+ sendJson(res, 409, { error: manifest.errors.join("\n") });
2602
+ return;
2603
+ }
2604
+ const previous = docIconFile(target.id, manifest.files);
2605
+ const file = manifest.files?.defaultLayout !== false &&
2606
+ !Object.hasOwn(manifest.files?.icons ?? {}, target.id) &&
2607
+ !existsSync(textfilePath(root, previous))
2608
+ ? defaultFilePath(`${target.id}-icon.svg`)
2609
+ : previous;
2610
+ const path = textfilePath(root, file);
2611
+ if (body?.data == null) {
2612
+ if (existsSync(path)) {
2613
+ unlinkSync(path);
2614
+ }
2615
+ sendJson(res, 200, { ok: true, icon: null });
2616
+ return;
2617
+ }
2618
+ const svg = typeof body.data === "string"
2619
+ ? squareSvg(Buffer.from(body.data, "base64").toString("utf8"))
2620
+ : undefined;
2621
+ if (!svg) {
2622
+ sendJson(res, 400, { error: "specify an svg (viewBox or width / height required)" });
2623
+ return;
2624
+ }
2625
+ if (file !== previous && existsSync(path)) {
2626
+ sendJson(res, 409, { error: "File already exists" });
2627
+ return;
2628
+ }
2629
+ mkdirSync(dirname(path), { recursive: true });
2630
+ writeFileSync(path, svg);
2631
+ if (file !== previous) {
2632
+ manifest.files ??= parseFileLayout({});
2633
+ manifest.files.icons ??= {};
2634
+ manifest.files.icons[target.id] = file;
2635
+ writeManifest(root, manifest.tree, manifest);
2636
+ }
2637
+ sendJson(res, 200, { ok: true, icon: svgDataUri(svg) });
2638
+ };
2639
+ // Sidebar DnD move (reorder + reparent). Rewrites only dooc.yaml (file / id unchanged).
2640
+ const handleMove = async (req, res) => {
2641
+ const body = await readJson(req);
2642
+ const { id, parent, index } = body ?? {};
2643
+ if (typeof id !== "string" ||
2644
+ (parent !== undefined && typeof parent !== "string") ||
2645
+ typeof index !== "number") {
2646
+ sendJson(res, 400, { error: "id / parent? / index required" });
2647
+ return;
2648
+ }
2649
+ sendOp(res, ops.move(id, parent, index));
2650
+ };
2651
+ // Rename the source file, update its references, and preserve the document's URL.
2652
+ const handleRename = async (req, res) => {
2653
+ const body = await readJson(req);
2654
+ const target = resolveContentFile(body?.file);
2655
+ if (!target) {
2656
+ sendJson(res, 404, { error: "target doc not found" });
2657
+ return;
2658
+ }
2659
+ try {
2660
+ const snapshot = filesSnapshot(root);
2661
+ const file = sourceDocFile(target.id, snapshot.layout);
2662
+ if (body?.sourceFile !== file)
2663
+ throw new Error("Files changed. Reload before retrying.");
2664
+ const result = updateFiles(root, {
2665
+ action: "rename",
2666
+ file,
2667
+ name: typeof body?.name === "string" ? body.name.trim() : "",
2668
+ revision: snapshot.revision,
2669
+ });
2670
+ sendOp(res, {
2671
+ path: docPath(loadSite().entries, target.id),
2672
+ sourceFile: sourceDocFile(target.id, result.layout),
2673
+ });
2674
+ }
2675
+ catch (error) {
2676
+ sendJson(res, 409, { error: error instanceof Error ? error.message : String(error) });
2677
+ }
2678
+ };
2679
+ // Delete a doc. If it's referenced, refuse without force and return the referrers (the client
2680
+ // confirms and resends with force). Response parent = where to navigate after deletion.
2681
+ const handleDelete = async (req, res) => {
2682
+ const body = await readJson(req);
2683
+ const target = resolveContentFile(body?.file);
2684
+ if (!target) {
2685
+ sendJson(res, 404, { error: "target doc not found" });
2686
+ return;
2687
+ }
2688
+ const result = ops.remove(target.id, body?.force === true);
2689
+ sendOp(res, isOpError(result)
2690
+ ? result
2691
+ : {
2692
+ ...result,
2693
+ path: result.parent ? docPath(loadSite().entries, result.parent) : "/",
2694
+ });
2695
+ };
2696
+ export const createBundleCache = () => {
2697
+ const store = new Map();
2698
+ return {
2699
+ get: async (entry, build) => {
2700
+ const hit = store.get(entry);
2701
+ if (hit) {
2702
+ return hit;
2703
+ }
2704
+ const js = await build();
2705
+ const etag = `"${createHash("sha256").update(js).digest("hex")}"`;
2706
+ const value = { js, etag };
2707
+ store.set(entry, value);
2708
+ return value;
2709
+ },
2710
+ clear: () => store.clear(),
2711
+ };
2712
+ };
2713
+ // The npm package (running as dist/*.js) has no src/, so read the prebuilt bundles.
2714
+ const PREBUILT = {
2715
+ "core/schema-graph-global.tsx": "flow.global.js",
2716
+ "viewer/edit-client.ts": "edit.global.js",
2717
+ "viewer/ui-client.ts": "ui.global.js",
2718
+ "viewer/code-highlight.ts": "code.global.js",
2719
+ };
2720
+ /** Client bundle (src/<entry> as an IIFE). Sourcemap for the server, minified for publish. */
2721
+ export const bundleClient = async (entry, publish = false) => {
2722
+ const prebuilt = PREBUILT[entry];
2723
+ if (usePrebuiltAssets && prebuilt) {
2724
+ return readViewerAsset(prebuilt).toString("utf8");
2725
+ }
2726
+ const esbuild = await import("esbuild");
2727
+ const out = await esbuild.build({
2728
+ entryPoints: [join(pkgRoot, "src", entry)],
2729
+ bundle: true,
2730
+ format: "iife",
2731
+ target: "es2022",
2732
+ write: false,
2733
+ ...(publish ? { minify: true } : { sourcemap: "inline" }),
2734
+ });
2735
+ return out.outputFiles?.[0]?.text ?? "";
2736
+ };
2737
+ export const startServer = async (mode, port = 5179, pluginModule) => {
2738
+ // Language adapters are read once; the manifest is re-read by the compiler.
2739
+ siteLanguages = await loadSiteLanguages(root);
2740
+ const viewerPlugins = await loadViewerPlugins(pluginModule);
2741
+ workspaceSource = viewerPlugins.workspace;
2742
+ let gitOrigin = viewerPlugins.gitOrigin;
2743
+ const handleGit = createGitHandler(root, () => gitOrigin);
2744
+ const handlePlugins = createPluginHandler(viewerPlugins.plugins, viewerPlugins);
2745
+ // Client bundles (bundleClient) = edit's editing UI and the schema graph interaction layer
2746
+ // shared by preview/edit. React Flow's stylesheet isn't mixed into the bundle — the mount
2747
+ // layer (flow-css.ts) injects it into a <style> at runtime, so the page needs no CSS delivery
2748
+ // path (same conditions as the published output).
2749
+ // Cache the bundle result + etag per entry (graph.js / edit.js) — this path isn't hit by the
2750
+ // per-keystroke /api/render / /api/save, but tsx watch doesn't restart for these, so
2751
+ // invalidate explicitly on the watcher's "client" notification (changes to edit-client.ts /
2752
+ // the schema graph interaction layer's src/*.tsx).
2753
+ const bundleCache = createBundleCache();
2754
+ // HMR — bootId changes on every process start. It's how the client detects a tsx watch
2755
+ // restart.
2756
+ const hub = createHmrHub(`${process.pid}-${Date.now()}`);
2757
+ const viewer = {
2758
+ register: handlePlugins.register,
2759
+ unregister: handlePlugins.unregister,
2760
+ setWorkspace(workspace) {
2761
+ workspaceSource = workspace === undefined ? undefined : parseWorkspaceSource(workspace);
2762
+ hub.broadcast("content", []);
2763
+ },
2764
+ setGitOrigin(origin) {
2765
+ gitOrigin = origin === undefined ? undefined : validateGitOrigin(origin);
2766
+ },
2767
+ setAllowedDomains(domains) {
2768
+ if (!Array.isArray(domains))
2769
+ throw new Error("Allowed domains must be a list");
2770
+ const previews = parsePreviewSettings({ domains });
2771
+ applicationPreviews = previews;
2772
+ compiler = createCompiler(root, { previews });
2773
+ localeCompilers.clear();
2774
+ hub.broadcast("content", []);
2775
+ },
2776
+ };
2777
+ viewer.setAllowedDomains(viewerPlugins.allowedDomains ?? []);
2778
+ await viewerPlugins.setup?.(viewer);
2779
+ // DOCS_HMR=0 = no hot reload (nothing is watched = changes need a manual reload).
2780
+ if (process.env.DOCS_HMR !== "0") {
2781
+ const watcher = watchProject(root, pkgRoot, (kind, files) => {
2782
+ console.log(`hmr: ${kind} (${files.join(", ")})`);
2783
+ if (kind === "client") {
2784
+ bundleCache.clear();
2785
+ }
2786
+ hub.broadcast(kind, files);
2787
+ });
2788
+ watchTextfiles = watcher.watchTextfiles;
2789
+ process.once("exit", watcher.close);
2790
+ }
2791
+ const handle = async (req, res) => {
2792
+ if (await handlePlugins(req, res))
2793
+ return;
2794
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
2795
+ const requestedLanguage = req.headers["x-docs-language"] ??
2796
+ requestUrl.searchParams.get("lang") ??
2797
+ contentLanguageCode(readManifest(root).defaultLang ?? DEFAULT_LANG);
2798
+ if (typeof requestedLanguage !== "string" || !isContentLanguage(requestedLanguage)) {
2799
+ sendJson(res, 400, { error: "Expected an ISO 639-3 language code" });
2800
+ return;
2801
+ }
2802
+ const locale = requestedLanguage;
2803
+ const path = decodeURIComponent(requestUrl.pathname);
2804
+ if (req.method === "GET" && path.startsWith("/assets/fonts/")) {
2805
+ const font = readFont(path.slice("/assets/fonts/".length));
2806
+ if (!font) {
2807
+ notFound(res, path);
2808
+ return;
2809
+ }
2810
+ res.writeHead(200, {
2811
+ "content-type": "font/woff2",
2812
+ "cache-control": "public, max-age=86400",
2813
+ });
2814
+ res.end(font);
2815
+ return;
2816
+ }
2817
+ // No favicon (this only silences the 404 console error from the browser's auto-request).
2818
+ if (path === "/favicon.ico") {
2819
+ res.writeHead(204);
2820
+ res.end();
2821
+ return;
2822
+ }
2823
+ if (req.method === "GET" && path === HMR_PATH) {
2824
+ hub.handle(res);
2825
+ return;
2826
+ }
2827
+ // Client bundles — graph.js (the schema graph interaction layer) is shared by
2828
+ // preview / edit, edit.js is for the editing UI only.
2829
+ const clientEntry = path === "/assets/code.js"
2830
+ ? "viewer/code-highlight.ts"
2831
+ : path === "/assets/graph.js"
2832
+ ? "core/schema-graph-global.tsx"
2833
+ : mode === "edit" && path === "/assets/edit.js"
2834
+ ? "viewer/edit-client.ts"
2835
+ : path === "/assets/ui.js"
2836
+ ? "viewer/ui-client.ts"
2837
+ : undefined;
2838
+ if (req.method === "GET" && clientEntry != null) {
2839
+ const { js, etag } = await bundleCache.get(clientEntry, () => bundleClient(clientEntry));
2840
+ if (req.headers["if-none-match"] === etag) {
2841
+ res.writeHead(304, { etag, "cache-control": "no-cache" });
2842
+ res.end();
2843
+ return;
2844
+ }
2845
+ res.writeHead(200, {
2846
+ "content-type": "application/javascript; charset=utf-8",
2847
+ etag,
2848
+ "cache-control": "no-cache",
2849
+ });
2850
+ res.end(js);
2851
+ return;
2852
+ }
2853
+ if (mode === "edit" && req.method === "POST") {
2854
+ if (path === "/api/git") {
2855
+ res.setHeader("cache-control", "no-store");
2856
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"]) ||
2857
+ (req.headers.host !== `localhost:${req.socket.localPort}` &&
2858
+ req.headers.host !== `127.0.0.1:${req.socket.localPort}` &&
2859
+ req.headers.host !== `[::1]:${req.socket.localPort}`)) {
2860
+ sendJson(res, 403, {
2861
+ error: "Git requires a same-origin local editor request",
2862
+ });
2863
+ return;
2864
+ }
2865
+ try {
2866
+ if ((workspaceSource ?? readWorkspaceSource(root)).protocol !== "git")
2867
+ throw new Error("Git workspace is not available");
2868
+ const input = await readJson(req, 1_048_576);
2869
+ const result = await handleGit(input);
2870
+ if (workspaceSource?.protocol === "git" &&
2871
+ (input?.action === "switch" || input?.action === "branch")) {
2872
+ const local = readWorkspaceSource(root);
2873
+ if (local.protocol === "git")
2874
+ workspaceSource = { ...workspaceSource, branch: local.branch };
2875
+ }
2876
+ sendJson(res, 200, result);
2877
+ }
2878
+ catch (error) {
2879
+ sendJson(res, 409, {
2880
+ error: error instanceof Error ? error.message : "Git operation failed",
2881
+ });
2882
+ }
2883
+ return;
2884
+ }
2885
+ if (path === "/api/files") {
2886
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"])) {
2887
+ sendJson(res, 403, {
2888
+ error: "Files require a same-origin local editor request",
2889
+ });
2890
+ return;
2891
+ }
2892
+ try {
2893
+ const input = await readJson(req, Math.ceil((MAX_FILE_UPLOAD_BYTES * 4) / 3) + 16_384);
2894
+ if (!input)
2895
+ throw new Error("Invalid file request");
2896
+ const result = input.action === "read"
2897
+ ? filesSnapshot(root)
2898
+ : input.action === "upload"
2899
+ ? uploadFile(root, input)
2900
+ : updateFiles(root, input);
2901
+ sendJson(res, 200, result);
2902
+ }
2903
+ catch (error) {
2904
+ sendJson(res, 409, {
2905
+ error: error instanceof Error ? error.message : String(error),
2906
+ });
2907
+ }
2908
+ return;
2909
+ }
2910
+ if (path === "/api/code-file") {
2911
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"])) {
2912
+ sendJson(res, 403, {
2913
+ error: "Code tools require a same-origin local editor request",
2914
+ });
2915
+ return;
2916
+ }
2917
+ const result = codeFileResponse(root, await readJson(req), applicationPreviews);
2918
+ sendJson(res, result.status, result.body);
2919
+ return;
2920
+ }
2921
+ if (path === "/api/textfile") {
2922
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"])) {
2923
+ sendJson(res, 403, {
2924
+ error: "Text files require a same-origin local editor request",
2925
+ });
2926
+ return;
2927
+ }
2928
+ const input = await readJson(req);
2929
+ if (input?.action === "preview") {
2930
+ try {
2931
+ if (typeof input.file !== "string")
2932
+ throw new Error("Expected a file path");
2933
+ const files = previewTextfiles(root, {
2934
+ [input.file]: input.content,
2935
+ });
2936
+ const [file, content] = [...files][0];
2937
+ sendJson(res, 200, {
2938
+ html: textfilePreviewHtml(loadSite(locale), file, content),
2939
+ });
2940
+ }
2941
+ catch (error) {
2942
+ sendJson(res, 400, { error: String(error) });
2943
+ }
2944
+ return;
2945
+ }
2946
+ if (input?.action === "validate") {
2947
+ try {
2948
+ if (typeof input.file !== "string" ||
2949
+ typeof input.content !== "string" ||
2950
+ isRemoteTextfile(input.file))
2951
+ throw new Error("Invalid text file tool request");
2952
+ previewTextfiles(root, { [input.file]: input.content });
2953
+ const name = textfileName(input.file);
2954
+ const extension = (name.split(".").at(-1) || name).toLowerCase();
2955
+ const grammar = siteLanguages.codeGrammars?.find((entry) => entry.name === extension || entry.aliases?.includes(extension));
2956
+ const language = existsSync(join(root, ".dooc/editor-tools", `${extension}.json`))
2957
+ ? extension
2958
+ : (grammar?.name ?? extension);
2959
+ const result = await validateTextfileCode(root, language, input.content);
2960
+ for (const message of textfilePreviewErrors(loadSite(locale), input.file, result.content))
2961
+ result.findings.push({
2962
+ message,
2963
+ range: [0, result.content.length],
2964
+ ruleId: "preview",
2965
+ });
2966
+ sendJson(res, 200, result);
2967
+ }
2968
+ catch (error) {
2969
+ sendJson(res, 400, {
2970
+ error: error instanceof Error ? error.message : String(error),
2971
+ });
2972
+ }
2973
+ return;
2974
+ }
2975
+ const result = await textfileResponse(root, input, applicationPreviews.domains);
2976
+ if (result.status === 200 &&
2977
+ typeof input?.file === "string" &&
2978
+ !isRemoteTextfile(input.file)) {
2979
+ result.body.gitBase =
2980
+ (workspaceSource ?? readWorkspaceSource(root)).protocol === "git"
2981
+ ? gitBaseText(root, input.file)
2982
+ : null;
2983
+ }
2984
+ sendJson(res, result.status, result.body);
2985
+ return;
2986
+ }
2987
+ if (path === "/api/render" || path === "/api/save") {
2988
+ await handleDraft(req, res, path === "/api/save", locale);
2989
+ return;
2990
+ }
2991
+ if (path === "/api/translation") {
2992
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"])) {
2993
+ sendJson(res, 403, {
2994
+ error: "Translations require a same-origin local editor request",
2995
+ });
2996
+ return;
2997
+ }
2998
+ const input = await readJson(req);
2999
+ const target = resolveContentFile(input?.file);
3000
+ if (!target ||
3001
+ typeof input?.language !== "string" ||
3002
+ typeof input.added !== "boolean") {
3003
+ sendJson(res, 400, { error: "file / language / added required" });
3004
+ return;
3005
+ }
3006
+ try {
3007
+ setDocTranslation(root, contentLanguageCode(readManifest(root).defaultLang ?? DEFAULT_LANG), target.id, input.language, input.added);
3008
+ sendJson(res, 200, {
3009
+ translatedLanguages: loadSite().entries.find((entry) => entry.id === target.id)?.translatedLanguages,
3010
+ });
3011
+ }
3012
+ catch (error) {
3013
+ sendJson(res, 400, { error: String(error) });
3014
+ }
3015
+ return;
3016
+ }
3017
+ if (path === "/api/code-tool") {
3018
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"])) {
3019
+ sendJson(res, 403, {
3020
+ error: "Code tools require a same-origin local editor request",
3021
+ });
3022
+ return;
3023
+ }
3024
+ const input = await readJson(req);
3025
+ const target = resolveContentFile(input?.file);
3026
+ if (!target ||
3027
+ !existsSync(target.abs) ||
3028
+ typeof input?.body !== "string" ||
3029
+ (input.action !== "validate" && typeof input.position !== "number") ||
3030
+ (input.action !== "format" &&
3031
+ input.action !== "lint" &&
3032
+ input.action !== "validate")) {
3033
+ sendJson(res, 400, { error: "Invalid code tool request" });
3034
+ return;
3035
+ }
3036
+ try {
3037
+ sendJson(res, 200, input.action === "validate"
3038
+ ? await validateCodeTools(root, input.body)
3039
+ : await runCodeTool(root, input.body, Number(input.position), input.action));
3040
+ }
3041
+ catch (error) {
3042
+ sendJson(res, 400, {
3043
+ error: error instanceof Error ? error.message : String(error),
3044
+ });
3045
+ }
3046
+ return;
3047
+ }
3048
+ if (path === "/api/lint") {
3049
+ await handleLint(req, res);
3050
+ return;
3051
+ }
3052
+ if (path === "/api/create") {
3053
+ await handleCreate(req, res);
3054
+ return;
3055
+ }
3056
+ if (path === "/api/icon") {
3057
+ await handleIcon(req, res);
3058
+ return;
3059
+ }
3060
+ if (path === "/api/site-icon") {
3061
+ if (!isLocalToolRequest(req.headers.host ?? "", req.headers.origin, req.headers["content-type"])) {
3062
+ sendJson(res, 403, {
3063
+ error: "Site icons require a same-origin local editor request",
3064
+ });
3065
+ return;
3066
+ }
3067
+ const result = siteIconResponse(root, await readJson(req));
3068
+ sendJson(res, result.status, result.body);
3069
+ return;
3070
+ }
3071
+ if (path === "/api/figure") {
3072
+ await handleFigure(req, res);
3073
+ return;
3074
+ }
3075
+ if (path === "/api/move") {
3076
+ await handleMove(req, res);
3077
+ return;
3078
+ }
3079
+ if (path === "/api/rename") {
3080
+ await handleRename(req, res);
3081
+ return;
3082
+ }
3083
+ if (path === "/api/delete") {
3084
+ await handleDelete(req, res);
3085
+ return;
3086
+ }
3087
+ }
3088
+ const site = loadSite(locale);
3089
+ if (req.method === "GET" && path === "/api/search") {
3090
+ sendJson(res, 200, searchIndex(site));
3091
+ return;
3092
+ }
3093
+ // edit-mode-only APIs (used by edit-client.ts). handle() strips the query from path, so
3094
+ // the query is read from req.url via URL/URLSearchParams.
3095
+ if (mode === "edit" && req.method === "GET" && path === "/api/nav") {
3096
+ const params = new URL(req.url ?? "/", "http://localhost").searchParams;
3097
+ sendJson(res, 200, {
3098
+ siteTitle: site.title,
3099
+ siteIcon: site.icon ? svgDataUri(site.icon) : undefined,
3100
+ translatedLanguages: site.entries.find((entry) => entry.id === requestUrl.searchParams.get("current"))?.translatedLanguages,
3101
+ html: navGroupsHtml(mode, site, params.get("current") || undefined, params.get("res") ?? undefined),
3102
+ });
3103
+ return;
3104
+ }
3105
+ if (mode === "edit" && req.method === "GET" && path === "/api/page") {
3106
+ const params = new URL(req.url ?? "/", "http://localhost").searchParams;
3107
+ const { status, body } = pageDataResponse(site, params.get("route"));
3108
+ sendJson(res, status, body);
3109
+ return;
3110
+ }
3111
+ if (mode === "edit" && req.method === "GET" && path === "/api/doc") {
3112
+ const params = new URL(req.url ?? "/", "http://localhost").searchParams;
3113
+ const { status, body } = docQueryResponse(site, params.get("file"));
3114
+ sendJson(res, status, body);
3115
+ return;
3116
+ }
3117
+ // "/" redirects to the first doc (the canonical URL follows the manifest hierarchy). In edit with 0
3118
+ // docs, an empty page (the first doc is created via "add doc" at the end of the sidebar).
3119
+ if (path === "/") {
3120
+ const first = site.entries[0];
3121
+ if (first) {
3122
+ res.writeHead(302, {
3123
+ location: `${docPath(site.entries, first.id)}?lang=${encodeURIComponent(locale)}`,
3124
+ });
3125
+ res.end();
3126
+ return;
3127
+ }
3128
+ if (mode === "edit") {
3129
+ sendPage(res, mode, site, {
3130
+ title: "docs",
3131
+ main: "<article></article>",
3132
+ });
3133
+ return;
3134
+ }
3135
+ notFound(res, path);
3136
+ return;
3137
+ }
3138
+ const linkedFigure = path.startsWith("/_files/file/")
3139
+ ? [...site.ctx.figures.values()].find((fig) => fig.file === path.slice("/_files/file/".length))
3140
+ : undefined;
3141
+ if (mode === "preview" && linkedFigure) {
3142
+ sendPage(res, mode, site, figurePage(site, linkedFigure));
3143
+ return;
3144
+ }
3145
+ if ((mode === "edit" &&
3146
+ (path === "/_git" ||
3147
+ path === "/_statics" ||
3148
+ path.startsWith("/_statics/") ||
3149
+ path === "/_files" ||
3150
+ path.startsWith("/_files/"))) ||
3151
+ path === "/_files/terms") {
3152
+ const rest = path.startsWith("/_statics")
3153
+ ? path.slice(1)
3154
+ : path === "/_git"
3155
+ ? "_git"
3156
+ : requestUrl.pathname.slice("/_files".length).replace(/^\/|\/$/g, "");
3157
+ const page = resourcesMain(site, rest);
3158
+ if (!page) {
3159
+ notFound(res, path);
3160
+ return;
3161
+ }
3162
+ const editor = mode === "edit"
3163
+ ? (site.entries.find((entry) => entry.id === requestUrl.searchParams.get("editor")) ?? site.entries[0])
3164
+ : undefined;
3165
+ if (editor) {
3166
+ const { aside, boot } = editorAside(editor, site);
3167
+ sendPage(res, mode, site, {
3168
+ title: page.title,
3169
+ main: entryMain(site, editor),
3170
+ currentId: editor.id,
3171
+ currentRes: rest,
3172
+ aside,
3173
+ boot: {
3174
+ ...boot,
3175
+ resource: {
3176
+ path,
3177
+ title: pageTitle(mode, page.title, site.title),
3178
+ html: docLinks(site, page.main),
3179
+ sourceFile: page.sourceFile,
3180
+ },
3181
+ },
3182
+ });
3183
+ return;
3184
+ }
3185
+ sendPage(res, mode, site, { ...page, currentRes: rest });
3186
+ return;
3187
+ }
3188
+ const id = resolveDocPath(site.entries, path);
3189
+ const doc = site.entries.find((e) => e.id === id);
3190
+ if (!doc) {
3191
+ notFound(res, path);
3192
+ return;
3193
+ }
3194
+ const opts = {
3195
+ title: docDisplayName(doc.frontmatter, doc.id),
3196
+ main: entryMain(site, doc),
3197
+ currentId: doc.id,
3198
+ };
3199
+ if (mode === "edit") {
3200
+ Object.assign(opts, editorAside(doc, site));
3201
+ }
3202
+ sendPage(res, mode, site, opts);
3203
+ };
3204
+ const server = createServer((req, res) => {
3205
+ handle(req, res).catch((e) => {
3206
+ if (!res.headersSent) {
3207
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
3208
+ }
3209
+ res.end(`error: ${e instanceof Error ? e.message : e}`);
3210
+ });
3211
+ });
3212
+ // If the port is in use (e.g. a previous instance left running), automatically try +1, up to
3213
+ // 10 ports.
3214
+ // The URL is printed once, on "listening" (no callbacks left over from failed listens).
3215
+ server.once("listening", () => {
3216
+ const addr = server.address();
3217
+ const p = typeof addr === "object" && addr ? addr.port : port;
3218
+ console.log(`dooc ${mode} (${root}): http://localhost:${p}/`);
3219
+ });
3220
+ const listen = (p, left) => {
3221
+ server.once("error", (err) => {
3222
+ if (err.code === "EADDRINUSE" && left > 0) {
3223
+ console.log(`port ${p} in use → trying ${p + 1}`);
3224
+ listen(p + 1, left - 1);
3225
+ return;
3226
+ }
3227
+ throw err;
3228
+ });
3229
+ server.listen(p, "127.0.0.1");
3230
+ };
3231
+ listen(port, 10);
3232
+ return viewer;
3233
+ };