Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Publish v3 package
on:
push:
tags:
- "json-document-url-v*"
- "json-document-markdown-v*"
- "json-document-markdown-web-v*"
- "json-document-v*"
Expand Down
22 changes: 22 additions & 0 deletions docs/api-reference/document-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# @interactive-os/json-document-url API

**탐색 분류:** JSON Document

DOM 독립 문서 URL 허용 정책의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다.

> 이 문서는 `packages/json-document-url/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요.

## `DocumentURLPolicy`

```ts
interface DocumentURLPolicy {
readonly schemes: readonly string[];
readonly relative: "any" | "explicit";
readonly controlCharacters: "reject" | "ignore-for-scheme";
}
```
## `resolveDocumentURL`

```ts
resolveDocumentURL(value: string | null | undefined, policy: DocumentURLPolicy): string | undefined
```
1 change: 1 addition & 0 deletions docs/api-reference/packages.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const apiReferencePackages = [
["document-url", "@interactive-os/json-document-url", "packages/json-document-url/src/index.ts", "JSON Document", "DOM 독립 문서 URL 허용 정책"],
["markdown", "@interactive-os/json-document-markdown", "packages/json-document-markdown/src/index.ts", "Document Types", "원문 문자열이 정본인 Markdown 문법과 source 위치 projection"],
["markdown-web", "@interactive-os/json-document-markdown-web", "packages/json-document-markdown-web/src/index.ts", "Adapter", "Markdown source 위치와 caret에 따른 DOM projection"],
["object-document", "@interactive-os/json-document-object-document", "packages/json-document-object-document/src/index.ts", "Document Types", "Object 문서와 Canvas 프로파일의 모델·검증·연산·projection"],
Expand Down
5 changes: 5 additions & 0 deletions docs/api-reference/rich-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ normalizeRichText(value: unknown, options?: { readonly schema?: RichTextSchema;
renderRichText<Output>(document: RichTextDocument, adapter: RichTextRenderAdapter<Output>): RichTextRenderResult<Output>
renderRichText<Output>(document: RichTextDocument, schema: RichTextSchema | null, adapter: RichTextRenderAdapter<Output>): RichTextRenderResult<Output>
```
## `resolveRichTextLinkURL`

```ts
resolveRichTextLinkURL(value: string | null | undefined): string | undefined
```
## `RICH_TEXT_CLIPBOARD_MIME`

