diff --git a/.changeset/content-reader.md b/.changeset/content-reader.md new file mode 100644 index 0000000..d897849 --- /dev/null +++ b/.changeset/content-reader.md @@ -0,0 +1,7 @@ +--- +"@inkcre/ui-web": minor +--- + +新增隔离的 HTML/GFM 阅读组件,保留代码、表格、链接并阻止内容脚本和资源自动加载。 + +同时将消费者已采用的一行 InkInput 修复纳入源码,避免新包升级后恢复普通可编辑输入的重复值与 ID。 diff --git a/packages/web/MIGRATION.md b/packages/web/MIGRATION.md index 9c2b399..7f33821 100644 --- a/packages/web/MIGRATION.md +++ b/packages/web/MIGRATION.md @@ -6,6 +6,10 @@ +### HTML 与 Markdown 阅读 + +新增 `InkContentReader`,按明确格式安全阅读已有内容。只接收内容和格式,不接管来源或持久化;完整行为见 ContentReader Story。现有组件接口不变。普通可编辑 `InkInput` 不再额外渲染同值、同 ID 的展示 span;升级后可移除针对旧包相同行为的补丁。 + ## 2.6.0 ### Logo 资产 diff --git a/packages/web/component-manifest.json b/packages/web/component-manifest.json index e01437d..225c9d7 100644 --- a/packages/web/component-manifest.json +++ b/packages/web/component-manifest.json @@ -11,6 +11,11 @@ "source": "inkButton", "category": "controls" }, + { + "name": "InkContentReader", + "source": "inkContentReader", + "category": "media" + }, { "name": "InkDatetimePickerView", "source": "inkDatetimePickerView", diff --git a/packages/web/package.json b/packages/web/package.json index e0a45a8..c00439e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -110,6 +110,8 @@ "@vue/shared": "3.5.25", "@vueuse/core": "^14.0.0", "dayjs": "^1.11.0", + "dompurify": "3.4.8", + "marked": "18.1.2", "vscode-json-languageservice": "5.6.4", "vscode-languageserver-textdocument": "^1.0.12" }, diff --git a/packages/web/skill.seed.json b/packages/web/skill.seed.json index 74f4df7..11a8b7d 100644 --- a/packages/web/skill.seed.json +++ b/packages/web/skill.seed.json @@ -15,7 +15,8 @@ "Loading state": "InkLoading;已知结构首次读取使用 InkSkeleton", "Empty or error state": "InkPlaceholder", "Expandable media": "InkImage", - "Paged collection": "InkPagination" + "Paged collection": "InkPagination", + "HTML or Markdown reading": "InkContentReader" }, "rules": [ "Choose components by product intent and interaction semantics, not only visual resemblance.", @@ -482,6 +483,14 @@ "commonMistakes": [ "Do not hide validation errors, destructive consequences, or required instructions in a tooltip." ] + }, + "InkContentReader": { + "intent": ["Read untrusted HTML or GFM Markdown without executing source code."], + "preferWhen": ["A content string has an explicit html or markdown format."], + "avoidWhen": [ + "Plain text must remain literal, or the application needs editing or remote media loading." + ], + "composeWith": [] } }, "recipes": [ diff --git a/packages/web/skills/ui-web/SKILL.md b/packages/web/skills/ui-web/SKILL.md index bf6faae..6d30e9c 100644 --- a/packages/web/skills/ui-web/SKILL.md +++ b/packages/web/skills/ui-web/SKILL.md @@ -36,6 +36,7 @@ matches a product task. - Empty or error state: InkPlaceholder - Expandable media: InkImage - Paged collection: InkPagination +- HTML or Markdown reading: InkContentReader ## Import And Registration diff --git a/packages/web/skills/ui-web/references/component-map.md b/packages/web/skills/ui-web/references/component-map.md index 4a232f6..7602985 100644 --- a/packages/web/skills/ui-web/references/component-map.md +++ b/packages/web/skills/ui-web/references/component-map.md @@ -35,6 +35,7 @@ component references relevant to the task. ## media +- [InkContentReader](components/InkContentReader.md): Read untrusted HTML or GFM Markdown without executing source code. - [InkImage](components/InkImage.md): image display; thumbnail; click-to-expand media ## overlays diff --git a/packages/web/skills/ui-web/references/components/InkContentReader.md b/packages/web/skills/ui-web/references/components/InkContentReader.md new file mode 100644 index 0000000..08d508d --- /dev/null +++ b/packages/web/skills/ui-web/references/components/InkContentReader.md @@ -0,0 +1,56 @@ + + +# InkContentReader + +## Intent + +- Read untrusted HTML or GFM Markdown without executing source code. + +## Prefer When + +- A content string has an explicit html or markdown format. + +## Avoid When + +- Plain text must remain literal, or the application needs editing or remote media loading. + +## Compose With + +None + +## Public API Facts + +- Import: `import { InkContentReader } from "@inkcre/ui-web";` + +### Models + +- None. + +### Props + +默认列是声明的值或表达式;默认工厂按组件实例求值。组件内的显示回退见 API Caveats。 + +| 名称 | 类型 | 必需 | 默认表达式 | +| --- | --- | --- | --- | +| `content` | `string` | 是 | `undefined` | +| `format` | `"html" \| "markdown"` | 是 | `undefined` | + +### Events + +- None. + +### Slots + +- None. + +- Types exported from the package root: None +- Story variants: `HTML structure and blocked active content`, `GFM source and literal code` + + +## API Caveats + +- None. + +## Common Mistakes + +- None. diff --git a/packages/web/src/components.d.ts b/packages/web/src/components.d.ts index 4e7eead..2204601 100644 --- a/packages/web/src/components.d.ts +++ b/packages/web/src/components.d.ts @@ -5,6 +5,7 @@ declare module "vue" { export interface GlobalComponents { InkAutoForm: typeof import("./index.js")["InkAutoForm"]; InkButton: typeof import("./index.js")["InkButton"]; + InkContentReader: typeof import("./index.js")["InkContentReader"]; InkDatetimePickerView: typeof import("./index.js")["InkDatetimePickerView"]; InkDialog: typeof import("./index.js")["InkDialog"]; InkDoubleCheck: typeof import("./index.js")["InkDoubleCheck"]; diff --git a/packages/web/src/components/inkContentReader/inkContentReader.scss b/packages/web/src/components/inkContentReader/inkContentReader.scss new file mode 100644 index 0000000..1013737 --- /dev/null +++ b/packages/web/src/components/inkContentReader/inkContentReader.scss @@ -0,0 +1,8 @@ +.ink-content-reader { + display: block; + width: 100%; + min-width: 0; + border: 0; + color: sys-var(color, text, base); + background: sys-var(color, surface, base); +} diff --git a/packages/web/src/components/inkContentReader/inkContentReader.ts b/packages/web/src/components/inkContentReader/inkContentReader.ts new file mode 100644 index 0000000..c192a74 --- /dev/null +++ b/packages/web/src/components/inkContentReader/inkContentReader.ts @@ -0,0 +1,59 @@ +import type { PropType } from "vue"; +import DOMPurify from "dompurify"; +import { Marked } from "marked"; + +export const inkContentReaderProps = { + content: { type: String, required: true }, + format: { type: String as PropType<"html" | "markdown">, required: true }, +} as const; + +export function readingDocument(content: string, format: "html" | "markdown"): string { + const source = + format === "markdown" + ? new Marked({ gfm: true, pedantic: false, breaks: false }).parse(content, { async: false }) + : content; + const body = DOMPurify.sanitize(source, { + RETURN_DOM: true, + SANITIZE_NAMED_PROPS: true, + USE_PROFILES: { html: true }, + FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "form", "link", "meta", "base"], + FORBID_ATTR: ["style", "srcset", "background", "formaction", "autofocus", "contenteditable"], + }); + if (!(body instanceof HTMLElement)) throw new Error("Content sanitization returned no HTML body"); + for (const element of body.querySelectorAll("[src],[poster]")) { + for (const attribute of ["src", "poster"]) { + const address = element.getAttribute(attribute); + if (!address) continue; + const link = body.ownerDocument.createElement("a"); + link.textContent = element.getAttribute("alt") || element.getAttribute("label") || address; + link.setAttribute("href", address); + // Put references outside media fallback content so they remain visible. + const media = element.closest("video,audio") || element; + media.after(link); + element.removeAttribute(attribute); + } + } + for (const media of body.querySelectorAll("video,audio")) media.replaceWith(...media.childNodes); + for (const media of body.querySelectorAll("img,source,track")) media.remove(); + for (const input of body.querySelectorAll("input")) { + if (input.type === "checkbox") input.disabled = true; + else input.remove(); + } + for (const anchor of body.querySelectorAll("a[href]")) { + const href = anchor.getAttribute("href") || ""; + if (href.startsWith("#")) { + // Match DOMPurify's isolated IDs without inheriting the host page's base URL. + anchor.setAttribute("href", `about:srcdoc#user-content-${href.slice(1)}`); + continue; + } + try { + const url = new URL(href); + if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported link"); + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noopener noreferrer"); + } catch { + anchor.removeAttribute("href"); + } + } + return `${body.innerHTML}`; +} diff --git a/packages/web/src/components/inkContentReader/inkContentReader.vue b/packages/web/src/components/inkContentReader/inkContentReader.vue new file mode 100644 index 0000000..b582466 --- /dev/null +++ b/packages/web/src/components/inkContentReader/inkContentReader.vue @@ -0,0 +1,39 @@ + +