vitepress-plugin-filelist 0.6.2 → 0.7.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.
@@ -92,6 +92,24 @@ interface FileListLabels {
92
92
  unlockFolder: string;
93
93
  /** @deprecated Network failures now append `corsHint`. */
94
94
  networkHint: string;
95
+ /** Deep search: the row at the end of a filtered list. `{count}` where noted */
96
+ deepHint: string;
97
+ deepSearch: string;
98
+ deepRunning: string;
99
+ deepStop: string;
100
+ deepDone: string;
101
+ deepCapped: string;
102
+ deepContinue: string;
103
+ deepSkipped: string;
104
+ deepFailed: string;
105
+ /** Markdown / HTML preview: the source / rendered switch. */
106
+ viewMode: string;
107
+ viewSource: string;
108
+ viewRendered: string;
109
+ /** Above a rendered HTML file. */
110
+ htmlSandboxNote: string;
111
+ /** Stands in for a Markdown image with a relative path. `{alt}` */
112
+ imageOmitted: string;
95
113
  /** Title of the dialog when a batch ZIP fails (the reason is the text). */
96
114
  batchFailedTitle: string;
97
115
  batchFailedHint: string;
@@ -156,6 +174,11 @@ interface FileListProps {
156
174
  * everything. Data is still loaded in full; only rendering is limited.
157
175
  */
158
176
  pageSize?: number;
177
+ /**
178
+ * Deep search loads at most this many folders per click (“Continue” goes on).
179
+ * Defaults to `500`.
180
+ */
181
+ deepSearchLimit?: number;
159
182
  /**
160
183
  * Browsers that must build the ZIP in memory (Firefox, Safari) ask first above
161
184
  * this many MB. Defaults to 500 (200 on touch devices); `0` never asks.
@@ -192,6 +215,7 @@ declare const _default: import("vue").DefineComponent<FileListProps, {}, {}, {},
192
215
  title: string;
193
216
  rememberPassword: boolean;
194
217
  pageSize: number;
218
+ deepSearchLimit: number;
195
219
  locale: FileListLocale;
196
220
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
197
221
  declare global {
@@ -310,7 +334,7 @@ export declare function previewKind(name: string): PreviewKind;
310
334
  export declare function totalSize(items: Array<{
311
335
  size?: number;
312
336
  }>): number;
313
- export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean): number;
337
+ export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean, locale?: string): number;
314
338
  interface IframePreview {
315
339
  label: string;
316
340
  scheme: string;
@@ -179,12 +179,29 @@ function previewKind(name) {
179
179
  function totalSize(items) {
180
180
  return items.reduce((sum, item) => sum + (Number.isFinite(item.size) && (item.size ?? 0) > 0 ? item.size : 0), 0);
181
181
  }
182
- function compareNodes(a, b, key, ascending = true) {
182
+ const collators = /* @__PURE__ */ new Map();
183
+ /**
184
+ * Name order for a UI locale: pinyin for Chinese, alphabetical for English,
185
+ * with numbers in natural order (“2” before “10”) either way.
186
+ */
187
+ function collatorFor(locale) {
188
+ const tag = locale.toLowerCase().startsWith("zh") ? "zh-Hans-CN" : "en";
189
+ let collator = collators.get(tag);
190
+ if (!collator) {
191
+ collator = new Intl.Collator(tag, {
192
+ numeric: true,
193
+ sensitivity: "base"
194
+ });
195
+ collators.set(tag, collator);
196
+ }
197
+ return collator;
198
+ }
199
+ function compareNodes(a, b, key, ascending = true, locale = "zh-CN") {
183
200
  if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
184
201
  let result = 0;
185
202
  if (key === "size") result = a.size - b.size;
186
203
  else if (key === "modified") result = new Date(a.modified).getTime() - new Date(b.modified).getTime();
187
- else result = a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true });
204
+ else result = collatorFor(locale).compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
188
205
  return ascending ? result : -result;
189
206
  }