```ts
Expand Down
28 changes: 24 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"packages/json-document-rich-text-react",
"packages/json-document-collaboration",
"packages/contenteditable-collaboration",
"site"
"site",
"packages/json-document-url"
],
"scripts": {
"dev": "npm run dev -w @interactive-os/json-document-site",
Expand Down
2 changes: 2 additions & 0 deletions packages/json-document-markdown-web/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,5 @@ Markdown 문자열이 정본이고 CommonMark + GFM은 문법 의미를 결정
## 보이는 줄을 따르는 수직 이동

`createMarkdownDOMAdapter`는 contenteditable의 공개 `createTextNavigationDOMAdapter`와 `createTextProjectionDOMAdapter`를 조합합니다. ↑↓는 숨긴 원문 기호 대신 보이는 본문 줄을 따라 가로 위치를 유지하고, Shift는 원래 anchor를 유지합니다. 별도의 Markdown 문법별 방향키 분기는 없습니다. [공용 화면 줄 이동 계약](/docs/api/contenteditable)과 [Usage](/demo/markdown-caret)에서 문서 끝·빈 줄·스크롤·native fallback의 범위를 확인할 수 있습니다.

URL 판정은 `@interactive-os/json-document-url`의 공개 `resolveDocumentURL`을 소비합니다. 링크는 http/https/mailto/tel, 이미지는 http/https를 허용하며 일반 상대 참조를 유지합니다. 판정 시 제어문자를 제거하고 반환 원문은 보존하는 기존 정책을 유지합니다. [URL API](/docs/api/document-url) · [Usage](/demo/document-url)
6 changes: 4 additions & 2 deletions packages/json-document-markdown-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"@interactive-os/json-document-markdown": "^0.1.0-rc.0",
"@interactive-os/json-document-contenteditable": "^0.1.0-rc.0",
"@interactive-os/json-document-editing": "^0.1.0-rc.0",
"@interactive-os/json-document-web": "^0.1.0-rc.0"
"@interactive-os/json-document-web": "^0.1.0-rc.0",
"@interactive-os/json-document-url": "^0.1.0-rc.0"
},
"devDependencies": {
"@types/node": "^25.9.0",
Expand All @@ -56,6 +57,7 @@
"@interactive-os/json-document-contenteditable": "*",
"jsdom": "^29.1.1",
"@interactive-os/json-document-editing": "*",
"@interactive-os/json-document-web": "*"
"@interactive-os/json-document-web": "*",
"@interactive-os/json-document-url": "*"
}
}
15 changes: 5 additions & 10 deletions packages/json-document-markdown-web/src/source-runs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveDocumentURL } from "@interactive-os/json-document-url";
import type { MarkdownNode, MarkdownNodeKind, MarkdownProjection, MarkdownMarker } from "@interactive-os/json-document-markdown";

interface SourceRange { readonly from: number; readonly to: number }
Expand All @@ -23,15 +24,6 @@ const tags: Record<MarkdownNodeKind, string> = {
const concealedGaps = new Set<MarkdownNodeKind>(["heading", "emphasis", "strong", "delete", "link", "linkReference", "table", "tableRow", "tableCell"]);
const markerGaps = new Set<MarkdownNodeKind>([...concealedGaps, "listItem", "blockquote", "footnoteDefinition"]);

/** Only navigable document URLs can become DOM attributes; raw HTML stays text. */
function safeURL(value: string | undefined, image = false): string | undefined {
if (!value) return undefined;
const normalized = value.replace(/[\u0000-\u0020\u007f]/g, "");
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
if (scheme && !(image ? ["http", "https"] : ["http", "https", "mailto", "tel"]).includes(scheme)) return undefined;
return value;
}

/** A source-complete tree: visual decoration never adds text to the document. */
export function sourceRuns(projection: MarkdownProjection): SourceRun[] {
const { source } = projection;
Expand Down Expand Up @@ -159,7 +151,10 @@ export function sourceRuns(projection: MarkdownProjection): SourceRun[] {
if (node.lang) attributes["data-language"] = node.lang;
const definition = node.identifier ? definitions.get(node.identifier) : undefined;
const image = node.kind === "image" || node.kind === "imageReference";
const url = safeURL(node.url ?? definition?.url, image);
const url = resolveDocumentURL(node.url ?? definition?.url, {
schemes: image ? ["http", "https"] : ["http", "https", "mailto", "tel"],
relative: "any", controlCharacters: "ignore-for-scheme",
});
if ((node.kind === "link" || node.kind === "linkReference") && url) {
attributes.href = url; attributes.rel = "noreferrer noopener";
attributes.target = "_blank";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,11 @@ test("hidden quote prefix deletes as one source unit and leaves extra spaces", (
expect(dom.resolveDeletionSelection!(root, {anchor:2, focus:2}, "backward")).toEqual({anchor:0, focus:2});
expect(dom.observe(root).value).toBe(source);
});


test.each([["notes/page",true,true],["../notes",true,true],["mailto:a@example.test",true,false],["javascript:alert(1)",false,false]] as const)("Markdown source DOM preserves link and image URL policies for %s", (url, link, image) => {
const {root,dom} = setup(`[label](<${url}>) ![image](<${url}>)`);
expect(root.querySelector("a")?.hasAttribute("href") ?? false).toBe(link);
expect(root.querySelector("img") !== null).toBe(image);
expect(dom.observe(root).value).toBe(`[label](<${url}>) ![image](<${url}>)`);
});
3 changes: 3 additions & 0 deletions packages/json-document-markdown-web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
},
{
"path": "../json-document-web"
},
{
"path": "../json-document-url"
}
],
"include": [
Expand Down
10 changes: 4 additions & 6 deletions packages/json-document-rich-text-web/src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
resolveRichTextLinkURL,
RICH_TEXT_CLIPBOARD_MIME,
RICH_TEXT_PROFILE_V1,
richTextSchemaV1,
Expand Down Expand Up @@ -124,7 +125,7 @@ function wrapMark(mark: RichTextMark, children: string): string {
if (mark.type === "strikethrough") return `<s>${children}</s>`;
if (mark.type === "code") return `<code>${children}</code>`;
if (mark.type !== "link") return children;
return safeHref(mark.attrs.href)
return resolveRichTextLinkURL(mark.attrs.href)
? `<a href="${escapeAttribute(mark.attrs.href)}"${mark.attrs.title ? ` title="${escapeAttribute(mark.attrs.title)}"` : ""}>${children}</a>`
: children;
}
Expand Down Expand Up @@ -224,7 +225,7 @@ function markFor(element: HTMLElement): RichTextMark | null {
if (tag === "u") return { type: "underline" };
if (tag === "s" || tag === "strike") return { type: "strikethrough" };
if (tag === "code") return { type: "code" };
if (tag === "a" && safeHref(element.getAttribute("href") ?? "")) {
if (tag === "a" && resolveRichTextLinkURL(element.getAttribute("href") ?? "")) {
const title = element.getAttribute("title");
return { type: "link", attrs: { href: element.getAttribute("href")!, ...(title ? { title } : {}) } };
}
Expand Down Expand Up @@ -321,10 +322,7 @@ function collectIds(nodes: ReadonlyArray<RichTextNode>): Set<string> {
return ids;
}

function safeHref(href: string): boolean {
return !/[\u0000-\u001f\u007f]/.test(href)
&& (/^(https?:|mailto:|tel:)/i.test(href) || /^(\/|\.\/|\.\.\/|#|\?)/.test(href));
}


function escapeHTML(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
Expand Down
10 changes: 10 additions & 0 deletions packages/json-document-rich-text-web/tests/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,13 @@ describe("Official Rich Text Web clipboard", () => {
expect(parsed?.html).toBe("<p><strong>Kept</strong></p>");
});
});


it.each([["../notes",true],["mailto:a@example.test",true],["notes",false],["javascript:alert(1)",false]] as const)("clipboard import and export preserve Rich Text link policy for %s", (href, allowed) => {
const slice = {...clipboard.slice, content:[{id:"t",type:"text" as const,text:"label",marks:[{type:"link" as const,attrs:{href}}]}],openStart:1,openEnd:1};
expect(serializeRichTextSlice(slice).includes("<a ")).toBe(allowed);
let id=0;
const imported = parseRichTextHTML(`<p><a href="${href}">label</a></p>`,()=>`link-${++id}`);
expect(imported?.text).toBe("label");
expect(imported?.html.includes("<a ")).toBe(allowed);
});
8 changes: 8 additions & 0 deletions packages/json-document-rich-text/docs/link-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## Rich Text 링크 URL 계약

`resolveRichTextLinkURL(value: string | null | undefined): string | undefined`는 renderer와 clipboard import/export가 함께 사용하는 링크 값 계약입니다.
허용하면 원문을 그대로 반환하고, 거절하면 `undefined`를 반환합니다. `http`, `https`, `mailto`, `tel`과 `/`, `./`, `../`, `#`, `?`로 시작하는 상대 참조를 허용합니다. C0·DEL이 포함된 값과 일반 상대 경로 `notes/page`는 기존 동작대로 거절합니다.

