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:
parent
749b1a6a4b
commit
2dba19e1f8
|
|
@ -7,20 +7,17 @@ import {
|
|||
onCleanup,
|
||||
Show,
|
||||
createResource,
|
||||
createMemo,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { parseMarkdown } from "../markdown";
|
||||
import { extractSection } from "../data-loader";
|
||||
import mermaid from "mermaid";
|
||||
import { getIndexedData } from "../data-loader/file-index";
|
||||
import { resolvePath } from "./utils/path";
|
||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||
|
||||
export interface ArticleProps {
|
||||
src: string;
|
||||
section?: string; // 指定要显示的标题(不含 #)
|
||||
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
|
||||
onLoaded?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
class?: string; // 额外的 class 用于样式控制
|
||||
|
|
@ -83,12 +80,6 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
|||
);
|
||||
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
|
||||
|
||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
||||
const iconPrefix = createMemo(() => {
|
||||
if (props.iconPath === "") return undefined; // 空字符串禁用图标前缀
|
||||
return resolvePath(props.src, props.iconPath ?? "./assets");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const data = content();
|
||||
if (data) {
|
||||
|
|
@ -144,7 +135,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
|||
<div
|
||||
class="relative"
|
||||
ref={setContentDom}
|
||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||
innerHTML={parseMarkdown(content()!)}
|
||||
/>
|
||||
{props.children}
|
||||
</ArticleDomCtx.Provider>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { getLayerStyle } from "./hooks/dimensions";
|
|||
import type { CardData, CardSide, LayerConfig } from "./types";
|
||||
import { DeckStore } from "./hooks/deckStore";
|
||||
import { processVariables } from "../utils/csv-loader";
|
||||
import { resolvePath } from "../utils/path";
|
||||
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
|
||||
|
||||
export interface CardLayerProps {
|
||||
|
|
@ -26,13 +25,8 @@ export function CardLayer(props: CardLayerProps) {
|
|||
const draggingState = () => props.store.state.draggingState;
|
||||
|
||||
function renderLayerContent(content: string) {
|
||||
const iconPath = resolvePath(
|
||||
props.store.state.cards.sourcePath,
|
||||
props.cardData.iconPath ?? "./assets",
|
||||
);
|
||||
return parseMarkdown(
|
||||
processVariables(content, props.cardData, props.store.state.cards),
|
||||
iconPath,
|
||||
) as string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { extractSection } from "../data-loader";
|
||||
import { parseMarkdown } from "../markdown";
|
||||
|
|
@ -27,9 +27,6 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
|||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||
const resolvedPath = resolvePath(articlePath, filePath);
|
||||
|
||||
// 计算嵌入文件的 iconPrefix,用 createMemo 包装避免每个 embed 都重新计算
|
||||
const iconPrefix = createMemo(() => resolvePath(resolvedPath, "./assets"));
|
||||
|
||||
const [content] = createResource(
|
||||
() => ({ path: resolvedPath, section }),
|
||||
async ({ path, section }) => {
|
||||
|
|
@ -56,10 +53,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
|||
</Show>
|
||||
<Show when={!content.loading && !content.error && content()}>
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
||||
<div
|
||||
class="prose"
|
||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||
/>
|
||||
<div class="prose" innerHTML={parseMarkdown(content()!)} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -6,14 +6,7 @@ import markedTable from "./table";
|
|||
import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||
import markedColumns from "./columns";
|
||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
||||
|
||||
let globalIconPrefix: string | undefined = undefined;
|
||||
function overrideIconPrefix(path?: string) {
|
||||
globalIconPrefix = path;
|
||||
return () => {
|
||||
globalIconPrefix = undefined;
|
||||
};
|
||||
}
|
||||
import { iconDirective } from "./icon";
|
||||
|
||||
// 使用 marked-directive 来支持指令语法
|
||||
const marked = new Marked()
|
||||
|
|
@ -33,73 +26,15 @@ const marked = new Marked()
|
|||
marker: ":::::",
|
||||
level: "container",
|
||||
},
|
||||
{
|
||||
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;
|
||||
},
|
||||
},
|
||||
iconDirective,
|
||||
]),
|
||||
{
|
||||
extensions: [...markedColumns()],
|
||||
},
|
||||
);
|
||||
|
||||
export function parseMarkdown(content: string, iconPrefix?: string): string {
|
||||
const restore = overrideIconPrefix(iconPrefix);
|
||||
try {
|
||||
export function parseMarkdown(content: string): string {
|
||||
return marked.parse(content.trimStart()) as string;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
export { marked };
|
||||
|
|
|
|||
Loading…
Reference in New Issue