190
207
  /**
@@ -852,6 +869,20 @@ const fileListLocales = {
852
869
  forgetPassword: "忘记",
853
870
  unlockFolder: "输入 {name} 的密码",
854
871
  networkHint: "可能被跨域拦截:请检查 OpenList 的 cors.allow_origins,或 OpenList-Worker 的 ALLOW_URLS。",
872
+ deepHint: "只筛选了已加载的内容",
873
+ deepSearch: "深度搜索",
874
+ deepRunning: "正在深度搜索 · 已查找 {count} 个文件夹",
875
+ deepStop: "停止",
876
+ deepDone: "已搜索全部 {count} 个文件夹",
877
+ deepCapped: "已查找 {count} 个文件夹,还有未搜索的子目录",
878
+ deepContinue: "继续",
879
+ deepSkipped: "跳过 {count} 个加密文件夹",
880
+ deepFailed: "{count} 个文件夹读取失败",
881
+ viewMode: "显示方式",
882
+ viewSource: "源码",
883
+ viewRendered: "渲染",
884
+ htmlSandboxNote: "已禁用脚本;网页引用的相对路径资源不会加载",
885
+ imageOmitted: "[图片:{alt}]",
855
886
  batchFailedTitle: "打包失败",
856
887
  batchFailedHint: "可以减少勾选的文件后重试。",
857
888
  downloadStarted: "已交给浏览器下载:{name}",
@@ -932,6 +963,20 @@ const fileListLocales = {
932
963
  forgetPassword: "Forget",
933
964
  unlockFolder: "Enter the password for {name}",
934
965
  networkHint: "Possibly blocked by CORS: check cors.allow_origins in OpenList, or ALLOW_URLS in OpenList-Worker.",
966
+ deepHint: "Only loaded folders are filtered",
967
+ deepSearch: "Deep search",
968
+ deepRunning: "Searching · {count} folders checked",
969
+ deepStop: "Stop",
970
+ deepDone: "Searched all {count} folders",
971
+ deepCapped: "{count} folders checked; some sub-folders remain",
972
+ deepContinue: "Continue",
973
+ deepSkipped: "{count} locked folders skipped",
974
+ deepFailed: "{count} folders could not be read",
975
+ viewMode: "View",
976
+ viewSource: "Source",
977
+ viewRendered: "Rendered",
978
+ htmlSandboxNote: "Scripts are disabled; resources at relative paths do not load",
979
+ imageOmitted: "[Image: {alt}]",
935
980
  batchFailedTitle: "Could not create the ZIP",
936
981
  batchFailedHint: "Try again with fewer files selected.",
937
982
  downloadStarted: "Downloading {name} in your browser",
@@ -960,6 +1005,45 @@ function resolveLabels(locale = "zh-CN", overrides) {
960
1005
  ...overrides
961
1006
  };
962
1007
  }
1008
+ // src/client/markdown.ts
1009
+ /**
1010
+ * Markdown → safe HTML for the preview dialog. Loaded on demand, so pages that
1011
+ * never render Markdown do not download markdown-it or DOMPurify.
1012
+ *
1013
+ * - Raw HTML in the file is allowed, then sanitised (no scripts, handlers, iframes...)
1014
+ * - Only absolute http(s) / mailto links stay clickable, opening in a new tab;
1015
+ * relative links (to other files in the share) become plain text
1016
+ * - Images with a relative source are replaced by their alt text, because the
1017
+ * share's relative paths do not resolve on the documentation site
1018
+ */
1019
+ async function renderMarkdown(source, imageLabel) {
1020
+ const [{ default: MarkdownIt }, { default: DOMPurify }] = await Promise.all([import("markdown-it"), import("dompurify")]);
1021
+ const probe = DOMPurify.isSupported ? DOMPurify.sanitize("<img src=\"x\" onerror=\"1\"><script>1<\/script>") : "<script";
1022
+ if (/<script|onerror/i.test(probe)) throw new Error("HTML sanitiser unavailable");
1023
+ const html = new MarkdownIt({
1024
+ html: true,
1025
+ linkify: true
1026
+ }).render(source);
1027
+ const template = document.createElement("template");
1028
+ template.innerHTML = DOMPurify.sanitize(html);
1029
+ const fragment = template.content;
1030
+ for (const link of fragment.querySelectorAll("a")) {
1031
+ const href = link.getAttribute("href") ?? "";
1032
+ if (/^(https?:|mailto:)/i.test(href)) {
1033
+ link.setAttribute("target", "_blank");
1034
+ link.setAttribute("rel", "noopener noreferrer");
1035
+ } else link.replaceWith(...link.childNodes);
1036
+ }
1037
+ for (const image of fragment.querySelectorAll("img")) {
1038
+ const src = image.getAttribute("src") ?? "";
1039
+ if (/^(https?:|data:image\/)/i.test(src)) continue;
1040
+ const placeholder = document.createElement("span");
1041
+ placeholder.className = "fl-md-image";
1042
+ placeholder.textContent = imageLabel(image.getAttribute("alt") || src.split("/").pop() || "");
1043
+ image.replaceWith(placeholder);
1044
+ }
1045
+ return template.innerHTML;
1046
+ }
963
1047
  // src/client/PreviewDialog.vue