판정 알고리즘은 `@interactive-os/json-document-url`의 공개 `resolveDocumentURL`을 사용합니다. 이 모듈은 Rich Text 링크의 정책 값만 소유합니다. renderer는 거절된 링크의 텍스트를 유지하며 진단을 내고, clipboard는 링크 의미를 제외합니다.

[정본 URL API](/docs/api/document-url) · [Usage](/demo/document-url)
14 changes: 11 additions & 3 deletions packages/json-document-rich-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
"provenance": true,
"tag": "next"
},
"files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"],
"files": [
"dist",
"!dist/.tsbuildinfo",
"README.md",
"LICENSE",
"docs"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand All @@ -37,7 +43,8 @@
"peerDependencies": {
"@interactive-os/json-document": "^3.0.0",
"@interactive-os/json-document-editing": "^0.1.0-rc.0",
"@interactive-os/json-document-selection": "^0.1.0-rc.0"
"@interactive-os/json-document-selection": "^0.1.0-rc.0",
"@interactive-os/json-document-url": "^0.1.0-rc.0"
},
"devDependencies": {
"@interactive-os/json-document": "*",
Expand All @@ -46,6 +53,7 @@
"@interactive-os/json-document-selection": "*",
"@types/node": "^25.9.0",
"typescript": "^5.0.0",
"vitest": "^4.1.7"
"vitest": "^4.1.7",
"@interactive-os/json-document-url": "*"
}
}
1 change: 1 addition & 0 deletions packages/json-document-rich-text/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ export type {
RichTextValidationFailure,
RichTextValidationResult,
} from "./validation.js";
export { resolveRichTextLinkURL } from "./link-url.js";
12 changes: 12 additions & 0 deletions packages/json-document-rich-text/src/link-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { resolveDocumentURL, type DocumentURLPolicy } from "@interactive-os/json-document-url";

