vitepress-plugin-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ import container from "markdown-it-container";
2
+ import { camelCase, deepMerge, isBoolean, isNull, isNumber, isString, isUndefined, kebabCase, objectEntries, objectKeys, slash } from "@pengzhanbo/utils";
3
+ import ansis from "ansis";
4
+ import process from "node:process";
5
+ //#region src/shared/link.ts
6
+ const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i;
7
+ function isExternal(path) {
8
+ return EXTERNAL_URL_RE.test(path);
9
+ }
10
+ const URL_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/;
11
+ function isLinkWithProtocol(link) {
12
+ return URL_PROTOCOL_RE.test(link) || link.startsWith("//");
13
+ }
14
+ //#endregion
15
+ //#region src/node/utils/resolve-attrs.ts
16
+ /**
17
+ * Regular expression for matching attribute values
18
+ *
19
+ * 匹配属性值的正则表达式
20
+ */
21
+ const RE_ATTR_VALUE = /(?:^|\s+)(?<attr>[\w-]+)(?:=(?<quote>['"])(?<valueWithQuote>.+?)\k<quote>|=(?<valueWithoutQuote>\S+))?(?:\s+|$)/;
22
+ /**
23
+ * Resolve attribute string to object
24
+ *
25
+ * 将属性字符串解析为对象
26
+ *
27
+ * @param info - Attribute string / 属性字符串
28
+ * @returns Object with attrs and rawAttrs / 包含 attrs 和 rawAttrs 的对象
29
+ * @typeParam T - Attribute type / 属性类型
30
+ */
31
+ function resolveAttrs(info) {
32
+ info = info.trim();
33
+ if (!info) return {};
34
+ const attrs = {};
35
+ let matched;
36
+ while (matched = info.match(RE_ATTR_VALUE)) {
37
+ const { attr, valueWithQuote, valueWithoutQuote } = matched.groups;
38
+ const value = valueWithQuote || valueWithoutQuote || true;
39
+ let v = isString(value) ? value.trim() : value;
40
+ if (v === "true") v = true;
41
+ else if (v === "false") v = false;
42
+ else if (v === "\"\"" || v === "''") v = "";
43
+ attrs[camelCase(attr)] = v;
44
+ info = info.slice(matched[0].length);
45
+ }
46
+ return attrs;
47
+ }
48
+ /**
49
+ * Resolve single attribute value from info string
50
+ *
51
+ * 从信息字符串中解析单个属性值
52
+ *
53
+ * @param info - Info string / 信息字符串
54
+ * @param key - Attribute key / 属性键
55
+ * @returns Attribute value or undefined / 属性值或 undefined
56
+ */
57
+ function resolveAttr(info, key) {
58
+ const pattern = new RegExp(`(?:^|\\s+)${key}(?:=(?<quote>['"])(?<valueWithQuote>.+?)\\k<quote>|=(?<valueWithoutQuote>\\S+))?(?:\\s+|$)`);
59
+ const groups = info.match(pattern)?.groups;
60
+ return groups?.valueWithQuote || groups?.valueWithoutQuote;
61
+ }
62
+ //#endregion
63
+ //#region src/node/markdown/container.ts
64
+ /**
65
+ * Create markdown-it custom container plugin
66
+ *
67
+ * 创建 markdown-it 的自定义容器插件
68
+ *
69
+ * @param md - Markdown-it instance / Markdown-it 实例
70
+ * @param type - Container type (e.g., 'tip', 'warning') / 容器类型(如 'tip', 'warning' 等)
71
+ * @param options - Optional before/after render hooks / 可选的 before/after 渲染钩子
72
+ * @param options.before - Callback for rendering container opening tag / 渲染容器起始标签时的回调函数
73
+ * @param options.after - Callback for rendering container closing tag / 渲染容器结束标签时的回调函数
74
+ */
75
+ function createContainerPlugin(md, type, { before, after } = {}) {
76
+ const render = (tokens, index, options, env) => {
77
+ const token = tokens[index];
78
+ const info = token.info.trim().slice(type.length).trim() || "";
79
+ if (token.nesting === 1) return before?.(info, tokens, index, options, env) ?? `<div class="custom-container ${type}">`;
80
+ else return after?.(info, tokens, index, options, env) ?? "</div>";
81
+ };
82
+ md.use(container, type, { render });
83
+ }
84
+ /**
85
+ * Create a custom container rule where content is not processed by markdown-it
86
+ * Requires custom content processing logic
87
+ * ```md
88
+ * ::: type
89
+ * xxxx <-- content: this part will not be processed by markdown-it
90
+ * :::
91
+ * ```
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * const example = createContainerSyntaxPlugin(md, 'example', (tokens, index, options, env) => {
96
+ * const { content, meta } = tokens[index]
97
+ * return `<div class="example">${meta.title} | ${content}</div>`
98
+ * })
99
+ * ```
100
+ *
101
+ * @param md - Markdown-it instance / Markdown-it 实例
102
+ * @param type - Container type / 容器类型
103
+ * @param render - Custom render rule / 自定义渲染规则
104
+ */
105
+ function createContainerSyntaxPlugin(md, type, render) {
106
+ const maker = ":";
107
+ const markerMinLen = 3;
108
+ /**
109
+ * Custom container block rule definition
110
+ *
111
+ * 自定义容器的 block 规则定义
112
+ *
113
+ * @param state - Current block state / 当前 block 状态
114
+ * @param startLine - Start line / 起始行
115
+ * @param endLine - End line / 结束行
116
+ * @param silent - Silent mode / 是否为静默模式
117
+ * @returns Whether matched / 是否匹配到自定义容器
118
+ */
119
+ function defineContainer(state, startLine, endLine, silent) {
120
+ const start = state.bMarks[startLine] + state.tShift[startLine];
121
+ const max = state.eMarks[startLine];
122
+ let pos = start;
123
+ if (state.src[pos] !== maker) return false;
124
+ for (pos = start + 1; pos <= max; pos++) if (state.src[pos] !== maker) break;
125
+ if (pos - start < markerMinLen) return false;
126
+ const markup = state.src.slice(start, pos);
127
+ const info = state.src.slice(pos, max).trim();
128
+ if (!info.startsWith(type)) return false;
129
+ /* istanbul ignore if -- @preserve */
130
+ if (silent) return true;
131
+ let line = startLine;
132
+ let content = "";
133
+ while (++line < endLine) {
134
+ if (state.src.slice(state.bMarks[line], state.eMarks[line]).trim() === markup) break;
135
+ content += `${state.src.slice(state.bMarks[line], state.eMarks[line])}\n`;
136
+ }
137
+ const token = state.push(`${type}_container`, "", 0);
138
+ token.meta = resolveAttrs(info.slice(type.length));
139
+ token.content = content;
140
+ token.markup = `${markup} ${type}`;
141
+ token.map = [startLine, line + 1];
142
+ state.line = line + 1;
143
+ return true;
144
+ }
145
+ const defaultRender = (tokens, index) => {
146
+ const { content } = tokens[index];
147
+ return `<div class="custom-container ${type}">${content}</div>`;
148
+ };
149
+ md.block.ruler.before("fence", `${type}_definition`, defineContainer);
150
+ md.renderer.rules[`${type}_container`] = render ?? defaultRender;
151
+ }
152
+ //#endregion
153
+ //#region src/node/markdown/embed.ts
154
+ /**
155
+ * Create embed rule block
156
+ *
157
+ * 创建嵌入规则块
158
+ *
159
+ * Syntax: \@\[name]()
160
+ *
161
+ * 语法:\@\[name]()
162
+ *
163
+ * @param md - Markdown instance / Markdown 实例
164
+ * @param {EmbedRuleBlockOptions} options - Embed rule block options / 嵌入规则块选项
165
+ * @typeParam Meta - Metadata type / 元数据类型
166
+ */
167
+ function createEmbedRuleBlock(md, { type, name = type, syntaxPattern, beforeName = "code", ruleOptions = { alt: [
168
+ "paragraph",
169
+ "reference",
170
+ "blockquote",
171
+ "list"
172
+ ] }, meta, content }) {
173
+ const MIN_LENGTH = type.length + 5;
174
+ const START_CODES = [
175
+ 64,
176
+ 91,
177
+ ...type.split("").map((c) => c.charCodeAt(0))
178
+ ];
179
+ md.block.ruler.before(beforeName, name, (state, startLine, endLine, silent) => {
180
+ const pos = state.bMarks[startLine] + state.tShift[startLine];
181
+ const max = state.eMarks[startLine];
182
+ if (pos + MIN_LENGTH > max) return false;
183
+ for (let i = 0; i < START_CODES.length; i += 1) if (state.src.charCodeAt(pos + i) !== START_CODES[i]) return false;
184
+ const content = state.src.slice(pos, max);
185
+ const match = content.match(syntaxPattern);
186
+ if (!match) return false;
187
+ /* istanbul ignore if -- @preserve */
188
+ if (silent) return true;
189
+ const token = state.push(name, "", 0);
190
+ token.meta = meta(match);
191
+ token.content = content;
192
+ token.map = [startLine, startLine + 1];
193
+ state.line = startLine + 1;
194
+ return true;
195
+ }, ruleOptions);
196
+ md.renderer.rules[name] = (tokens, index, _, env) => {
197
+ const token = tokens[index];
198
+ token.content = content(token.meta, token.content, env);
199
+ return token.content;
200
+ };
201
+ }
202
+ //#endregion
203
+ //#region src/node/utils/constants.ts
204
+ /**
205
+ * 支持的视频文件名扩展名
206
+ */
207
+ const EXTENSION_VIDEOS = [
208
+ "mp4",
209
+ "mp3",
210
+ "webm",
211
+ "ogg",
212
+ "mpd",
213
+ "dash",
214
+ "m3u8",
215
+ "hls",
216
+ "ts",
217
+ "flv",
218
+ "mkv",
219
+ "mov",
220
+ "ogv"
221
+ ];
222
+ /**
223
+ * 支持的图片文件名扩展名
224
+ */
225
+ const EXTENSION_IMAGES = [
226
+ "jpg",
227
+ "jpeg",
228
+ "png",
229
+ "gif",
230
+ "avif",
231
+ "webp",
232
+ "svg",
233
+ "bmp",
234
+ "ico",
235
+ "tiff",
236
+ "apng",
237
+ "jfif",
238
+ "pjpeg",
239
+ "pjp",
240
+ "xbm"
241
+ ];
242
+ /**
243
+ * 支持的音频文件名扩展名
244
+ */
245
+ const EXTENSION_AUDIOS = [
246
+ "mp3",
247
+ "flac",
248
+ "wav",
249
+ "ogg",
250
+ "opus",
251
+ "webm",
252
+ "acc"
253
+ ];
254
+ //#endregion
255
+ //#region src/node/utils/logger.ts
256
+ /**
257
+ * Log levels mapping
258
+ *
259
+ * 日志级别映射
260
+ */
261
+ const logLevels = {
262
+ silent: 0,
263
+ error: 1,
264
+ warn: 2,
265
+ info: 3,
266
+ debug: 4
267
+ };
268
+ /**
269
+ * Create logger instance
270
+ *
271
+ * 创建日志实例
272
+ *
273
+ * @param prefix - Log prefix / 日志前缀
274
+ * @param defaultLevel - Default log level / 默认日志级别
275
+ * @returns Logger instance / 日志实例
276
+ */
277
+ function createLogger(prefix, defaultLevel = "info") {
278
+ prefix = `[${prefix}]`;
279
+ /**
280
+ * Output log
281
+ *
282
+ * 输出日志
283
+ */
284
+ function output(type, msg, level) {
285
+ level = isBoolean(level) ? level ? defaultLevel : "error" : level;
286
+ if (logLevels[level] >= logLevels[type]) {
287
+ const method = type === "info" || type === "debug" ? "log" : type;
288
+ const tag = type === "debug" ? ansis.magenta.bold(prefix) : type === "info" ? ansis.cyan.bold(prefix) : type === "warn" ? ansis.yellow.bold(prefix) : ansis.red.bold(prefix);
289
+ const format = `${ansis.dim((/* @__PURE__ */ new Date()).toLocaleTimeString())} ${tag} ${msg}`;
290
+ console[method](format);
291
+ }
292
+ }
293
+ return {
294
+ debug(msg, level = defaultLevel) {
295
+ output("debug", msg, level);
296
+ },
297
+ info(msg, level = defaultLevel) {
298
+ output("info", msg, level);
299
+ },
300
+ warn(msg, level = defaultLevel) {
301
+ output("warn", msg, level);
302
+ },
303
+ error(msg, level = defaultLevel) {
304
+ output("error", msg, level);
305
+ }
306
+ };
307
+ }
308
+ //#endregion
309
+ //#region src/node/utils/parseRect.ts
310
+ /**
311
+ * Parse rect size string, add unit if it's a number
312
+ *
313
+ * 解析矩形尺寸字符串,如果是数字则添加单位
314
+ *
315
+ * @param str - Size string / 尺寸字符串
316
+ * @param unit - Unit to append (default: 'px') / 要添加的单位(默认:'px')
317
+ * @returns Size string with unit / 带单位的尺寸字符串
318
+ */
319
+ function parseRect(str, unit = "px") {
320
+ if (Number.parseFloat(str) === Number(str)) return `${str}${unit}`;
321
+ return str;
322
+ }
323
+ //#endregion
324
+ //#region src/node/utils/slugify.ts
325
+ const rControl = /[\u0000-\u001F]/g;
326
+ const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
327
+ const rCombining = /[\u0300-\u036F]/g;
328
+ /**
329
+ * Default slugification function
330
+ */
331
+ function slugify(str) {
332
+ return str.normalize("NFKD").replace(rCombining, "").replace(rControl, "").replace(rSpecial, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").replace(/^(\d)/, "_$1").toLowerCase();
333
+ }
334
+ //#endregion
335
+ //#region src/node/utils/stringify-attrs.ts
336
+ /**
337
+ * Stringify attributes object to HTML attribute string
338
+ *
339
+ * 将属性对象字符串化为 HTML 属性字符串
340
+ *
341
+ * @param attrs - Attributes object / 属性对象
342
+ * @param withUndefinedOrNull - Whether to include undefined/null values / 是否包含 undefined/null 值
343
+ * @param forceStringify - Keys to force stringify / 强制字符串化的键
344
+ * @returns HTML attribute string / HTML 属性字符串
345
+ * @typeParam T - Attribute type / 属性类型
346
+ */
347
+ function stringifyAttrs(attrs, withUndefinedOrNull = false, forceStringify = []) {
348
+ const result = objectEntries(attrs).map(([key, value]) => {
349
+ const k = kebabCase(key);
350
+ if (isUndefined(value) || value === "undefined") return withUndefinedOrNull ? `:${k}="undefined"` : "";
351
+ if (isNull(value) || value === "null") return withUndefinedOrNull ? `:${k}="null"` : "";
352
+ if (value === "true") value = true;
353
+ if (value === "false") value = false;
354
+ if (isBoolean(value)) return value ? `${k}` : "";
355
+ if (isNumber(value)) return `:${k}="${value}"`;
356
+ if (isString(value) && (value[0] === "{" || value[0] === "[")) {
357
+ const v = value.replaceAll("\"", "'");
358
+ if (forceStringify.includes(key)) return `${k}="${v}"`;
359
+ return `:${k}="${v}"`;
360
+ }
361
+ return `${isString(key) && key[0] === ":" ? ":" : ""}${k}="${String(value)}"`;
362
+ }).filter(Boolean).join(" ");
363
+ return result ? ` ${result}` : "";
364
+ }
365
+ //#endregion
366
+ //#region src/node/utils/treat-as-html.ts
367
+ const KNOWN_EXTENSIONS = /* @__PURE__ */ new Set();
368
+ function treatAsHtml(filename) {
369
+ if (KNOWN_EXTENSIONS.size === 0) {
370
+ const extraExts = typeof process === "object" && process.env?.VITE_EXTRA_EXTENSIONS || import.meta.env?.VITE_EXTRA_EXTENSIONS || "";
371
+ `3g2,3gp,aac,ai,apng,au,avif,bin,bmp,cer,class,conf,crl,css,csv,dll,doc,eps,epub,exe,gif,gz,ics,ief,jar,jpe,jpeg,jpg,js,json,jsonld,m4a,man,mid,midi,mjs,mov,mp2,mp3,mp4,mpe,mpeg,mpg,mpp,oga,ogg,ogv,ogx,opus,otf,p10,p7c,p7m,p7s,pdf,png,ps,qt,roff,rtf,rtx,ser,svg,t,tif,tiff,tr,ts,tsv,ttf,txt,vtt,wav,weba,webm,webp,woff,woff2,xhtml,xml,yaml,yml,zip${extraExts && typeof extraExts === "string" ? `,${extraExts}` : ""}`.split(",").forEach((ext) => KNOWN_EXTENSIONS.add(ext));
372
+ }
373
+ const ext = filename.split(".").pop();
374
+ return ext == null || !KNOWN_EXTENSIONS.has(ext.toLowerCase());
375
+ }
376
+ //#endregion
377
+ //#region src/node/vitepress/get-vitepress-config.ts
378
+ function getVitepressConfig() {
379
+ const config = globalThis.VITEPRESS_CONFIG;
380
+ if (!config) throw new Error("VITEPRESS_CONFIG is not initialized");
381
+ return config;
382
+ }
383
+ //#endregion
384
+ //#region src/node/vitepress/createLocales.ts
385
+ /**
386
+ * 创建 locales
387
+ * @param builtinLocales 内置的 locales
388
+ * @param userLocales 用户的 locales
389
+ * @returns locales
390
+ */
391
+ function createLocales(builtinLocales, userLocales = {}) {
392
+ const locales = {};
393
+ const vitepressLocales = getVitepressConfig().userConfig.locales || {};
394
+ for (const [key, { lang }] of objectEntries(vitepressLocales)) for (const [langs, localeData] of builtinLocales) if (langs.includes(key) || lang && langs.includes(lang)) {
395
+ locales[key] = localeData;
396
+ break;
397
+ }
398
+ if (!locales.root) locales.root = builtinLocales[0][1];
399
+ deepMerge(locales, userLocales);
400
+ return locales;
401
+ }
402
+ //#endregion
403
+ //#region src/node/vitepress/get-locale-with-path.ts
404
+ function getLocaleWithPath(path) {
405
+ const locales = getVitepressConfig().userConfig?.locales || {};
406
+ const keys = objectKeys(locales);
407
+ const key = keys.find((locale) => path.startsWith(locale)) || keys[0] || "";
408
+ if (!key || !locales[key]) return {
409
+ lang: "",
410
+ locale: ""
411
+ };
412
+ return {
413
+ lang: locales[key].lang || key,
414
+ locale: key === "root" ? "" : key
415
+ };
416
+ }
417
+ //#endregion
418
+ //#region src/node/vitepress/resolve-route-link.ts
419
+ const indexRE = /(^|.*\/)index.md(.*)$/i;
420
+ function resolveRouteLink(url, env) {
421
+ if (isExternal(url)) return url;
422
+ const config = getVitepressConfig();
423
+ if (url.startsWith("/")) return slash(config.site.base + url);
424
+ if (url.startsWith("#")) return decodeURI(normalizeHash(url));
425
+ const { pathname, protocol } = new URL(url, "http://a.com");
426
+ if (!url.startsWith("#") && protocol.startsWith("http") && treatAsHtml(pathname)) {
427
+ const indexMatch = url.match(indexRE);
428
+ if (indexMatch) {
429
+ const [, path, hash] = indexMatch;
430
+ url = path + normalizeHash(hash);
431
+ } else {
432
+ let cleanUrl = url.replace(/[?#].*$/, "");
433
+ if (cleanUrl.endsWith(".md")) cleanUrl = cleanUrl.replace(/\.md$/, env.cleanUrls ? "" : ".html");
434
+ if (!env.cleanUrls && !cleanUrl.endsWith(".html") && !cleanUrl.endsWith("/")) cleanUrl += ".html";
435
+ const parsed = new URL(url, "http://a.com");
436
+ url = cleanUrl + parsed.search + normalizeHash(parsed.hash);
437
+ }
438
+ if (!url.startsWith("/") && !url.startsWith("./")) url = `./${url}`;
439
+ }
440
+ return url;
441
+ }
442
+ function normalizeHash(str) {
443
+ return str ? encodeURI(`#${slugify(decodeURI(str).slice(1))}`) : "";
444
+ }
445
+ //#endregion
446
+ export { EXTENSION_AUDIOS, EXTENSION_IMAGES, EXTENSION_VIDEOS, EXTERNAL_URL_RE, URL_PROTOCOL_RE, createContainerPlugin, createContainerSyntaxPlugin, createEmbedRuleBlock, createLocales, createLogger, getLocaleWithPath, getVitepressConfig, isExternal, isLinkWithProtocol, logLevels, parseRect, resolveAttr, resolveAttrs, resolveRouteLink, slugify, stringifyAttrs, treatAsHtml };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "vitepress-plugin-toolkit",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "Development toolkit for vitepress plugins",
6
+ "author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "vitepress",
10
+ "vitepress-plugin",
11
+ "toolkit"
12
+ ],
13
+ "sideEffects": false,
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/node/index.d.ts",
17
+ "default": "./dist/node/index.js"
18
+ },
19
+ "./client": {
20
+ "browser": "./dist/client/browser/index.js",
21
+ "default": "./dist/client/ssr/index.js"
22
+ },
23
+ "./styles/*": "./dist/client/styles/*"
24
+ },
25
+ "module": "./dist/node/index.js",
26
+ "types": "./dist/node/index.d.ts",
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "peerDependencies": {
31
+ "vitepress": "^1.6.4 || ^2.0.0-alpha.17",
32
+ "vue": "^3.5.0"
33
+ },
34
+ "dependencies": {
35
+ "@pengzhanbo/utils": "^3.7.3",
36
+ "@vueuse/core": "^14.3.0",
37
+ "ansis": "^4.3.1",
38
+ "markdown-it-container": "^4.0.0"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "clean": "rimraf --glob ./dist",
45
+ "dev": "pnpm '/(tsdown|copy):watch/'",
46
+ "build": "pnpm tsdown && pnpm copy",
47
+ "copy": "cpx \"src/**/*.css\" dist",
48
+ "copy:watch": "pnpm copy -w",
49
+ "tsdown": "tsdown --config-loader unrun"
50
+ }
51
+ }