964
1048
  const _sfc_main$1 = /*@__PURE__*/ defineComponent({
965
1049
  __name: "PreviewDialog",
@@ -994,7 +1078,9 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
994
1078
  /**
995
1079
  * File preview in a native <dialog>, without leaving the page.
996
1080
  *
997
- * image / video / audio: native elements; text: the first `maxTextBytes`
1081
+ * image / video / audio: native elements; text: the first `maxTextBytes`,
1082
+ * with Markdown and HTML switchable between source (default) and rendered
1083
+ * (Markdown sanitised in VitePress' doc style, HTML in a script-less sandbox);
998
1084
  * through a Range request; pdf: the first matching OpenList `iframe_previews`
999
1085
  * viewer, else the browser's reader through a Blob. Anything else (Office
1000
1086
  * included), failures, oversized PDFs and demo mode: file info + download.
@@ -1012,8 +1098,18 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1012
1098
  const blobUrl = ref("");
1013
1099
  const rotation = ref(0);
1014
1100
  const actualSize = ref(false);
1101
+ /** Markdown / HTML: show the source or render it. Kept while the page is open. */
1102
+ const view = ref("source");
1103
+ const renderedHtml = ref("");
1015
1104
  let controller;
1016
1105
  let loadSeq = 0;
1106
+ /** Text files that can also be rendered. */
1107
+ const renderAs = computed(() => {
1108
+ const name = props.file?.name ?? "";
1109
+ if (/\.(md|markdown)$/i.test(name)) return "markdown";
1110
+ if (/\.html?$/i.test(name)) return "html";
1111
+ return null;
1112
+ });
1017
1113
  const tooLarge = computed(() => kind.value === "pdf" && !props.viewer && (props.file?.size ?? 0) > props.maxPdfBytes);
1018
1114
  const mode = computed(() => {
1019
1115
  if (props.demo) return "demo";
@@ -1023,6 +1119,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1023
1119
  return kind.value;
1024
1120
  });
1025
1121
  const busy = computed(() => !props.demo && kind.value !== "none" && !props.error && !bodyFailed.value && (props.resolving || loadingBody.value || !props.src && !tooLarge.value));
1122
+ const canRender = computed(() => !!renderAs.value && mode.value === "text" && !busy.value);
1026
1123
  const infoMessage = computed(() => {
1027
1124
  const file = props.file;
1028
1125
  if (!file) return "";
@@ -1043,6 +1140,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1043
1140
  loadingBody.value = false;
1044
1141
  bodyFailed.value = false;
1045
1142
  textContent.value = "";
1143
+ renderedHtml.value = "";
1046
1144
  truncated.value = false;
1047
1145
  rotation.value = 0;
1048
1146
  actualSize.value = false;
@@ -1124,6 +1222,20 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1124
1222
  props.error,
1125
1223
  props.demo
1126
1224
  ], () => void loadBody(), { immediate: true });
1225
+ watch(() => [
1226
+ view.value,
1227
+ renderAs.value,
1228
+ textContent.value
1229
+ ], async ([current, as, text]) => {
1230
+ if (current !== "rendered" || as !== "markdown" || !text) return;
1231
+ const seq = loadSeq;
1232
+ try {
1233
+ const html = await renderMarkdown(text, (alt) => t("imageOmitted", { alt }));
1234
+ if (seq === loadSeq) renderedHtml.value = html;
1235
+ } catch {
1236
+ if (seq === loadSeq) view.value = "source";
1237
+ }
1238
+ });
1127
1239
  watch(() => props.open, (open) => {
1128
1240
  const el = dialog.value;
1129
1241
  if (!el) return;
@@ -1145,7 +1257,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1145
1257
  }, _attrs))}>`);
1146
1258
  if (__props.open && __props.file) {
1147
1259
  _push(`<div class="fl-preview-panel"><header class="fl-preview-head"><span class="fl-type"${ssrRenderAttr("data-kind", unref(fileKind)(__props.file.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(__props.file.name) || "FILE")}</span><div class="fl-preview-title"><span class="fl-preview-name">${ssrInterpolate(__props.file.name)}</span><span class="fl-preview-meta">${ssrInterpolate(unref(formatSize)(__props.file.size))} · ${ssrInterpolate(unref(formatDate)(__props.file.modified))}</span></div>`);
1148
- if (__props.total > 1 || mode.value === "image" && !busy.value) {
1260
+ if (__props.total > 1 || mode.value === "image" && !busy.value || canRender.value) {
1149
1261
  _push(`<div class="fl-preview-tools fl-preview-nav">`);
1150
1262
  if (__props.total > 1) {
1151
1263
  _push(`<!--[--><button class="fl-preview-tool" type="button"${ssrRenderAttr("aria-label", t("previous"))}${ssrRenderAttr("title", t("previous"))}>`);
@@ -1173,6 +1285,13 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1173
1285
  }, null, _parent));
1174
1286
  _push(`</button><!--]-->`);
1175
1287
  } else _push(`<!---->`);
1288
+ if (canRender.value) {
1289
+ _push(`<span class="fl-preview-view" role="group"${ssrRenderAttr("aria-label", t("viewMode"))}><!--[-->`);
1290
+ ssrRenderList(["source", "rendered"], (option) => {
1291
+ _push(`<button class="fl-preview-view-btn" type="button"${ssrRenderAttr("aria-pressed", view.value === option)}>${ssrInterpolate(t(option === "source" ? "viewSource" : "viewRendered"))}</button>`);
1292
+ });
1293
+ _push(`<!--]--></span>`);
1294
+ } else _push(`<!---->`);
1176
1295
  _push(`</div>`);
1177
1296
  } else _push(`<!---->`);
1178
1297
  _push(`<div class="fl-preview-tools fl-preview-actions">`);
@@ -1195,10 +1314,17 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1195
1314
  else if (mode.value === "video") _push(`<video class="fl-preview-video"${ssrRenderAttr("src", __props.src)} controls playsinline preload="metadata"></video>`);
1196
1315
  else if (mode.value === "audio") _push(`<audio class="fl-preview-audio"${ssrRenderAttr("src", __props.src)} controls preload="metadata"></audio>`);
1197
1316
  else if (mode.value === "text") {
1198
- _push(`<div class="fl-preview-text">`);
1317
+ _push(`<div class="${ssrRenderClass([{ "fl-preview-rendered": renderAs.value && view.value === "rendered" }, "fl-preview-text"])}">`);
1199
1318
  if (truncated.value) _push(`<p class="fl-preview-note">${ssrInterpolate(t("textTruncated", { size: unref(formatSize)(__props.maxTextBytes) }))}</p>`);
1200
1319
  else _push(`<!---->`);
1201
- _push(`<pre>${ssrInterpolate(textContent.value)}</pre></div>`);
1320
+ if (renderAs.value === "markdown" && view.value === "rendered") {
1321
+ _push(`<!--[--><!-- sanitised by renderMarkdown (DOMPurify) -->`);
1322
+ if (renderedHtml.value) _push(`<div class="vp-doc fl-md">${renderedHtml.value ?? ""}</div>`);
1323
+ else _push(`<div class="fl-preview-status" role="status">${ssrInterpolate(t("loading"))}</div>`);
1324
+ _push(`<!--]-->`);
1325
+ } else if (renderAs.value === "html" && view.value === "rendered") _push(`<!--[--><p class="fl-preview-note">${ssrInterpolate(t("htmlSandboxNote"))}</p><!-- empty sandbox: no scripts, no forms, no popups, an opaque origin --><iframe class="fl-preview-html" sandbox="" referrerpolicy="no-referrer"${ssrRenderAttr("srcdoc", textContent.value)}${ssrRenderAttr("title", __props.file.name)}></iframe><!--]-->`);
1326
+ else _push(`<pre>${ssrInterpolate(textContent.value)}</pre>`);
1327
+ _push(`</div>`);
1202
1328
  } else if (mode.value === "pdf") _push(`<iframe class="fl-preview-frame"${ssrRenderAttr("src", __props.viewer || blobUrl.value)}${ssrRenderAttr("title", __props.file.name)}></iframe>`);
1203
1329
  else {
1204
1330
  _push(`<div class="fl-preview-info"><span class="fl-type fl-preview-badge"${ssrRenderAttr("data-kind", unref(fileKind)(__props.file.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(__props.file.name) || "FILE")}</span><p class="fl-preview-message">${ssrInterpolate(infoMessage.value)}</p>`);
@@ -1247,9 +1373,9 @@ function toNodes(items, parent, password) {
1247
1373
  zipPath: [...parent.zipPath, item.name]
1248
1374
  }));
1249
1375
  }
1250
- function sortTree(nodes, key, ascending) {
1251
- nodes.sort((a, b) => compareNodes(a, b, key, ascending));
1252
- for (const node of nodes) if (node.children.length) sortTree(node.children, key, ascending);
1376
+ function sortTree(nodes, key, ascending, locale) {
1377
+ nodes.sort((a, b) => compareNodes(a, b, key, ascending, locale));
1378
+ for (const node of nodes) if (node.children.length) sortTree(node.children, key, ascending, locale);
1253
1379
  }
1254
1380
  /** Loaded files at or under a node. */
1255
1381
  function descendantFiles(node) {
@@ -1436,6 +1562,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1436
1562
  default: true
1437
1563
  },
1438
1564
  pageSize: { default: 200 },
1565
+ deepSearchLimit: { default: 500 },
1439
1566
  zipWarnSize: {},
1440
1567
  zipMaxSize: {},
1441
1568
  locale: { default: "zh-CN" },
@@ -1500,7 +1627,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1500
1627
  return isNetworkError(error) ? `${message} ${t("corsHint")}` : message;
1501
1628
  }
1502
1629
  function sortNodes(nodes) {
1503
- sortTree(nodes, sortKey.value, sortAsc.value);
1630
+ sortTree(nodes, sortKey.value, sortAsc.value, props.locale);
1504
1631
  }
1505
1632
  function focusUnlock(path) {
1506
1633
  nextTick(() => {
@@ -1532,7 +1659,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1532
1659
  * Load a folder's children. A password error locks the folder instead of
1533
1660
  * toasting; `prompt` also shows its same-row input (and focuses it).
1534
1661
  */
1535
- async function loadChildren(node, prompt = false) {
1662
+ async function loadChildren(node, prompt = false, quiet = false) {
1536
1663
  if (node.loaded) return true;
1537
1664
  if (node.lock) {
1538
1665
  if (prompt) startPrompt(node, prompt === "focus");
@@ -1548,7 +1675,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1548
1675
  node.expanded = false;
1549
1676
  node.lock = newLock(false, passwords.dropStale(rememberedAt) ? "stale" : "");
1550
1677
  if (prompt) startPrompt(node, prompt === "focus");
1551
- } else showToast(failureMessage("folderFailed", error));
1678
+ } else if (!quiet) showToast(failureMessage("folderFailed", error));
1552
1679
  return false;
1553
1680
  } finally {
1554
1681
  node.loading = false;
@@ -1635,6 +1762,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1635
1762
  }
1636
1763
  /** First load, or a retry after an error: shows the loading state. */
1637
1764
  async function loadRoot() {
1765
+ stopDeepSearch();
1638
1766
  refreshSeq += 1;
1639
1767
  refreshing.value = false;
1640
1768
  loadingRoot.value = true;
@@ -1711,6 +1839,88 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1711
1839
  return out;
1712
1840
  });
1713
1841
  const visible = computed(() => rows.value.filter((row) => !isMore(row)));
1842
+ /** A name split around the filter keyword, for highlighting. */
1843
+ function nameParts(name) {
1844
+ const query = keyword.value.trim().toLowerCase();
1845
+ if (!query) return [{
1846
+ text: name,
1847
+ hit: false
1848
+ }];
1849
+ const parts = [];
1850
+ const lower = name.toLowerCase();
1851
+ let from = 0;
1852
+ for (let at = lower.indexOf(query); at !== -1; at = lower.indexOf(query, from)) {
1853
+ if (at > from) parts.push({
1854
+ text: name.slice(from, at),
1855
+ hit: false
1856
+ });
1857
+ parts.push({
1858
+ text: name.slice(at, at + query.length),
1859
+ hit: true
1860
+ });
1861
+ from = at + query.length;
1862
+ }
1863
+ if (from < name.length) parts.push({
1864
+ text: name.slice(from),
1865
+ hit: false
1866
+ });
1867
+ return parts;
1868
+ }
1869
+ /**
1870
+ * The filter only sees loaded folders. Deep search loads the rest under the
1871
+ * mounted paths (four at a time, `deepSearchLimit` per run) so matches appear
1872
+ * in place, in their folders, like any other row. Locked folders are skipped.
1873
+ */
1874
+ const deep = reactive({
1875
+ running: false,
1876
+ searched: 0,
1877
+ skipped: 0,
1878
+ failed: 0,
1879
+ capped: false,
1880
+ done: false
1881
+ });
1882
+ let deepSeq = 0;
1883
+ /** Folders that are known but not loaded yet (locked ones excluded). */
1884
+ function unloadedFolders() {
1885
+ const out = [];
1886
+ const walk = (nodes) => {
1887
+ for (const node of nodes) {
1888
+ if (!node.is_dir || node.lock) continue;
1889
+ if (!node.loaded) out.push(node);
1890
+ else walk(node.children);
1891
+ }
1892
+ };
1893
+ walk(roots.value);
1894
+ return out;
1895
+ }
1896
+ const hasUnloaded = computed(() => unloadedFolders().length > 0);
1897
+ const deepRow = computed(() => {
1898
+ if (!keyword.value.trim()) return null;
1899
+ if (deep.running) return "running";
1900
+ if (deep.capped) return "capped";
1901
+ if (hasUnloaded.value) return "idle";
1902
+ return deep.done ? "done" : null;
1903
+ });
1904
+ function stopDeepSearch() {
1905
+ deepSeq += 1;
1906
+ deep.running = false;
1907
+ }
1908
+ watch(keyword, (value) => {
1909
+ if (value.trim()) return;
1910
+ stopDeepSearch();
1911
+ Object.assign(deep, {
1912
+ searched: 0,
1913
+ skipped: 0,
1914
+ failed: 0,
1915
+ capped: false,
1916
+ done: false
1917
+ });
1918
+ });
1919
+ const deepSummary = computed(() => [
1920
+ deepRow.value === "capped" ? t("deepCapped", { count: deep.searched }) : t("deepDone", { count: deep.searched }),
1921
+ deep.skipped ? t("deepSkipped", { count: deep.skipped }) : "",
1922
+ deep.failed ? t("deepFailed", { count: deep.failed }) : ""
1923
+ ].filter(Boolean).join(" · "));
1714
1924
  const stats = computed(() => folderStats(roots.value, selected.value));
1715
1925
  const noStats = {
1716
1926
  files: 0,
@@ -2031,7 +2241,14 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
2031
2241
  } else _push(`<span class="fl-arrow-placeholder" aria-hidden="true"></span>`);
2032
2242
  if (node.is_dir) _push(`<svg class="fl-folder-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>`);
2033
2243
  else _push(`<span class="fl-type"${ssrRenderAttr("data-kind", unref(fileKind)(node.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(node.name) || "FILE")}</span>`);
2034
- _push(`<span class="fl-label"><span class="${ssrRenderClass([{ "fl-name-dir": node.is_dir }, "fl-name"])}">${ssrInterpolate(node.name)}</span><span class="fl-meta">`);
2244
+ _push(`<span class="fl-label"><span class="${ssrRenderClass([{ "fl-name-dir": node.is_dir }, "fl-name"])}"><!--[-->`);
2245
+ ssrRenderList(nameParts(node.name), (part, at) => {
2246
+ _push(`<!--[-->`);
2247
+ if (part.hit) _push(`<mark class="fl-hit">${ssrInterpolate(part.text)}</mark>`);
2248
+ else _push(`<!--[-->${ssrInterpolate(part.text)}<!--]-->`);
2249
+ _push(`<!--]-->`);
2250
+ });
2251
+ _push(`<!--]--></span><span class="fl-meta">`);
2035
2252
  if (!node.is_dir) _push(`<!--[-->${ssrInterpolate(unref(formatSize)(node.size))} · ${ssrInterpolate(unref(formatDate)(node.modified))}<!--]-->`);
2036
2253
  else if (node.loaded) _push(`<!--[-->${ssrInterpolate(t("folderItems", { count: statsOf(node).files }))} · ${ssrInterpolate(unref(formatSize)(statsOf(node).bytes))}<!--]-->`);
2037
2254
  else _push(`<!---->`);
@@ -2103,7 +2320,20 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
2103
2320
  }
2104
2321
  _push(`<!--]-->`);
2105
2322
  });