const policy: DocumentURLPolicy = {
schemes: ["http", "https", "mailto", "tel"],
relative: "explicit",
controlCharacters: "reject",
};

/** Rich Text link eligibility shared by rendering and clipboard import/export. */
export function resolveRichTextLinkURL(value: string | null | undefined): string | undefined {
return resolveDocumentURL(value, policy);
}
8 changes: 2 additions & 6 deletions packages/json-document-rich-text/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveRichTextLinkURL } from "./link-url.js";
import type { JSONValue } from "@interactive-os/json-document";
import {
hasRichTextContent,
Expand Down Expand Up @@ -65,7 +66,7 @@ export function renderRichText<Output>(
diagnostics.push({ code: "rich-text.unknown-mark", reason: `No renderer schema for ${mark.type}.`, nodeId: node.id, markType: mark.type });
return children;
}
if (mark.type === "link" && !safeHref(mark.attrs.href)) {
if (mark.type === "link" && !resolveRichTextLinkURL(mark.attrs.href)) {
diagnostics.push({ code: "rich-text.unsafe-link", reason: `Unsafe link href ${JSON.stringify(mark.attrs.href)}.`, nodeId: node.id, markType: mark.type });
return children;
}
Expand All @@ -78,8 +79,3 @@ export function renderRichText<Output>(
return adapter.node(node, children);
}
}

function safeHref(href: string): boolean {
return !/[\u0000-\u001f\u007f]/.test(href)
&& (/^(https?:|mailto:|tel:)/i.test(href) || /^(\/|\.\/|\.\.\/|#|\?)/.test(href));
}
13 changes: 13 additions & 0 deletions packages/json-document-rich-text/tests/link-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from "vitest";
import { RICH_TEXT_PROFILE_V1, renderRichText, resolveRichTextLinkURL, type RichTextDocument, type RichTextRenderAdapter } from "../src/index.js";
const adapter: RichTextRenderAdapter<string> = {
document: (_node,children) => children.join(""), node: (_node,children) => children.join(""),
text: node => node.text, mark: (_mark,children) => `link(${children.join("")})`, unknown: () => "",
};
test.each([["../notes",true],["https://example.test",true],["notes",false],["https:\n//example.test",false],["javascript:alert(1)",false]] as const)("renderer uses the public link contract for %s", (href, allowed) => {
const doc: RichTextDocument = {id:"doc",type:"doc",profile:RICH_TEXT_PROFILE_V1,content:[{id:"p",type:"paragraph",content:[{id:"t",type:"text",text:"label",marks:[{type:"link",attrs:{href}}]}]}]};
const result = renderRichText(doc,adapter);
expect(resolveRichTextLinkURL(href)).toBe(allowed ? href : undefined);
expect(result.output).toBe(allowed ? "link(label)" : "label");
expect(result.diagnostics).toHaveLength(allowed ? 0 : 1);
});
19 changes: 15 additions & 4 deletions packages/json-document-rich-text/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{ "path": "../json-document" },
{ "path": "../json-document-editing" },
{ "path": "../json-document-selection" }
{
"path": "../json-document"
},
{
"path": "../json-document-editing"
},
{
"path": "../json-document-selection"
},
{
"path": "../json-document-url"
}
],
"include": ["src/**/*.ts"]
"include": [
"src/**/*.ts"
]
}
Loading