Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/content-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@inkcre/ui-web": minor
---

新增隔离的 HTML/GFM 阅读组件,保留代码、表格、链接并阻止内容脚本和资源自动加载。

同时将消费者已采用的一行 InkInput 修复纳入源码,避免新包升级后恢复普通可编辑输入的重复值与 ID。
4 changes: 4 additions & 0 deletions packages/web/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

<!-- migration-base: 2.6.0 -->

### HTML 与 Markdown 阅读

新增 `InkContentReader`,按明确格式安全阅读已有内容。只接收内容和格式,不接管来源或持久化;完整行为见 ContentReader Story。现有组件接口不变。普通可编辑 `InkInput` 不再额外渲染同值、同 ID 的展示 span;升级后可移除针对旧包相同行为的补丁。

## 2.6.0

### Logo 资产
Expand Down
5 changes: 5 additions & 0 deletions packages/web/component-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
"source": "inkButton",
"category": "controls"
},
{
"name": "InkContentReader",
"source": "inkContentReader",
"category": "media"
},
{
"name": "InkDatetimePickerView",
"source": "inkDatetimePickerView",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
11 changes: 10 additions & 1 deletion packages/web/skill.seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions packages/web/skills/ui-web/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/web/skills/ui-web/references/component-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!-- Generated by scripts/build-agent-skills.ts. Do not edit directly. -->

# 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.
1 change: 1 addition & 0 deletions packages/web/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
59 changes: 59 additions & 0 deletions packages/web/src/components/inkContentReader/inkContentReader.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>("[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 `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-inkcre-reader'"><meta name="referrer" content="no-referrer"><style nonce="inkcre-reader">:root{color-scheme:light dark}body{margin:0;font:16px/1.65 system-ui,sans-serif;overflow-wrap:anywhere}h1,h2,h3,h4,h5,h6{line-height:1.35;font-weight:600}h1{font-size:1.5em}h2{font-size:1.25em}h3,h4,h5,h6{font-size:1.1em}pre{overflow-x:auto;white-space:pre;tab-size:4}code{font-family:ui-monospace,monospace}table{border-collapse:collapse}th,td{padding:.35em .7em;border:1px solid GrayText;text-align:start}blockquote{margin-inline:0;padding-inline-start:1em;border-inline-start:2px solid GrayText}a{color:inherit}input{pointer-events:none}</style></head><body>${body.innerHTML}</body></html>`;
}
39 changes: 39 additions & 0 deletions packages/web/src/components/inkContentReader/inkContentReader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
import { inkContentReaderProps, readingDocument } from "./inkContentReader";
import { useOptionalI18n } from "../../i18n";
import en from "../../locales/en";

const props = defineProps(inkContentReaderProps);
const i18n = useOptionalI18n();
const title = computed(() => (i18n ? i18n.t("contentReader.title") : en.contentReader.title));
const document = computed(() => readingDocument(props.content, props.format));
const frame = ref<HTMLIFrameElement>();
const height = ref(160);
let observer: ResizeObserver | undefined;
function measure() {
observer?.disconnect();
const body = frame.value?.contentDocument?.body;
if (!body) return;
const resize = () => {
height.value = Math.ceil(body.getBoundingClientRect().height) + 24;
};
resize();
observer = new ResizeObserver(resize);
observer.observe(body);
}
onBeforeUnmount(() => observer?.disconnect());
</script>
<template>
<iframe
ref="frame"
class="ink-content-reader"
:title="title"
:srcdoc="document"
:style="{ height: `${height}px` }"
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
referrerpolicy="no-referrer"
@load="measure"
/>
</template>
<style lang="scss" scoped src="./inkContentReader.scss" />
4 changes: 1 addition & 3 deletions packages/web/src/components/inkInput/inkInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ const [DefineInput, ReuseInput] = createReusableTemplate();
>
<slot>{{ modelValue || placeholder }}</slot>
</button>
<span v-else-if="type !== 'inline' || !editable" :id="controlId" class="ink-input__value">{{
modelValue
}}</span>
<span v-else-if="!editable" :id="controlId" class="ink-input__value">{{ modelValue }}</span>
</div>
</DefineInput>
<InkField
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/locales/en.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export default {
contentReader: { title: "Content" },
image: {
fit: "Fit to window",
originalSize: "Original size",
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/locales/zh-CN.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export default {
contentReader: { title: "正文" },
image: {
fit: "适应窗口",
originalSize: "原尺寸",
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/public-components.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Auto-generated by build-package-metadata.ts - DO NOT EDIT
import InkAutoForm from "./components/inkAutoForm/inkAutoForm.vue";
import InkButton from "./components/inkButton/inkButton.vue";
import InkContentReader from "./components/inkContentReader/inkContentReader.vue";
import InkDatetimePickerView from "./components/inkDatetimePickerView/inkDatetimePickerView.vue";
import InkDialog from "./components/inkDialog/inkDialog.vue";
import InkDoubleCheck from "./components/inkDoubleCheck/inkDoubleCheck.vue";
Expand All @@ -26,6 +27,7 @@ import InkTooltip from "./components/inkTooltip/inkTooltip.vue";
export const publicComponents: {
readonly InkAutoForm: typeof InkAutoForm;
readonly InkButton: typeof InkButton;
readonly InkContentReader: typeof InkContentReader;
readonly InkDatetimePickerView: typeof InkDatetimePickerView;
readonly InkDialog: typeof InkDialog;
readonly InkDoubleCheck: typeof InkDoubleCheck;
Expand All @@ -50,6 +52,7 @@ export const publicComponents: {
} = {
InkAutoForm,
InkButton,
InkContentReader,
InkDatetimePickerView,
InkDialog,
InkDoubleCheck,
Expand All @@ -76,6 +79,7 @@ export const publicComponents: {
export {
InkAutoForm,
InkButton,
InkContentReader,
InkDatetimePickerView,
InkDialog,
InkDoubleCheck,
Expand Down
7 changes: 7 additions & 0 deletions packages/web/stories/media/inkContentReader.story.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 内容阅读

`InkContentReader` 接受 `content` 字符串和明确的 `format="html"` 或 `format="markdown"`。Markdown 按 GFM 呈现表格、只读任务列表、删除线和自动链接,原文由调用方保留;纯文本应使用文字容器。

组件在隔离的 iframe 中显示净化后的内容。脚本、样式、表单和嵌入页面不执行,媒体资源不自动加载;图片保留说明和可用的 HTTP(S) 引用。HTTP(S) 链接经点击在新窗口打开且不传递 referrer,页内锚点保留。没有可信来源地址时,相对链接不猜目标。组件不接收 Block、resolver 或来源对象,也不保存或改写内容。

Story 对照标准 HTML 的标题、表格、代码空白与链接,以及相同阅读规则下的 GFM。检查窄容器中的长代码可横向滚动,iframe 随正文增高;检查恶意脚本和图片请求均未发生。
18 changes: 18 additions & 0 deletions packages/web/stories/media/inkContentReader.story.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script setup lang="ts">
import InkContentReader from "../../src/components/inkContentReader/inkContentReader.vue";
const markdown =
"# Notes\n\n- [x] Reviewed\n- [ ] Pending\n\n| Item | Value |\n| --- | --- |\n| A | 12 |\n\n```html\n<node>& value\n indented\n```\n\n~~removed~~ https://example.org";
const html =
'<article><h1>Saved page</h1><p><a href="https://example.org">Source</a></p><pre><code>&lt;node&gt;&amp; value\n indented</code></pre><table><tr><th>Item</th><th>Value</th></tr><tr><td>A</td><td>12</td></tr></table><img src="https://example.org/image.png" alt="Image reference"><script>window.readerExecuted=true</scr' +
'ipt><input value="private"><a href="javascript:alert(1)">Unsafe link</a></article>';
</script>
<template>
<Story title="Media/ContentReader" :layout="{ type: 'grid', width: '100%' }">
<Variant title="HTML structure and blocked active content"
><InkContentReader :content="html" format="html"
/></Variant>
<Variant title="GFM source and literal code"
><InkContentReader :content="markdown" format="markdown"
/></Variant>
</Story>
</template>
Loading
Loading