2106
- _push(`<!--]--><div class="fl-foot"><span>${ssrInterpolate(t("loaded", { count: allFiles.value.length }))} · ${ssrInterpolate(t("totalSize", { size: unref(formatSize)(unref(totalSize)(allFiles.value)) }))}</span></div>`);
2323
+ _push(`<!--]-->`);
2324
+ if (deepRow.value) {
2325
+ _push(`<div class="fl-row fl-deep" role="status"><span class="fl-cell fl-cell-check"></span><span class="fl-cell fl-more-cell"><span class="fl-indent" aria-hidden="true"></span>`);
2326
+ if (deepRow.value === "running") _push(`<!--[--><span class="fl-more-count">${ssrInterpolate(t("deepRunning", { count: deep.searched }))}</span><button class="fl-more-btn fl-more-all" type="button">${ssrInterpolate(t("deepStop"))}</button><span class="fl-deep-bar" aria-hidden="true"></span><!--]-->`);
2327
+ else if (deepRow.value === "idle") _push(`<!--[--><span class="fl-more-count">${ssrInterpolate(deep.searched ? deepSummary.value : t("deepHint"))}</span><button class="fl-more-btn" type="button">${ssrInterpolate(t("deepSearch"))}</button><!--]-->`);
2328
+ else {
2329
+ _push(`<!--[--><span class="fl-more-count">${ssrInterpolate(deepSummary.value)}</span>`);
2330
+ if (deepRow.value === "capped") _push(`<button class="fl-more-btn" type="button">${ssrInterpolate(t("deepContinue"))}</button>`);
2331
+ else _push(`<!---->`);
2332
+ _push(`<!--]-->`);
2333
+ }
2334
+ _push(`</span></div>`);
2335
+ } else _push(`<!---->`);
2336
+ _push(`<div class="fl-foot"><span>${ssrInterpolate(t("loaded", { count: allFiles.value.length }))} · ${ssrInterpolate(t("totalSize", { size: unref(formatSize)(unref(totalSize)(allFiles.value)) }))}</span></div>`);
2107
2337
  if (toast.value) _push(`<div class="fl-toast" role="status">${ssrInterpolate(toast.value)}</div>`);
