docnest 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ npx docnest serve
20
20
 
21
21
  ## 配置
22
22
 
23
- 复制 `docnest.config.example.mjs` 为项目根目录的 `docnest.config.mjs`,配置项目自己的标题、目录顺序、存储键前缀和 PDF 水印。
23
+ 复制 `docnest.config.example.mjs` 为项目根目录的 `docnest.config.mjs`,配置项目自己的标题、目录顺序、存储键前缀和阅读限制策略。
24
24
 
25
25
  ### 外观主题
26
26
 
@@ -56,7 +56,18 @@ export default defineConfig({
56
56
 
57
57
  未配置 `appearance` 时使用 `slate-modern + auto`。旧配置中的 `current-docs` 会自动迁移到 `slate-modern`,正文视觉不会发生变化;旧版保存的 `markdown-theme` 明暗偏好也会自动迁移到新的项目级存储键。
58
58
 
59
- 水印默认关闭。开启后,PDF 导出弹窗会读取配置文字,并允许本次导出临时修改;临时修改不会写回文件或浏览器存储。
59
+ PDF、页面水印和图表外带能力由一个开关统一控制,默认使用普通阅读模式:
60
+
61
+ ```js
62
+ export default defineConfig({
63
+ restrictedMode: false,
64
+ })
65
+ ```
66
+
67
+ - `restrictedMode: false`:显示打印和 PDF 导出,允许 Mermaid 图下载与大图查看。导出 PDF 时会弹出本次水印设置,可选择是否添加并自定义文案;默认文案为 `site.title`,不会写回配置。
68
+ - `restrictedMode: true`:隐藏打印和 PDF 导出入口且不加载导出模块;使用 `site.title` 作为固定水印,禁止文字选择、复制、剪切和拖拽,并隐藏 Mermaid 下载与大图查看入口。Mermaid 仍会在正文中正常渲染。
69
+
70
+ 受限模式只移除 DocNest 提供的页面入口和脚本,不承诺对浏览器开发者工具或系统级截图提供绝对防护。
60
71
 
61
72
  ### 极简单口令授权
62
73
 
@@ -67,6 +78,8 @@ export default defineConfig({
67
78
  auth: {
68
79
  enabled: true,
69
80
  passphrase: '请替换为项目自己的长口令',
81
+ // 服务通过反向代理挂载在子路径时填写,例如 /documents;默认跳转到 /。
82
+ redirectPrefix: '',
70
83
  stateFile: '.docnest/auth.json',
71
84
  localStorageKey: 'my-project:auth-passphrase',
72
85
  sessionTtlMinutes: 24 * 60,
@@ -80,6 +93,8 @@ export default defineConfig({
80
93
  `localStorageKey` 可选,用于为不同宿主项目隔离浏览器存储键。内置授权不提供“修改口令”或“退出”按钮;需要更换口令时,停止服务、删除 `stateFile`,修改配置中的 `passphrase` 后重新启动。
81
94
  建议把 `.docnest/` 加入宿主项目的 `.gitignore`,并为口令状态文件设置仅当前用户可读写的权限。
82
95
 
96
+ 如果 DocNest 由反向代理挂载在 `/documents/` 等子路径下,将 `auth.redirectPrefix` 配置为 `/documents`。登录页会把原始文档目标写成带此前缀的路径,口令登录后的服务端跳转也会使用同一前缀;不配置时保持跳转到 `/` 的默认行为。
97
+
83
98
  授权开启但既没有状态文件,也没有初始 `passphrase` 时,DocNest 会拒绝启动,避免意外以空口令开放文档。
84
99
 
85
100
  如需显式指定端口、文档目录或监听地址:
@@ -125,3 +140,16 @@ npm run pack:check
125
140
  ```
126
141
 
127
142
  最低支持 Node.js 20,验证 Node.js 20 和 22。
143
+
144
+ ## 发布规范
145
+
146
+ DocNest 的 GitHub Release 和 npm 发布统一由 GitHub Actions 完成,不在本地直接执行 `npm publish`。
147
+
148
+ 发布新版本时:
149
+
150
+ 1. 更新 `package.json` 和 `package-lock.json` 的版本号,并运行 `npm test` 与 `npm run pack:check`。
151
+ 2. 将版本提交推送到 `main`。
152
+ 3. 在 `main` 上创建与包版本一致的标签,例如 `v0.1.13`。
153
+ 4. 推送该标签:`git push origin v0.1.13`。
154
+
155
+ 标签推送后,`.github/workflows/publish.yml` 会校验标签与包版本一致,使用 npm OIDC 发布公开包,并自动创建名为 `DocNest v0.1.13` 的 GitHub Release。远程仓库只保留 `main` 分支,发布标签不需要推送临时开发分支。
package/dist/cli.js CHANGED
@@ -62,8 +62,7 @@ function exposeConfig(config) {
62
62
  process.env.DOCS_THEME_DEFAULT = config.appearance.defaultTheme;
63
63
  process.env.DOCS_THEME_MODE_DEFAULT = config.appearance.defaultMode;
64
64
  process.env.DOCS_THEME_ENABLED = JSON.stringify(config.appearance.enabledThemes);
65
- process.env.DOCS_WATERMARK_ENABLED = String(config.export.watermark.enabled);
66
- process.env.DOCS_WATERMARK_TEXT = config.export.watermark.text;
65
+ process.env.DOCS_RESTRICTED_MODE = String(config.restrictedMode);
67
66
  process.env.DOCS_HOST = config.server.host;
68
67
  process.env.DOCS_PORT_START = String(config.server.port);
69
68
  process.env.DOCS_WATCH_ENABLED = String(config.server.watch);
package/dist/config.d.ts CHANGED
@@ -28,16 +28,20 @@ export type DocNestConfig = {
28
28
  auth?: {
29
29
  enabled?: boolean;
30
30
  passphrase?: string;
31
+ /**
32
+ * Prefix to add to authentication return targets when the service is
33
+ * mounted below a reverse-proxy path, for example `/documents`.
34
+ */
35
+ redirectPrefix?: string;
31
36
  stateFile?: string;
32
37
  localStorageKey?: string;
33
38
  sessionTtlMinutes?: number;
34
39
  };
35
- export?: {
36
- watermark?: {
37
- enabled?: boolean;
38
- text?: string;
39
- };
40
- };
40
+ /**
41
+ * Restrict the reading experience to in-browser viewing only.
42
+ * Restricted mode also enables the fixed site-title watermark.
43
+ */
44
+ restrictedMode?: boolean;
41
45
  };
42
46
  export declare function defineConfig(config: DocNestConfig): DocNestConfig;
43
47
  export type ResolvedDocNestConfig = Required<DocNestConfig> & {
@@ -50,13 +54,12 @@ export type ResolvedDocNestConfig = Required<DocNestConfig> & {
50
54
  auth: {
51
55
  enabled: boolean;
52
56
  passphrase: string;
57
+ redirectPrefix: string;
53
58
  stateFile: string;
54
59
  localStorageKey: string;
55
60
  sessionTtlMinutes: number;
56
61
  };
57
- export: {
58
- watermark: Required<NonNullable<NonNullable<DocNestConfig['export']>['watermark']>>;
59
- };
62
+ restrictedMode: boolean;
60
63
  };
61
64
  export declare function loadConfig(projectRoot?: string): Promise<ResolvedDocNestConfig>;
62
65
  export {};
package/dist/config.js CHANGED
@@ -45,6 +45,20 @@ function normalizePositiveNumber(value, fallback) {
45
45
  const normalized = Number(value);
46
46
  return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback;
47
47
  }
48
+ function normalizeRedirectPrefix(value) {
49
+ if (typeof value !== 'string')
50
+ return '';
51
+ const normalized = value.trim();
52
+ if (!normalized ||
53
+ normalized === '/' ||
54
+ !normalized.startsWith('/') ||
55
+ normalized.startsWith('//') ||
56
+ normalized.includes('//') ||
57
+ normalized.includes('\\') ||
58
+ /[\r\n?#]/.test(normalized))
59
+ return '';
60
+ return normalized.replace(/\/+$/, '') || '';
61
+ }
48
62
  function normalizeThemeName(value) {
49
63
  if (value === 'current-docs')
50
64
  return 'slate-modern';
@@ -87,7 +101,6 @@ async function loadConfig(projectRoot = process.cwd()) {
87
101
  const appearance = userConfig.appearance ?? {};
88
102
  const server = userConfig.server ?? {};
89
103
  const auth = userConfig.auth ?? {};
90
- const watermark = userConfig.export?.watermark ?? {};
91
104
  const storageKeyPrefix = site.storageKeyPrefix?.trim() || `docnest:${projectName}`;
92
105
  const authLocalStorageKey = auth.localStorageKey?.trim() || `${storageKeyPrefix}:auth-passphrase`;
93
106
  return {
@@ -114,15 +127,11 @@ async function loadConfig(projectRoot = process.cwd()) {
114
127
  auth: {
115
128
  enabled: auth.enabled ?? false,
116
129
  passphrase: typeof auth.passphrase === 'string' ? auth.passphrase : '',
130
+ redirectPrefix: normalizeRedirectPrefix(auth.redirectPrefix),
117
131
  stateFile: node_path_1.default.resolve(projectRoot, auth.stateFile?.trim() || '.docnest/auth.json'),
118
132
  localStorageKey: authLocalStorageKey,
119
133
  sessionTtlMinutes: normalizePositiveNumber(auth.sessionTtlMinutes, 24 * 60),
120
134
  },
121
- export: {
122
- watermark: {
123
- enabled: watermark.enabled ?? false,
124
- text: watermark.text?.trim() || site.title?.trim() || projectName,
125
- },
126
- },
135
+ restrictedMode: userConfig.restrictedMode === true,
127
136
  };
128
137
  }
package/dist/server.js CHANGED
@@ -54,8 +54,9 @@ const DOCS_THEME_ENABLED = (() => {
54
54
  return [...DOCS_THEME_NAMES];
55
55
  }
56
56
  })();
57
- const DOCS_WATERMARK_ENABLED = process.env.DOCS_WATERMARK_ENABLED === 'true';
58
- const DOCS_WATERMARK_TEXT = process.env.DOCS_WATERMARK_TEXT || DOCS_TITLE;
57
+ const DOCS_RESTRICTED_MODE = process.env.DOCS_RESTRICTED_MODE === 'true';
58
+ const DOCS_PDF_ENABLED = !DOCS_RESTRICTED_MODE;
59
+ const DOCS_PAGE_WATERMARK_TEXT = DOCS_TITLE;
59
60
  const DOCS_DIR = path.resolve(process.env.DOCS_ROOT || path.join(process.cwd(), 'docs'));
60
61
  exports.docsDir = DOCS_DIR;
61
62
  const ROOT_DIRECTORY_ORDER = (() => {
@@ -108,8 +109,8 @@ app.locals.docsStorageKeyPrefix = DOCS_STORAGE_KEY_PREFIX;
108
109
  app.locals.docsThemeDefault = DOCS_THEME_DEFAULT;
109
110
  app.locals.docsThemeModeDefault = DOCS_THEME_MODE_DEFAULT;
110
111
  app.locals.docsThemeEnabled = DOCS_THEME_ENABLED;
111
- app.locals.docsWatermarkEnabled = DOCS_WATERMARK_ENABLED;
112
- app.locals.docsWatermarkText = DOCS_WATERMARK_TEXT;
112
+ app.locals.docsRestrictedMode = DOCS_RESTRICTED_MODE;
113
+ app.locals.docsPageWatermarkText = DOCS_PAGE_WATERMARK_TEXT;
113
114
  app.locals.docsAuthEnabled = BUILT_IN_AUTH_ENABLED;
114
115
  app.locals.docsAuthStorageKey = AUTH_CONFIG.localStorageKey || `${DOCS_STORAGE_KEY_PREFIX}:auth-passphrase`;
115
116
  function normalizeDocRelativePath(value) {
@@ -239,11 +240,13 @@ app.get('/favicon.ico', (req, res) => {
239
240
  app.use('/mermaid', express.static(MERMAID_DIR));
240
241
  // GitHub Markdown 样式(本地,避免 CDN 慢)
241
242
  app.use('/vendor/github-markdown-css', express.static(MARKDOWN_THEME_DIR));
242
- // html2canvas / DOMPurify / jsPDF(本地,用于前端导出 PDF)
243
- app.use('/vendor/html2canvas', express.static(HTML2CANVAS_DIR));
244
- app.use('/vendor/dompurify', express.static(DOMPURIFY_DIR));
245
- // jsPDF(本地,用于 Mermaid 图导出 PDF)
246
- app.use('/vendor/jspdf', express.static(JSPDF_DIR));
243
+ if (DOCS_PDF_ENABLED) {
244
+ // html2canvas / DOMPurify / jsPDF(本地,用于前端导出 PDF)
245
+ app.use('/vendor/html2canvas', express.static(HTML2CANVAS_DIR));
246
+ app.use('/vendor/dompurify', express.static(DOMPURIFY_DIR));
247
+ // jsPDF(本地,用于 Mermaid 图导出 PDF)
248
+ app.use('/vendor/jspdf', express.static(JSPDF_DIR));
249
+ }
247
250
  app.use(express.json({ limit: '16kb' }));
248
251
  app.use(express.urlencoded({ extended: false, limit: '16kb' }));
249
252
  function readCookie(cookieHeader, name) {
@@ -277,6 +280,17 @@ function safeNextPath(value) {
277
280
  return '/';
278
281
  return value;
279
282
  }
283
+ function authRedirectTarget(value) {
284
+ const nextPath = safeNextPath(value);
285
+ const prefix = typeof AUTH_CONFIG.redirectPrefix === 'string' ? AUTH_CONFIG.redirectPrefix : '';
286
+ if (!prefix ||
287
+ nextPath === prefix ||
288
+ nextPath.startsWith(`${prefix}/`) ||
289
+ nextPath.startsWith(`${prefix}?`) ||
290
+ nextPath.startsWith(`${prefix}#`))
291
+ return nextPath;
292
+ return nextPath === '/' ? `${prefix}/` : `${prefix}${nextPath}`;
293
+ }
280
294
  function setAuthCookie(res, token) {
281
295
  const maxAge = AUTH_MANAGER.sessionMaxAgeSeconds;
282
296
  res.setHeader('Set-Cookie', `${AUTH_MANAGER.sessionCookieName}=${encodeURIComponent(token)}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax`);
@@ -289,7 +303,7 @@ function wantsJson(req) {
289
303
  }
290
304
  function renderLoginPage(res, nextPath, error, status = 200) {
291
305
  return res.status(status).render('login', {
292
- nextPath: safeNextPath(nextPath),
306
+ nextPath: authRedirectTarget(nextPath),
293
307
  error: error || '',
294
308
  assetVersion: ASSET_VERSION,
295
309
  });
@@ -313,7 +327,7 @@ app.post('/auth/verify', (req, res) => {
313
327
  app.post('/auth/login', (req, res) => {
314
328
  if (!BUILT_IN_AUTH_ENABLED)
315
329
  return res.redirect('/');
316
- const nextPath = safeNextPath(req.body?.next);
330
+ const nextPath = authRedirectTarget(req.body?.next);
317
331
  const token = AUTH_MANAGER.createSession(String(req.body?.passphrase || ''));
318
332
  if (!token)
319
333
  return renderLoginPage(res, nextPath, '口令不正确,请重试。', 401);
@@ -500,8 +514,11 @@ function renderTree(tree, level = 0) {
500
514
  else {
501
515
  // 文件路径直接使用,路径重定向在 resolveDocPath 中处理
502
516
  const url = `/doc?path=${encodeURIComponent(item.path)}`;
517
+ const isReadme = item.name.toLowerCase() === 'readme.md';
518
+ const readmeClass = isReadme ? ' readme-link' : '';
519
+ const readmeBadge = isReadme ? '<span class="tree-readme-badge">README</span>' : '';
503
520
  html += `<li class="file">
504
- <a href="${url}" class="file-link" title="${itemName}"><span class="tree-kind-icon tree-kind-icon-file" aria-hidden="true">📄</span>${itemName}</a>
521
+ <a href="${url}" class="file-link${readmeClass}" title="${itemName}"><span class="tree-kind-icon tree-kind-icon-file" aria-hidden="true">📄</span><span class="tree-file-name">${itemName}</span>${readmeBadge}</a>
505
522
  </li>`;
506
523
  }
507
524
  }
@@ -36,15 +36,14 @@ export default defineConfig({
36
36
  enabled: false,
37
37
  // 仅首次启动且授权状态文件不存在时使用;更换口令需手动删除状态文件后重启。
38
38
  passphrase: '',
39
+ // 通过反向代理挂载在子路径时,为登录成功后的目标补此前缀;默认空值跳转到根路径。
40
+ redirectPrefix: '',
39
41
  stateFile: '.docnest/auth.json',
40
42
  // 前端 localStorage 的键;口令会永久保存在浏览器本地,直到手动清除或口令失效。
41
43
  localStorageKey: 'my-project:auth-passphrase',
42
44
  sessionTtlMinutes: 24 * 60,
43
45
  },
44
- export: {
45
- watermark: {
46
- enabled: false,
47
- text: '我的项目文档',
48
- },
49
- },
46
+ // 受限阅读模式;默认关闭。开启后隐藏打印/PDF、Mermaid 下载和大图查看,
47
+ // 并使用 site.title 显示固定水印,同时禁止页面文字选择与复制。
48
+ restrictedMode: false,
50
49
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docnest",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "可复用的本地 Markdown 文档中心",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,5 +66,6 @@
66
66
  "@types/node": "^22.10.0",
67
67
  "tsx": "^4.19.2",
68
68
  "typescript": "^5.7.2"
69
- }
69
+ },
70
+ "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
70
71
  }
@@ -451,7 +451,9 @@ body[data-theme="dark"] .dir-toggle.active {
451
451
  color: var(--linear-text);
452
452
  text-decoration: none;
453
453
  font-size: 0.8125rem;
454
- display: block;
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 0.35rem;
455
457
  padding: 0.35rem 0.5rem;
456
458
  border-radius: var(--linear-radius);
457
459
  transition: background-color 0.15s ease, color 0.15s ease;
@@ -460,6 +462,40 @@ body[data-theme="dark"] .dir-toggle.active {
460
462
  white-space: nowrap;
461
463
  }
462
464
 
465
+ .tree-kind-icon-file {
466
+ flex: 0 0 auto;
467
+ }
468
+
469
+ .tree-file-name {
470
+ min-width: 0;
471
+ overflow: hidden;
472
+ text-overflow: ellipsis;
473
+ }
474
+
475
+ .file-link.readme-link {
476
+ background: rgba(94, 106, 210, 0.07);
477
+ font-weight: 600;
478
+ }
479
+
480
+ .file-link.readme-link:hover,
481
+ .file-link.readme-link.active {
482
+ background: rgba(94, 106, 210, 0.14);
483
+ }
484
+
485
+ .tree-readme-badge,
486
+ .dir-listing-item-badge {
487
+ flex: 0 0 auto;
488
+ margin-left: auto;
489
+ padding: 0.1rem 0.4rem;
490
+ border: 1px solid rgba(94, 106, 210, 0.34);
491
+ border-radius: 999px;
492
+ color: var(--linear-accent);
493
+ font-size: 0.625rem;
494
+ font-weight: 700;
495
+ letter-spacing: 0.04em;
496
+ line-height: 1.2;
497
+ }
498
+
463
499
  .file-link:hover {
464
500
  background-color: rgba(0, 0, 0, 0.04);
465
501
  color: var(--linear-accent);
@@ -2230,6 +2266,7 @@ body[data-theme="dark"] .pdf-export-modal__desc {
2230
2266
  }
2231
2267
 
2232
2268
  body[data-theme="dark"] .pdf-export-modal__option,
2269
+ body[data-theme="dark"] .pdf-export-modal__watermark-input,
2233
2270
  body[data-theme="dark"] .pdf-export-modal__btn {
2234
2271
  background: #1c1c1e;
2235
2272
  border-color: #2c2c2e;
@@ -5696,6 +5733,17 @@ body[data-theme="dark"] .breadcrumb-separator {
5696
5733
  color: var(--linear-text-muted);
5697
5734
  }
5698
5735
 
5736
+ .dir-listing-item.is-readme {
5737
+ background: rgba(94, 106, 210, 0.07);
5738
+ border-left: 3px solid var(--linear-accent);
5739
+ padding-left: calc(0.875rem - 3px);
5740
+ }
5741
+
5742
+ .dir-listing-item.is-readme .dir-listing-item-name {
5743
+ color: var(--linear-text);
5744
+ font-weight: 650;
5745
+ }
5746
+
5699
5747
  .dir-listing-item.is-dir .dir-listing-item-name {
5700
5748
  font-weight: 550;
5701
5749
  color: var(--linear-text);
@@ -40,6 +40,58 @@ body {
40
40
  font-family: var(--doc-font-ui);
41
41
  }
42
42
 
43
+ /* Protected reading mode: no text selection/copy and a persistent viewport watermark. */
44
+ html.docnest-page-protected body,
45
+ html.docnest-page-protected body * {
46
+ -webkit-user-select: none !important;
47
+ -moz-user-select: none !important;
48
+ user-select: none !important;
49
+ }
50
+
51
+ html.docnest-page-protected body input,
52
+ html.docnest-page-protected body textarea,
53
+ html.docnest-page-protected body select,
54
+ html.docnest-page-protected body [contenteditable="true"] {
55
+ -webkit-user-select: text !important;
56
+ -moz-user-select: text !important;
57
+ user-select: text !important;
58
+ }
59
+
60
+ /* Restricted mode keeps Mermaid inline but removes every affordance for opening
61
+ * or exporting a larger copy of the diagram. */
62
+ html.docnest-restricted .markdown-body .mermaid {
63
+ cursor: default;
64
+ }
65
+
66
+ html.docnest-restricted .markdown-body .mermaid::after,
67
+ html.docnest-restricted .markdown-body .mermaid-download-btn {
68
+ display: none !important;
69
+ }
70
+
71
+ .docnest-page-watermark {
72
+ position: fixed;
73
+ inset: 0;
74
+ z-index: 9990;
75
+ display: grid;
76
+ grid-template-columns: repeat(3, minmax(0, 1fr));
77
+ grid-template-rows: repeat(4, minmax(0, 1fr));
78
+ overflow: hidden;
79
+ pointer-events: none;
80
+ user-select: none;
81
+ }
82
+
83
+ .docnest-page-watermark__item {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ color: var(--linear-text, rgba(15, 23, 42, 0.42));
88
+ font: 700 clamp(0.75rem, 1.5vw, 1.5rem)/1 var(--doc-font-mono);
89
+ letter-spacing: 0.08em;
90
+ opacity: 0.12;
91
+ transform: rotate(-24deg);
92
+ white-space: nowrap;
93
+ }
94
+
43
95
  ::selection {
44
96
  background: var(--doc-selection);
45
97
  }
@@ -318,8 +370,7 @@ body[data-doc-theme][data-theme="dark"] .pdf-export-modal__panel {
318
370
  }
319
371
 
320
372
  body[data-doc-theme][data-theme="dark"] .download-menu-content button,
321
- body[data-doc-theme][data-theme="dark"] .pdf-export-modal__option,
322
- body[data-doc-theme][data-theme="dark"] .pdf-export-modal__watermark-input {
373
+ body[data-doc-theme][data-theme="dark"] .pdf-export-modal__btn {
323
374
  border-color: var(--linear-border);
324
375
  background: var(--linear-bg-subtle);
325
376
  color: var(--linear-text);
@@ -313,7 +313,7 @@
313
313
  return Math.max(minBreakY - startY, searchStartY + bestRow - startY);
314
314
  }
315
315
 
316
- function addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermarkEnabled, watermarkText) {
316
+ function addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark) {
317
317
  var pageWidth = pdf.internal.pageSize.getWidth();
318
318
  var pageHeight = pdf.internal.pageSize.getHeight();
319
319
  var usableWidth = pageWidth - margin * 2;
@@ -357,8 +357,14 @@
357
357
  sliceHeight
358
358
  );
359
359
 
360
- if (watermarkEnabled) {
361
- drawWatermarkPattern(context, pageCanvas.width, pageCanvas.height, pageCanvasHeight, watermarkText);
360
+ if (watermark && watermark.enabled) {
361
+ drawWatermarkPattern(
362
+ context,
363
+ pageCanvas.width,
364
+ pageCanvas.height,
365
+ pageCanvasHeight,
366
+ watermark.text,
367
+ );
362
368
  }
363
369
 
364
370
  if (pageIndex > 0) {
@@ -413,18 +419,17 @@
413
419
  function openPdfExportDialog() {
414
420
  return new Promise(function(resolve) {
415
421
  var config = window.DOCNEST_CONFIG || {};
416
- var defaultWatermarkEnabled = config.watermarkEnabled === true;
417
- var defaultWatermarkText = String(config.watermarkText || document.title || '');
422
+ var defaultWatermarkText = String(config.siteTitle || document.title || '');
418
423
  var modalId = 'pdf-export-modal';
419
424
  var overlay = document.createElement('div');
420
425
  overlay.className = 'pdf-export-modal';
421
426
  overlay.innerHTML = [
422
427
  '<div class="pdf-export-modal__panel" role="dialog" aria-modal="true" aria-labelledby="pdf-export-title">',
423
428
  '<div class="pdf-export-modal__title" id="pdf-export-title">下载 PDF</div>',
424
- '<div class="pdf-export-modal__desc">导出前确认本次 PDF 是否需要水印;本次修改不会写回项目配置。</div>',
429
+ '<div class="pdf-export-modal__desc">可为本次导出的 PDF 临时添加水印,不会修改项目配置。</div>',
425
430
  '<label class="pdf-export-modal__option">',
426
431
  '<input type="checkbox" id="pdf-export-watermark-checkbox">',
427
- '<span class="pdf-export-modal__option-text">启用水印</span>',
432
+ '<span class="pdf-export-modal__option-text">本次导出添加水印</span>',
428
433
  '</label>',
429
434
  '<label class="pdf-export-modal__watermark-field" for="pdf-export-watermark-text">水印文字</label>',
430
435
  '<input class="pdf-export-modal__watermark-input" id="pdf-export-watermark-text" type="text" maxlength="80">',
@@ -446,45 +451,38 @@
446
451
  }
447
452
 
448
453
  overlay.addEventListener('click', function(event) {
449
- if (event.target === overlay) {
450
- close(null);
451
- }
454
+ if (event.target === overlay) close(null);
452
455
  });
453
-
454
456
  overlay.querySelector('[data-action="cancel"]').addEventListener('click', function() {
455
457
  close(null);
456
458
  });
457
-
458
459
  overlay.querySelector('[data-action="confirm"]').addEventListener('click', function() {
459
460
  var checkbox = overlay.querySelector('#pdf-export-watermark-checkbox');
460
461
  var watermarkInput = overlay.querySelector('#pdf-export-watermark-text');
461
462
  close({
462
- watermarkEnabled: !!(checkbox && checkbox.checked),
463
- watermarkText: watermarkInput ? watermarkInput.value.trim() : defaultWatermarkText,
463
+ enabled: !!(checkbox && checkbox.checked),
464
+ text: watermarkInput ? watermarkInput.value.trim() : defaultWatermarkText,
464
465
  });
465
466
  });
466
467
 
467
468
  document.body.appendChild(overlay);
468
469
  if (typeof pushModal === 'function') {
469
- pushModal(modalId, function() {
470
- close(null);
471
- });
470
+ pushModal(modalId, function() { close(null); });
472
471
  }
473
472
 
474
473
  var checkbox = overlay.querySelector('#pdf-export-watermark-checkbox');
474
+ var watermarkInput = overlay.querySelector('#pdf-export-watermark-text');
475
+ if (watermarkInput) {
476
+ watermarkInput.value = defaultWatermarkText;
477
+ watermarkInput.disabled = true;
478
+ }
475
479
  if (checkbox) {
476
- checkbox.checked = defaultWatermarkEnabled;
480
+ checkbox.checked = false;
477
481
  checkbox.addEventListener('change', function() {
478
- var watermarkInput = overlay.querySelector('#pdf-export-watermark-text');
479
482
  if (watermarkInput) watermarkInput.disabled = !checkbox.checked;
480
483
  });
481
484
  checkbox.focus();
482
485
  }
483
- var watermarkInput = overlay.querySelector('#pdf-export-watermark-text');
484
- if (watermarkInput) {
485
- watermarkInput.value = defaultWatermarkText;
486
- watermarkInput.disabled = !defaultWatermarkEnabled;
487
- }
488
486
  });
489
487
  }
490
488
 
@@ -568,7 +566,7 @@
568
566
  }
569
567
  }
570
568
 
571
- function exportCurrentDoc(button, options) {
569
+ function exportCurrentDoc(button, watermark) {
572
570
  var article = document.querySelector('.main-content .markdown-body');
573
571
  if (!article) {
574
572
  showPdfExportError('未找到当前文档内容,无法导出 PDF。');
@@ -588,21 +586,13 @@
588
586
  format: 'a4',
589
587
  compress: true,
590
588
  });
591
- var config = window.DOCNEST_CONFIG || {};
592
- var watermarkEnabled = options && typeof options.watermarkEnabled === 'boolean'
593
- ? options.watermarkEnabled
594
- : config.watermarkEnabled === true;
595
- var watermarkText = options && options.watermarkText
596
- ? options.watermarkText
597
- : String(config.watermarkText || document.title || '');
598
-
599
589
  return waitForExportReady()
600
590
  .then(function() {
601
591
  return renderCanvas(exportRoot);
602
592
  })
603
593
  .then(function(canvas) {
604
594
  var blockBreakpoints = collectBlockBreakpoints(exportRoot, canvas.height / Math.max(exportRoot.scrollHeight, 1));
605
- addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermarkEnabled, watermarkText);
595
+ addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark);
606
596
  pdf.save(getExportFileName() + '.pdf');
607
597
  })
608
598
  .finally(function() {
@@ -619,13 +609,13 @@
619
609
  }
620
610
 
621
611
  function init() {
612
+ if (window.DOCNEST_CONFIG && window.DOCNEST_CONFIG.restrictedMode === true) return;
622
613
  var button = document.getElementById('download-doc-pdf-btn');
623
614
  if (!button) return;
624
615
 
625
616
  button.addEventListener('click', function() {
626
- openPdfExportDialog().then(function(result) {
627
- if (!result) return;
628
- exportCurrentDoc(button, result);
617
+ openPdfExportDialog().then(function(watermark) {
618
+ if (watermark) exportCurrentDoc(button, watermark);
629
619
  });
630
620
  });
631
621
  }
@@ -1,5 +1,10 @@
1
1
  // 图表下载和复制功能
2
+ function isDocNestRestrictedMode() {
3
+ return !!(window.DOCNEST_CONFIG && window.DOCNEST_CONFIG.restrictedMode === true);
4
+ }
5
+
2
6
  function downloadCurrentDiagram() {
7
+ if (isDocNestRestrictedMode()) return;
3
8
  if (!currentSvg) return;
4
9
  // 获取原始 SVG(移除缩放样式)
5
10
  const originalSvg = currentSvg.cloneNode(true);
@@ -10,6 +15,7 @@ function downloadCurrentDiagram() {
10
15
  }
11
16
 
12
17
  function copyCurrentDiagram() {
18
+ if (isDocNestRestrictedMode()) return;
13
19
  if (!currentSvg) return;
14
20
 
15
21
  // 获取原始 SVG(移除缩放样式)
@@ -199,6 +205,7 @@ function showCopyNotification(message, success) {
199
205
 
200
206
  // 下载功能
201
207
  function showDownloadMenu(svgElement, defaultName) {
208
+ if (isDocNestRestrictedMode()) return;
202
209
  const menu = document.createElement('div');
203
210
  menu.className = 'download-menu';
204
211
  menu.id = 'download-menu';
@@ -246,6 +253,7 @@ function closeDownloadMenu() {
246
253
  }
247
254
 
248
255
  function downloadDiagram(format, name) {
256
+ if (isDocNestRestrictedMode()) return;
249
257
  const svg = window.currentDownloadSvg;
250
258
  if (!svg) return;
251
259
 
@@ -4,7 +4,12 @@ let defaultZoom = 1;
4
4
  let currentSvg = null;
5
5
  let isFullscreen = false;
6
6
 
7
+ function isDocNestRestrictedMode() {
8
+ return !!(window.DOCNEST_CONFIG && window.DOCNEST_CONFIG.restrictedMode === true);
9
+ }
10
+
7
11
  function setupDiagramViewer() {
12
+ if (isDocNestRestrictedMode()) return;
8
13
  const diagrams = document.querySelectorAll('.markdown-body .mermaid');
9
14
  diagrams.forEach((diagram, index) => {
10
15
  // 添加下载按钮
@@ -38,6 +43,7 @@ function setupDiagramViewer() {
38
43
  }
39
44
 
40
45
  function openDiagramViewer(svgElement) {
46
+ if (isDocNestRestrictedMode()) return;
41
47
  const viewer = document.getElementById('diagram-viewer');
42
48
  const container = document.getElementById('diagram-viewer-svg-container');
43
49
 
@@ -15,6 +15,7 @@
15
15
  <script src="/static/js/core/sidebar.js?v=<%= assetVersion %>"></script>
16
16
  </head>
17
17
  <body data-current-dir-path="<%= currentDirPath %>">
18
+ <%- include('partials/page-protection') %>
18
19
  <div class="container">
19
20
  <%- include('partials/header', { showBackLink: true, showPrintButton: false, showPdfButton: false }) %>
20
21
  <div class="content">
@@ -47,9 +48,11 @@
47
48
  </a>
48
49
  <% }); %>
49
50
  <% listing.files.forEach(function(file) { %>
50
- <a href="/doc?path=<%= encodeURIComponent(file.path) %>" class="dir-listing-item is-file">
51
+ <% const isReadme = file.name.toLowerCase() === 'readme.md'; %>
52
+ <a href="/doc?path=<%= encodeURIComponent(file.path) %>" class="dir-listing-item is-file<%= isReadme ? ' is-readme' : '' %>">
51
53
  <span class="dir-listing-item-icon">📄</span>
52
54
  <span class="dir-listing-item-name"><%= file.name %></span>
55
+ <% if (isReadme) { %><span class="dir-listing-item-badge">目录说明</span><% } %>
53
56
  </a>
54
57
  <% }); %>
55
58
  </div>
@@ -15,10 +15,13 @@
15
15
  <script src="/static/js/core/theme.js?v=<%= assetVersion %>"></script>
16
16
  <script src="/static/js/core/mermaid-init.js?v=<%= assetVersion %>"></script>
17
17
  <script src="/static/js/core/doc-client-nav.js?v=<%= assetVersion %>"></script>
18
- <script src="/static/js/core/doc-pdf-export.js?v=<%= assetVersion %>"></script>
18
+ <% if (!docsRestrictedMode) { %>
19
+ <script src="/static/js/core/doc-pdf-export.js?v=<%= assetVersion %>"></script>
20
+ <% } %>
19
21
  <script src="/static/js/core/sidebar.js?v=<%= assetVersion %>"></script>
20
22
  </head>
21
23
  <body data-current-path="<%= currentPath %>">
24
+ <%- include('partials/page-protection') %>
22
25
  <div class="container">
23
26
  <%- include('partials/header', { showBackLink: true, showPrintButton: true, showPdfButton: true }) %>
24
27
  <div class="content">
@@ -34,26 +37,31 @@
34
37
  </main>
35
38
  </div>
36
39
  </div>
37
- <%- include('partials/diagram-viewer') %>
38
- <!-- 按依赖顺序加载模块 -->
39
- <script src="/static/js/utils/modal-stack.js?v=<%= assetVersion %>"></script>
40
- <script src="/static/js/diagram/diagram-viewer.js?v=<%= assetVersion %>"></script>
41
- <script src="/static/js/diagram/diagram-zoom.js?v=<%= assetVersion %>"></script>
42
- <script src="/static/js/diagram/diagram-drag.js?v=<%= assetVersion %>"></script>
43
- <script src="/static/js/diagram/diagram-controls.js?v=<%= assetVersion %>"></script>
44
- <script src="/static/js/diagram/diagram-download.js?v=<%= assetVersion %>"></script>
40
+ <% if (!docsRestrictedMode) { %>
41
+ <%- include('partials/diagram-viewer') %>
42
+ <!-- 按依赖顺序加载模块 -->
43
+ <script src="/static/js/utils/modal-stack.js?v=<%= assetVersion %>"></script>
44
+ <script src="/static/js/diagram/diagram-viewer.js?v=<%= assetVersion %>"></script>
45
+ <script src="/static/js/diagram/diagram-zoom.js?v=<%= assetVersion %>"></script>
46
+ <script src="/static/js/diagram/diagram-drag.js?v=<%= assetVersion %>"></script>
47
+ <script src="/static/js/diagram/diagram-controls.js?v=<%= assetVersion %>"></script>
48
+ <script src="/static/js/diagram/diagram-download.js?v=<%= assetVersion %>"></script>
49
+ <% } %>
45
50
  <script src="/static/js/utils/app.js?v=<%= assetVersion %>"></script>
51
+ <% if (!docsRestrictedMode) { %>
52
+ <script>
53
+ // 打印当前文章
54
+ (function() {
55
+ const printBtn = document.getElementById('print-doc-btn');
56
+ if (printBtn) {
57
+ printBtn.addEventListener('click', function() {
58
+ window.print();
59
+ });
60
+ }
61
+ })();
62
+ </script>
63
+ <% } %>
46
64
  <script>
47
- // 打印当前文章
48
- (function() {
49
- const printBtn = document.getElementById('print-doc-btn');
50
- if (printBtn) {
51
- printBtn.addEventListener('click', function() {
52
- window.print();
53
- });
54
- }
55
- })();
56
-
57
65
  // 高亮当前文档;展开状态:恢复上次保存的 + 当前文档所在路径,不折叠其他已展开项
58
66
  (function() {
59
67
  const STORAGE_KEY = <%- JSON.stringify(docsStorageKeyPrefix + ':open-dirs') %>;
@@ -17,6 +17,7 @@
17
17
  <script src="/static/js/core/sidebar.js?v=<%= assetVersion %>"></script>
18
18
  </head>
19
19
  <body>
20
+ <%- include('partials/page-protection') %>
20
21
  <div class="container">
21
22
  <%- include('partials/header', { showBackLink: false }) %>
22
23
  <div class="content">
@@ -29,14 +30,16 @@
29
30
  </main>
30
31
  </div>
31
32
  </div>
32
- <%- include('partials/diagram-viewer') %>
33
- <!-- 按依赖顺序加载模块 -->
34
- <script src="/static/js/utils/modal-stack.js?v=<%= assetVersion %>"></script>
35
- <script src="/static/js/diagram/diagram-viewer.js?v=<%= assetVersion %>"></script>
36
- <script src="/static/js/diagram/diagram-zoom.js?v=<%= assetVersion %>"></script>
37
- <script src="/static/js/diagram/diagram-drag.js?v=<%= assetVersion %>"></script>
38
- <script src="/static/js/diagram/diagram-controls.js?v=<%= assetVersion %>"></script>
39
- <script src="/static/js/diagram/diagram-download.js?v=<%= assetVersion %>"></script>
33
+ <% if (!docsRestrictedMode) { %>
34
+ <%- include('partials/diagram-viewer') %>
35
+ <!-- 按依赖顺序加载模块 -->
36
+ <script src="/static/js/utils/modal-stack.js?v=<%= assetVersion %>"></script>
37
+ <script src="/static/js/diagram/diagram-viewer.js?v=<%= assetVersion %>"></script>
38
+ <script src="/static/js/diagram/diagram-zoom.js?v=<%= assetVersion %>"></script>
39
+ <script src="/static/js/diagram/diagram-drag.js?v=<%= assetVersion %>"></script>
40
+ <script src="/static/js/diagram/diagram-controls.js?v=<%= assetVersion %>"></script>
41
+ <script src="/static/js/diagram/diagram-download.js?v=<%= assetVersion %>"></script>
42
+ <% } %>
40
43
  <script src="/static/js/utils/app.js?v=<%= assetVersion %>"></script>
41
44
  <script src="/socket.io/socket.io.js"></script>
42
45
  <script>
@@ -14,6 +14,7 @@
14
14
  <script src="/static/js/core/sidebar.js?v=<%= assetVersion %>"></script>
15
15
  </head>
16
16
  <body class="not-found-page">
17
+ <%- include('partials/page-protection') %>
17
18
  <div class="container">
18
19
  <%- include('partials/header', { showBackLink: true }) %>
19
20
  <div class="content">
@@ -2,8 +2,8 @@
2
2
  window.DOCNEST_CONFIG = {
3
3
  authEnabled: <%= docsAuthEnabled ? 'true' : 'false' %>,
4
4
  authStorageKey: <%- JSON.stringify(docsAuthStorageKey) %>,
5
- watermarkEnabled: <%= docsWatermarkEnabled ? 'true' : 'false' %>,
6
- watermarkText: <%- JSON.stringify(docsWatermarkText) %>,
5
+ siteTitle: <%- JSON.stringify(docsTitle) %>,
6
+ restrictedMode: <%= docsRestrictedMode ? 'true' : 'false' %>,
7
7
  storageKeyPrefix: <%- JSON.stringify(docsStorageKeyPrefix) %>,
8
8
  appearance: {
9
9
  defaultTheme: <%- JSON.stringify(docsThemeDefault) %>,
@@ -13,13 +13,13 @@
13
13
  </div>
14
14
  <div class="header-meta">
15
15
  <div class="header-actions">
16
- <% if (typeof showPrintButton !== 'undefined' && showPrintButton) { %>
16
+ <% if (typeof showPrintButton !== 'undefined' && showPrintButton && !docsRestrictedMode) { %>
17
17
  <button type="button" id="print-doc-btn" class="header-action-btn print-btn" title="打印当前文章">
18
18
  <span class="print-icon">🖨️</span>
19
19
  <span class="header-action-text">打印</span>
20
20
  </button>
21
21
  <% } %>
22
- <% if (typeof showPdfButton !== 'undefined' && showPdfButton) { %>
22
+ <% if (typeof showPdfButton !== 'undefined' && showPdfButton && !docsRestrictedMode) { %>
23
23
  <button type="button" id="download-doc-pdf-btn" class="header-action-btn download-pdf-btn" title="下载当前文章 PDF">
24
24
  <span class="download-pdf-icon">⬇️</span>
25
25
  <span class="header-action-text">下载 PDF</span>
@@ -0,0 +1,31 @@
1
+ <% if (docsRestrictedMode) { %>
2
+ <div class="docnest-page-watermark" aria-hidden="true">
3
+ <% for (let index = 0; index < 12; index += 1) { %>
4
+ <span class="docnest-page-watermark__item"><%= docsPageWatermarkText %></span>
5
+ <% } %>
6
+ </div>
7
+ <script>
8
+ document.documentElement.classList.add('docnest-page-protected', 'docnest-restricted');
9
+ (function() {
10
+ function isEditableTarget(target) {
11
+ return !!(target && target.closest && target.closest('input, textarea, select, [contenteditable="true"]'));
12
+ }
13
+
14
+ function preventProtectedAction(event) {
15
+ if (!isEditableTarget(event.target)) event.preventDefault();
16
+ }
17
+
18
+ document.addEventListener('selectstart', preventProtectedAction);
19
+ document.addEventListener('copy', preventProtectedAction);
20
+ document.addEventListener('cut', preventProtectedAction);
21
+ document.addEventListener('dragstart', preventProtectedAction);
22
+ document.addEventListener('keydown', function(event) {
23
+ if (isEditableTarget(event.target)) return;
24
+ var key = String(event.key || '').toLowerCase();
25
+ if ((event.metaKey || event.ctrlKey) && (key === 'a' || key === 'c' || key === 'x')) {
26
+ event.preventDefault();
27
+ }
28
+ });
29
+ })();
30
+ </script>
31
+ <% } %>