docnest 0.1.4 → 0.1.6
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 +7 -0
- package/dist/attachments.d.ts +11 -0
- package/dist/attachments.js +148 -0
- package/dist/cli.js +0 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -84,6 +84,13 @@ startServer(3000)
|
|
|
84
84
|
|
|
85
85
|
存储适配器实现 `name`、`readDir`、`fileExists`、`readFile`、`readAsset`,可选实现 `ready`;这样文档内容仍由宿主项目提供,DocNest 只负责展示和交互。
|
|
86
86
|
|
|
87
|
+
## 静态构建中的文档附件
|
|
88
|
+
|
|
89
|
+
如果宿主项目需要生成静态站点,可使用 `docnest/attachments` 提供的
|
|
90
|
+
`collectDocumentAttachmentPaths` 和 `copyDocumentAttachments`。它们只复制
|
|
91
|
+
Markdown 或 HTML 中明确引用的本地附件,不按文件扩展名设置业务白名单;外链、文档链接、隐藏路径、路径穿越和代码块中的伪引用会被忽略,缺失的附件默认会让构建失败。
|
|
92
|
+
常见凭据文件(如 `.env`、私钥和证书容器)属于公共安全阻断项,即使被误写进文档链接也不会进入静态产物。
|
|
93
|
+
|
|
87
94
|
## 开发
|
|
88
95
|
|
|
89
96
|
```bash
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DocumentAttachmentOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* Whether a missing, non-Markdown local reference should fail collection.
|
|
4
|
+
* Defaults to true so static builds cannot silently publish broken assets.
|
|
5
|
+
*/
|
|
6
|
+
failOnMissing?: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** Collect every existing, explicitly referenced local document attachment. */
|
|
9
|
+
export declare function collectDocumentAttachmentPaths(docsRoot: string, options?: DocumentAttachmentOptions): string[];
|
|
10
|
+
/** Copy collected attachments while preserving their paths relative to docsRoot. */
|
|
11
|
+
export declare function copyDocumentAttachments(sourceRoot: string, destinationRoot: string, options?: DocumentAttachmentOptions): string[];
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.collectDocumentAttachmentPaths = collectDocumentAttachmentPaths;
|
|
7
|
+
exports.copyDocumentAttachments = copyDocumentAttachments;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const MARKDOWN_SOURCE_EXTENSIONS = new Set(['.md', '.markdown']);
|
|
11
|
+
const SENSITIVE_ATTACHMENT_EXTENSIONS = new Set([
|
|
12
|
+
'.env',
|
|
13
|
+
'.pem',
|
|
14
|
+
'.key',
|
|
15
|
+
'.p12',
|
|
16
|
+
'.pfx',
|
|
17
|
+
'.jks',
|
|
18
|
+
'.keystore',
|
|
19
|
+
'.secret',
|
|
20
|
+
'.secrets',
|
|
21
|
+
]);
|
|
22
|
+
const SENSITIVE_ATTACHMENT_NAMES = new Set([
|
|
23
|
+
'id_rsa',
|
|
24
|
+
'id_dsa',
|
|
25
|
+
'id_ecdsa',
|
|
26
|
+
'id_ed25519',
|
|
27
|
+
]);
|
|
28
|
+
const MARKDOWN_LINK_PATTERN = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
|
29
|
+
const MARKDOWN_REFERENCE_PATTERN = /^\s{0,3}\[[^\]]+\]:\s*(?:<([^>\n]+)>|(\S+))/gm;
|
|
30
|
+
const HTML_ASSET_PATTERN = /\b(?:src|href)=["']([^"']+)["']/gi;
|
|
31
|
+
const FENCED_CODE_PATTERN = /(```|~~~)[^\n]*\n[\s\S]*?\n\1\s*/g;
|
|
32
|
+
function collectMarkdownPaths(root, base = '') {
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(root, base), { withFileTypes: true })) {
|
|
35
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
36
|
+
continue;
|
|
37
|
+
const relativePath = base ? node_path_1.default.join(base, entry.name) : entry.name;
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
results.push(...collectMarkdownPaths(root, relativePath));
|
|
40
|
+
}
|
|
41
|
+
else if (entry.isFile() && MARKDOWN_SOURCE_EXTENSIONS.has(node_path_1.default.extname(entry.name).toLowerCase())) {
|
|
42
|
+
results.push(relativePath.replace(/\\/g, '/'));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
function isLocalRelativeTarget(target) {
|
|
48
|
+
return !/^(?:[a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(target);
|
|
49
|
+
}
|
|
50
|
+
function stripTargetSuffix(target) {
|
|
51
|
+
return target.split(/[?#]/, 1)[0];
|
|
52
|
+
}
|
|
53
|
+
function isSensitiveAttachment(target) {
|
|
54
|
+
const baseName = node_path_1.default.basename(target).toLowerCase();
|
|
55
|
+
return SENSITIVE_ATTACHMENT_EXTENSIONS.has(node_path_1.default.extname(baseName))
|
|
56
|
+
|| SENSITIVE_ATTACHMENT_NAMES.has(baseName)
|
|
57
|
+
|| baseName.startsWith('.env.');
|
|
58
|
+
}
|
|
59
|
+
function extractTargets(markdown) {
|
|
60
|
+
const source = markdown.replace(FENCED_CODE_PATTERN, '');
|
|
61
|
+
const targets = [];
|
|
62
|
+
MARKDOWN_LINK_PATTERN.lastIndex = 0;
|
|
63
|
+
for (const match of source.matchAll(MARKDOWN_LINK_PATTERN)) {
|
|
64
|
+
targets.push(match[1] || match[2] || '');
|
|
65
|
+
}
|
|
66
|
+
MARKDOWN_REFERENCE_PATTERN.lastIndex = 0;
|
|
67
|
+
for (const match of source.matchAll(MARKDOWN_REFERENCE_PATTERN)) {
|
|
68
|
+
targets.push(match[1] || match[2] || '');
|
|
69
|
+
}
|
|
70
|
+
HTML_ASSET_PATTERN.lastIndex = 0;
|
|
71
|
+
for (const match of source.matchAll(HTML_ASSET_PATTERN)) {
|
|
72
|
+
targets.push(match[1] || '');
|
|
73
|
+
}
|
|
74
|
+
return targets;
|
|
75
|
+
}
|
|
76
|
+
function resolveAttachmentPath(docsRoot, realDocsRoot, markdownPath, rawTarget, failOnMissing) {
|
|
77
|
+
if (!rawTarget || !isLocalRelativeTarget(rawTarget))
|
|
78
|
+
return null;
|
|
79
|
+
let decodedTarget;
|
|
80
|
+
try {
|
|
81
|
+
decodedTarget = decodeURIComponent(stripTargetSuffix(rawTarget));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!decodedTarget)
|
|
87
|
+
return null;
|
|
88
|
+
const extension = node_path_1.default.extname(decodedTarget).toLowerCase();
|
|
89
|
+
if (MARKDOWN_SOURCE_EXTENSIONS.has(extension))
|
|
90
|
+
return null;
|
|
91
|
+
if (isSensitiveAttachment(decodedTarget))
|
|
92
|
+
return null;
|
|
93
|
+
const resolved = node_path_1.default.resolve(docsRoot, node_path_1.default.dirname(markdownPath), decodedTarget);
|
|
94
|
+
const relative = node_path_1.default.relative(docsRoot, resolved);
|
|
95
|
+
if (!relative || node_path_1.default.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const relativeSegments = relative.split(node_path_1.default.sep);
|
|
99
|
+
if (relativeSegments.some((segment) => segment.startsWith('.')))
|
|
100
|
+
return null;
|
|
101
|
+
if (!node_fs_1.default.existsSync(resolved)) {
|
|
102
|
+
if (failOnMissing) {
|
|
103
|
+
throw new Error(`文档附件不存在: ${markdownPath} -> ${rawTarget}`);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!node_fs_1.default.statSync(resolved).isFile())
|
|
108
|
+
return null;
|
|
109
|
+
// Do not follow a symlink out of the document root.
|
|
110
|
+
const realResolved = node_fs_1.default.realpathSync(resolved);
|
|
111
|
+
const realRelative = node_path_1.default.relative(realDocsRoot, realResolved);
|
|
112
|
+
if (node_path_1.default.isAbsolute(realRelative) || realRelative === '..' || realRelative.startsWith(`..${node_path_1.default.sep}`)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return relative.replace(/\\/g, '/');
|
|
116
|
+
}
|
|
117
|
+
/** Collect every existing, explicitly referenced local document attachment. */
|
|
118
|
+
function collectDocumentAttachmentPaths(docsRoot, options = {}) {
|
|
119
|
+
const root = node_path_1.default.resolve(docsRoot);
|
|
120
|
+
if (!node_fs_1.default.statSync(root).isDirectory()) {
|
|
121
|
+
throw new Error(`文档目录不存在: ${docsRoot}`);
|
|
122
|
+
}
|
|
123
|
+
const realRoot = node_fs_1.default.realpathSync(root);
|
|
124
|
+
const failOnMissing = options.failOnMissing ?? true;
|
|
125
|
+
const results = new Set();
|
|
126
|
+
for (const markdownPath of collectMarkdownPaths(root)) {
|
|
127
|
+
const markdown = node_fs_1.default.readFileSync(node_path_1.default.join(root, markdownPath), 'utf8');
|
|
128
|
+
for (const rawTarget of extractTargets(markdown)) {
|
|
129
|
+
const attachmentPath = resolveAttachmentPath(root, realRoot, markdownPath, rawTarget, failOnMissing);
|
|
130
|
+
if (attachmentPath)
|
|
131
|
+
results.add(attachmentPath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...results].sort();
|
|
135
|
+
}
|
|
136
|
+
/** Copy collected attachments while preserving their paths relative to docsRoot. */
|
|
137
|
+
function copyDocumentAttachments(sourceRoot, destinationRoot, options = {}) {
|
|
138
|
+
const attachmentPaths = collectDocumentAttachmentPaths(sourceRoot, options);
|
|
139
|
+
const source = node_path_1.default.resolve(sourceRoot);
|
|
140
|
+
const destination = node_path_1.default.resolve(destinationRoot);
|
|
141
|
+
for (const relativePath of attachmentPaths) {
|
|
142
|
+
const sourcePath = node_path_1.default.join(source, relativePath);
|
|
143
|
+
const destinationPath = node_path_1.default.join(destination, relativePath);
|
|
144
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(destinationPath), { recursive: true });
|
|
145
|
+
node_fs_1.default.copyFileSync(sourcePath, destinationPath);
|
|
146
|
+
}
|
|
147
|
+
return attachmentPaths;
|
|
148
|
+
}
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docnest",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "可复用的本地 Markdown 文档中心",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
"./config": "./dist/config.js",
|
|
19
|
+
"./attachments": "./dist/attachments.js",
|
|
19
20
|
"./server": "./dist/server.js",
|
|
20
21
|
".": "./dist/config.js"
|
|
21
22
|
},
|