2108
2338
  else _push(`<!---->`);
2109
2339
  _push(ssrRenderComponent(_sfc_main$3, {
@@ -824,7 +824,8 @@
824
824
 
825
825
  /* ---------------- long folders ---------------- */
826
826
 
827
- .fl-more {
827
+ .fl-more,
828
+ .fl-deep {
828
829
  background: var(--vp-c-bg-soft);
829
830
  }
830
831
 
@@ -868,6 +869,37 @@
868
869
  border-color: transparent;
869
870
  }
870
871
 
872
+ /* ---------------- filter and deep search ---------------- */
873
+
874
+ .fl-hit {
875
+ padding: 0 1px;
876
+ color: inherit;
877
+ background: var(--fl-hit-bg, rgb(250 204 21 / 32%));
878
+ border-radius: 3px;
879
+ }
880
+
881
+ .fl-deep {
882
+ position: relative;
883
+ }
884
+
885
+ .fl-deep-bar {
886
+ position: absolute;
887
+ bottom: -1px;
888
+ left: 0;
889
+ width: 30%;
890
+ height: 2px;
891
+ background: var(--vp-c-brand-1);
892
+ animation: fl-progress 1.2s ease-in-out infinite;
893
+ }
894
+
895
+ @media (prefers-reduced-motion: reduce) {
896
+ .fl-deep-bar {
897
+ width: 100%;
898
+ animation: none;
899
+ opacity: 0.6;
900
+ }
901
+ }
902
+
871
903
  /* ---------------- confirm dialog ---------------- */
872
904
 
873
905
  .fl-confirm {
@@ -1316,6 +1348,62 @@
1316
1348
  background: var(--vp-c-default-soft);
1317
1349
  }
1318
1350
 
1351
+ /* Markdown / HTML: source or rendered */
1352
+ .fl-preview-view {
1353
+ display: inline-flex;
1354
+ padding: 2px;
1355
+ margin-left: 6px;
1356
+ background: var(--vp-c-default-soft);
1357
+ border-radius: 8px;
1358
+ }
1359
+
1360
+ .fl-preview-view-btn {
1361
+ height: 28px;
1362
+ padding: 0 10px;
1363
+ font-size: 12px;
1364
+ color: var(--vp-c-text-2);
1365
+ cursor: pointer;
1366
+ background: transparent;
1367
+ border: 0;
1368
+ border-radius: 6px;
1369
+ }
1370
+
1371
+ .fl-preview-view-btn[aria-pressed='true'] {
1372
+ font-weight: 600;
1373
+ color: var(--vp-c-text-1);
1374
+ background: var(--vp-c-bg);
1375
+ box-shadow: 0 1px 2px rgb(0 0 0 / 12%);
1376
+ }
1377
+
1378
+ .fl-preview-rendered {
1379
+ display: flex;
1380
+ flex-direction: column;
1381
+ }
1382
+
1383
+ .fl-preview .fl-md {
1384
+ box-sizing: border-box;
1385
+ max-width: 860px;
1386
+ padding: 16px 24px 32px;
1387
+ margin: 0 auto;
1388
+ }
1389
+
1390
+ .fl-md-image {
1391
+ display: inline-block;
1392
+ padding: 2px 8px;
1393
+ font-size: 12px;
1394
+ color: var(--vp-c-text-2);
1395
+ border: 1px dashed var(--vp-c-divider);
1396
+ border-radius: 6px;
1397
+ }
1398
+
1399
+ .fl-preview-html {
1400
+ flex: 1 1 auto;
1401
+ width: 100%;
1402
+ min-height: 0;
1403
+ background: #fff;
1404
+ border: 0;
1405
+ }
1406
+
1319
1407
  .fl-preview-count {
1320
1408
  min-width: 44px;
1321
1409
  font-size: 12px;
@@ -1487,6 +1575,12 @@
1487
1575
  min-width: 36px;
1488
1576
  }
1489
1577
 
1578
+ .fl-preview-view-btn {
1579
+ height: 36px;
1580
+ padding: 0 14px;
1581
+ font-size: 14px;
1582
+ }
1583
+
1490
1584
  .fl-preview .fl-preview-head .fl-type {
1491
1585
  display: none;
1492
1586
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitepress-plugin-filelist",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "packageManager": "pnpm@11.25.0",
5
5
  "type": "module",
6
6
  "description": "OpenList file browser for VitePress: tree view, batch ZIP download, i18n, password and token support.",
@@ -42,7 +42,9 @@
42
42
  "typecheck": "vue-tsc --noEmit",
43
43
  "test": "vitest run",
44
44
  "test:watch": "vitest",
45
- "pack:check": "npm pack --dry-run"
45
+ "pack:check": "npm pack --dry-run",
46
+ "test:e2e": "playwright test",
47
+ "e2e:site": "pnpm run build && pnpm --dir demo install --force && pnpm --dir demo run build:e2e"
46
48
  },
47
49
  "peerDependencies": {
48
50
  "vitepress": "^1.0.0 || ^2.0.0-alpha.0",
@@ -50,9 +52,13 @@
50
52
  },
51
53
  "dependencies": {
52
54
  "client-zip": "^2.5.1",
55
+ "dompurify": "^3.4.17",
56
+ "markdown-it": "^14.3.3",
53
57
  "vitepress-tuck": "^0.12.2"
54
58
  },
55
59
  "devDependencies": {
60
+ "@playwright/test": "1.63.0",
61
+ "@types/markdown-it": "^14.2.0",
56
62
  "@types/node": "^22.10.0",
57
63
  "@vue/test-utils": "^2.5.1",
58
64
  "happy-dom": "^21.0.4",