feat: implement icon directive with relative path fallback

Refactor icon rendering to use a custom `marked-directive` instead of
a global icon prefix. This allows icons to use a CSS-based fallback
chain (searching up to 10 directory levels for an `assets/` folder),
making icon paths more robust and removing the need to pass path
prefixes through the component tree.
This commit is contained in:
hypercross 2026-07-08 16:24:09 +08:00
parent 749b1a6a4b
commit 2dba19e1f8
5 changed files with 69 additions and 93 deletions

View File

@ -7,20 +7,17 @@ import {
onCleanup, onCleanup,
Show, Show,
createResource, createResource,
createMemo,
createSignal, createSignal,
} from "solid-js"; } from "solid-js";
import { parseMarkdown } from "../markdown"; import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader"; import { extractSection } from "../data-loader";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index"; import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path";
import { useNavigateWithParams } from "./useNavigateWithParams"; import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps { export interface ArticleProps {
src: string; src: string;
section?: string; // 指定要显示的标题(不含 # section?: string; // 指定要显示的标题(不含 #
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
onLoaded?: () => void; onLoaded?: () => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制 class?: string; // 额外的 class 用于样式控制
@ -83,12 +80,6 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
); );
const [contentDom, setContentDom] = createSignal<HTMLDivElement>(); const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
if (props.iconPath === "") return undefined; // 空字符串禁用图标前缀
return resolvePath(props.src, props.iconPath ?? "./assets");
});
createEffect(() => { createEffect(() => {
const data = content(); const data = content();
if (data) { if (data) {
@ -144,7 +135,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
<div <div
class="relative" class="relative"
ref={setContentDom} ref={setContentDom}
innerHTML={parseMarkdown(content()!, iconPrefix())} innerHTML={parseMarkdown(content()!)}
/> />
{props.children} {props.children}
</ArticleDomCtx.Provider> </ArticleDomCtx.Provider>

View File

@ -4,7 +4,6 @@ import { getLayerStyle } from "./hooks/dimensions";
import type { CardData, CardSide, LayerConfig } from "./types"; import type { CardData, CardSide, LayerConfig } from "./types";
import { DeckStore } from "./hooks/deckStore"; import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader"; import { processVariables } from "../utils/csv-loader";
import { resolvePath } from "../utils/path";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction"; import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
export interface CardLayerProps { export interface CardLayerProps {
@ -26,13 +25,8 @@ export function CardLayer(props: CardLayerProps) {
const draggingState = () => props.store.state.draggingState; const draggingState = () => props.store.state.draggingState;
function renderLayerContent(content: string) { function renderLayerContent(content: string) {
const iconPath = resolvePath(
props.store.state.cards.sourcePath,
props.cardData.iconPath ?? "./assets",
);
return parseMarkdown( return parseMarkdown(
processVariables(content, props.cardData, props.store.state.cards), processVariables(content, props.cardData, props.store.state.cards),
iconPath,
) as string; ) as string;
} }

View File

@ -1,5 +1,5 @@
import { customElement, noShadowDOM } from "solid-element"; import { customElement, noShadowDOM } from "solid-element";
import { createResource, Show, createMemo, createEffect } from "solid-js"; import { createResource, Show, createEffect } from "solid-js";
import { getIndexedData } from "../data-loader/file-index"; import { getIndexedData } from "../data-loader/file-index";
import { extractSection } from "../data-loader"; import { extractSection } from "../data-loader";
import { parseMarkdown } from "../markdown"; import { parseMarkdown } from "../markdown";
@ -27,9 +27,6 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
const articlePath = articleEl?.getAttribute("data-src") || ""; const articlePath = articleEl?.getAttribute("data-src") || "";
const resolvedPath = resolvePath(articlePath, filePath); const resolvedPath = resolvePath(articlePath, filePath);
// 计算嵌入文件的 iconPrefix用 createMemo 包装避免每个 embed 都重新计算
const iconPrefix = createMemo(() => resolvePath(resolvedPath, "./assets"));
const [content] = createResource( const [content] = createResource(
() => ({ path: resolvedPath, section }), () => ({ path: resolvedPath, section }),
async ({ path, section }) => { async ({ path, section }) => {
@ -56,10 +53,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
</Show> </Show>
<Show when={!content.loading && !content.error && content()}> <Show when={!content.loading && !content.error && content()}>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */} {/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div <div class="prose" innerHTML={parseMarkdown(content()!)} />
class="prose"
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
</Show> </Show>
</div> </div>
); );

62
src/markdown/icon.ts Normal file
View File

@ -0,0 +1,62 @@
/**
* marked-directive :[icon] :[icon.ext]
*
* CSS fallback
* url('./assets/icon.png'),
* url('../assets/icon.png'),
* url('../../assets/icon.png'),
* ...
*
* .svg, .png, .gif, .jpg, .jpeg, .webp .png
*/
const SUPPORTED_EXTENSIONS = ["svg", "png", "gif", "jpg", "jpeg", "webp"];
/** 生成多层 fallback 路径,向上查找最多 10 级 */
function buildIconSrc(iconName: string, extension: string): string {
const levels = 10;
const urls: string[] = [];
for (let i = 0; i < levels; i++) {
const prefix = i === 0 ? "./" : "../".repeat(i);
urls.push(`url('${prefix}assets/${iconName}.${extension}')`);
}
return urls.join(", ");
}
export const iconDirective = {
level: "inline" as const,
marker: ":",
renderer(token: any) {
// meta.name 非空表示这是一个命名指令(如 :div不是图标
if (token.meta.name) return false;
const iconText = token.text || "";
let iconName = iconText;
let extension = "png";
const lastDotIndex = iconName.lastIndexOf(".");
if (lastDotIndex > 0) {
const potentialExt = iconName.slice(lastDotIndex + 1).toLowerCase();
if (SUPPORTED_EXTENSIONS.includes(potentialExt)) {
extension = potentialExt;
iconName = iconName.slice(0, lastDotIndex);
}
}
const label = token.attrs?.label as string | undefined;
const inner = label
? `<span class="icon-label-stroke">${label}</span><span class="icon-label">${label}</span>`
: "";
const style = `style="--icon-src: ${buildIconSrc(iconName, extension)}"`;
const iconHtml = `<icon ${style} class="icon-${iconName} ${token.attrs?.class || ""}">${inner}</icon>`;
const repeat = parseInt(`${token.attrs?.repeat || ""}`);
const join = token.attrs?.join || "";
const separator = join ? `<${join}></${join}>` : "";
if (isNaN(repeat) || repeat < 1) return iconHtml;
return Array(repeat).fill(iconHtml).join(separator);
},
};

View File

@ -6,14 +6,7 @@ import markedTable from "./table";
import { gfmHeadingId } from "marked-gfm-heading-id"; import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns"; import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag"; import markedCodeBlockYamlTag from "./code-block-yaml-tag";
import { iconDirective } from "./icon";
let globalIconPrefix: string | undefined = undefined;
function overrideIconPrefix(path?: string) {
globalIconPrefix = path;
return () => {
globalIconPrefix = undefined;
};
}
// 使用 marked-directive 来支持指令语法 // 使用 marked-directive 来支持指令语法
const marked = new Marked() const marked = new Marked()
@ -33,73 +26,15 @@ const marked = new Marked()
marker: ":::::", marker: ":::::",
level: "container", level: "container",
}, },
{ iconDirective,
level: "inline",
marker: ":",
// :[icon] 或 :[icon.ext] 语法
// 支持的扩展名: .svg, .png, .gif, .jpg, .jpeg, .webp
// 如果不指定扩展名,默认为 .png
renderer(token) {
if (!token.meta.name) {
const iconText = token.text || "";
// 已知支持的图片扩展名
const supportedExtensions = [
"svg",
"png",
"gif",
"jpg",
"jpeg",
"webp",
];
// 检查是否包含扩展名(查找最后一个点)
let iconName = iconText;
let extension = "png"; // 默认扩展名
const lastDotIndex = iconName.lastIndexOf(".");
if (lastDotIndex > 0) {
const potentialExt = iconName
.slice(lastDotIndex + 1)
.toLowerCase();
if (supportedExtensions.includes(potentialExt)) {
extension = potentialExt;
iconName = iconName.slice(0, lastDotIndex);
}
}
const label = token.attrs?.label as string | undefined;
const inner = label
? `<span class="icon-label-stroke">${label}</span><span class="icon-label">${label}</span>`
: "";
const style = globalIconPrefix
? `style="--icon-src: url('${globalIconPrefix}/${iconName}.${extension}')"`
: "";
const iconHtml = `<icon ${style} class="icon-${iconName} ${token.attrs?.class || ""}">${inner}</icon>`;
const repeat = parseInt(`${token.attrs?.repeat || ""}`);
const join = token.attrs?.join || "";
const separator = join ? `<${join}></${join}>` : "";
if (isNaN(repeat) || repeat < 1) return iconHtml;
return Array(repeat).fill(iconHtml).join(separator);
}
return false;
},
},
]), ]),
{ {
extensions: [...markedColumns()], extensions: [...markedColumns()],
}, },
); );
export function parseMarkdown(content: string, iconPrefix?: string): string { export function parseMarkdown(content: string): string {
const restore = overrideIconPrefix(iconPrefix);
try {
return marked.parse(content.trimStart()) as string; return marked.parse(content.trimStart()) as string;
} finally {
restore();
}
} }
export { marked }; export { marked };