From b77b28868ada47a6de4392d6ddc0a4f0ba63f8ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?=
=?UTF-8?q?=E1=85=A2?=
Date: Fri, 11 Sep 2026 21:14:55 +0900
Subject: [PATCH] =?UTF-8?q?feat(url):=20=EB=AC=B8=EC=84=9C=20URL=20?=
=?UTF-8?q?=ED=97=88=EC=9A=A9=20=EC=A0=95=EC=B1=85=EA=B3=BC=20=EC=86=8C?=
=?UTF-8?q?=EB=B9=84=EC=9E=90=EB=A5=BC=20=EC=A0=95=EB=B3=B8=20=EB=AA=A8?=
=?UTF-8?q?=EB=93=88=EB=A1=9C=20=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/publish.yml | 1 +
docs/api-reference/document-url.md | 22 +++++++++
docs/api-reference/packages.mjs | 1 +
docs/api-reference/rich-text.md | 5 +++
package-lock.json | 28 ++++++++++--
package.json | 3 +-
.../json-document-markdown-web/docs/api.md | 2 +
.../json-document-markdown-web/package.json | 6 ++-
.../src/source-runs.ts | 15 +++----
.../tests/markdown-dom.test.ts | 8 ++++
.../json-document-markdown-web/tsconfig.json | 3 ++
.../src/clipboard.ts | 10 ++---
.../tests/clipboard.test.ts | 10 +++++
.../json-document-rich-text/docs/link-url.md | 8 ++++
packages/json-document-rich-text/package.json | 14 ++++--
packages/json-document-rich-text/src/index.ts | 1 +
.../json-document-rich-text/src/link-url.ts | 12 +++++
.../json-document-rich-text/src/render.ts | 8 +---
.../tests/link-url.test.ts | 13 ++++++
.../json-document-rich-text/tsconfig.json | 19 ++++++--
packages/json-document-url/LICENSE | 21 +++++++++
packages/json-document-url/README.md | 5 +++
packages/json-document-url/docs/api.md | 30 +++++++++++++
packages/json-document-url/package.json | 45 +++++++++++++++++++
packages/json-document-url/src/index.ts | 16 +++++++
packages/json-document-url/tests/url.test.ts | 19 ++++++++
packages/json-document-url/tsconfig.json | 9 ++++
packages/json-document-url/tsconfig.test.json | 10 +++++
packages/json-document-url/vitest.config.ts | 2 +
scripts/ci-plan.mjs | 1 +
scripts/ci-plan.test.mjs | 10 ++++-
scripts/release-package.mjs | 1 +
site/config/json-document-source-aliases.ts | 1 +
site/package.json | 3 +-
site/site-routes.json | 28 +++++++++++-
site/src/app/live-demo-registry.tsx | 1 +
site/src/app/routeTree.gen.ts | 42 +++++++++++++++++
.../app/routes/_page/demo/document-url.tsx | 8 ++++
.../routes/_page/docs/api/document-url.tsx | 3 ++
site/src/routes/docs/DocsRoute.tsx | 1 +
site/src/routes/docs/doc-pages.ts | 1 +
.../routes/document-url/DocumentURLRoute.tsx | 23 ++++++++++
.../src/shared/demo-workbench/demo-sources.ts | 15 +++++++
site/tests/browser/document-url.spec.ts | 20 +++++++++
site/tests/unit/demo-workbench.test.tsx | 9 ++++
tsconfig.build.json | 1 +
46 files changed, 475 insertions(+), 39 deletions(-)
create mode 100644 docs/api-reference/document-url.md
create mode 100644 packages/json-document-rich-text/docs/link-url.md
create mode 100644 packages/json-document-rich-text/src/link-url.ts
create mode 100644 packages/json-document-rich-text/tests/link-url.test.ts
create mode 100644 packages/json-document-url/LICENSE
create mode 100644 packages/json-document-url/README.md
create mode 100644 packages/json-document-url/docs/api.md
create mode 100644 packages/json-document-url/package.json
create mode 100644 packages/json-document-url/src/index.ts
create mode 100644 packages/json-document-url/tests/url.test.ts
create mode 100644 packages/json-document-url/tsconfig.json
create mode 100644 packages/json-document-url/tsconfig.test.json
create mode 100644 packages/json-document-url/vitest.config.ts
create mode 100644 site/src/app/routes/_page/demo/document-url.tsx
create mode 100644 site/src/app/routes/_page/docs/api/document-url.tsx
create mode 100644 site/src/routes/document-url/DocumentURLRoute.tsx
create mode 100644 site/tests/browser/document-url.spec.ts
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 64dba21f2..d8e85c1b1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -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*"
diff --git a/docs/api-reference/document-url.md b/docs/api-reference/document-url.md
new file mode 100644
index 000000000..7f6c882ec
--- /dev/null
+++ b/docs/api-reference/document-url.md
@@ -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
+```
diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs
index d174ce5b4..8dada1b88 100644
--- a/docs/api-reference/packages.mjs
+++ b/docs/api-reference/packages.mjs
@@ -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"],
diff --git a/docs/api-reference/rich-text.md b/docs/api-reference/rich-text.md
index 6f648e3a6..39adfba94 100644
--- a/docs/api-reference/rich-text.md
+++ b/docs/api-reference/rich-text.md
@@ -67,6 +67,11 @@ normalizeRichText(value: unknown, options?: { readonly schema?: RichTextSchema;
renderRichText
`,()=>`link-${++id}`);
+ expect(imported?.text).toBe("label");
+ expect(imported?.html.includes("(
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;
}
@@ -78,8 +79,3 @@ export function renderRichText(
return adapter.node(node, children);
}
}
-
-function safeHref(href: string): boolean {
- return !/[\u0000-\u001f\u007f]/.test(href)
- && (/^(https?:|mailto:|tel:)/i.test(href) || /^(\/|\.\/|\.\.\/|#|\?)/.test(href));
-}
diff --git a/packages/json-document-rich-text/tests/link-url.test.ts b/packages/json-document-rich-text/tests/link-url.test.ts
new file mode 100644
index 000000000..d52ef5346
--- /dev/null
+++ b/packages/json-document-rich-text/tests/link-url.test.ts
@@ -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 = {
+ 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);
+});
diff --git a/packages/json-document-rich-text/tsconfig.json b/packages/json-document-rich-text/tsconfig.json
index c64d21ab9..b9d775035 100644
--- a/packages/json-document-rich-text/tsconfig.json
+++ b/packages/json-document-rich-text/tsconfig.json
@@ -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"
+ ]
}
diff --git a/packages/json-document-url/LICENSE b/packages/json-document-url/LICENSE
new file mode 100644
index 000000000..6a984193a
--- /dev/null
+++ b/packages/json-document-url/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 interactive-os contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/json-document-url/README.md b/packages/json-document-url/README.md
new file mode 100644
index 000000000..5e9bbb5cc
--- /dev/null
+++ b/packages/json-document-url/README.md
@@ -0,0 +1,5 @@
+# @interactive-os/json-document-url
+
+문서 URL 허용 판정의 DOM 독립 정본 모듈입니다.
+
+[API와 Usage 안내](./docs/api.md)
diff --git a/packages/json-document-url/docs/api.md b/packages/json-document-url/docs/api.md
new file mode 100644
index 000000000..da6841292
--- /dev/null
+++ b/packages/json-document-url/docs/api.md
@@ -0,0 +1,30 @@
+# 문서 URL 정책 API
+
+`@interactive-os/json-document-url`은 DOM과 프레임워크에 의존하지 않는 문서 URL 값의 허용 판정 모듈입니다. 문서 타입과 표현 대상이 정책을 제공하고 이 모듈이 scheme·상대 참조·제어문자를 판정합니다.
+
+`resolveDocumentURL(value: string | null | undefined, policy: DocumentURLPolicy): string | undefined`
+
+허용하면 입력 문자열 그대로, 거절하면 `undefined`를 반환합니다. URL 파싱·정규화·네트워크 요청·HTML sanitization을 수행하지 않습니다. 정책 필드는 모두 명시합니다.
+
+| 필드 | 의미 |
+| --- | --- |
+| `schemes` | 허용 scheme 목록. 대소문자 구분 없이 비교 |
+| `relative: "any"` | scheme 없는 모든 참조 허용 |
+| `relative: "explicit"` | `/`, `./`, `../`, `#`, `?`로 시작하는 참조만 허용 |
+| `controlCharacters: "reject"` | C0·DEL을 포함하면 거절 |
+| `controlCharacters: "ignore-for-scheme"` | 판정할 때 C0·공백·DEL을 제거하되 반환 원문은 보존 |
+
+빈 문자열·null·undefined는 항상 거절합니다. `//host`는 상대 참조 정책으로 판단합니다. 이 계약은 주소의 도달 가능성이나 신뢰성을 판정하지 않습니다.
+
+```ts
+import { resolveDocumentURL } from "@interactive-os/json-document-url";
+const href = resolveDocumentURL("../notes", {
+ schemes: ["http", "https", "mailto", "tel"],
+ relative: "explicit",
+ controlCharacters: "reject",
+});
+```
+
+Markdown Web은 링크와 이미지의 허용 scheme을 구분하고 일반 상대 참조를 보존합니다. Rich Text의 `resolveRichTextLinkURL`은 명시적 상대 참조와 제어문자 거절 정책을 소유하며 renderer와 clipboard가 함께 소비합니다.
+
+[Usage](/demo/document-url)에서 정책별 결과와 정본 구현을 확인할 수 있습니다.
diff --git a/packages/json-document-url/package.json b/packages/json-document-url/package.json
new file mode 100644
index 000000000..fae733d0f
--- /dev/null
+++ b/packages/json-document-url/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@interactive-os/json-document-url",
+ "version": "0.1.0-rc.0",
+ "description": "DOM-free document URL eligibility policies with source-preserving results.",
+ "type": "module",
+ "license": "MIT",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/developer-1px/json-document.git",
+ "directory": "packages/json-document-url"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "tag": "next"
+ },
+ "files": [
+ "dist",
+ "!dist/.tsbuildinfo",
+ "README.md",
+ "LICENSE",
+ "docs"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "clean": "rm -rf dist",
+ "build": "npm run clean && tsc -b tsconfig.json",
+ "test": "vitest run --config vitest.config.ts",
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
+ "verify": "npm run typecheck && npm test && npm run build"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.0",
+ "typescript": "^5.0.0",
+ "vitest": "^4.1.7"
+ }
+}
diff --git a/packages/json-document-url/src/index.ts b/packages/json-document-url/src/index.ts
new file mode 100644
index 000000000..7f65780ce
--- /dev/null
+++ b/packages/json-document-url/src/index.ts
@@ -0,0 +1,16 @@
+/** URL eligibility policy; callers own document-kind and target-specific values. */
+export interface DocumentURLPolicy {
+ readonly schemes: readonly string[];
+ readonly relative: "any" | "explicit";
+ readonly controlCharacters: "reject" | "ignore-for-scheme";
+}
+
+/** Return the unchanged source URL when allowed; never decode, resolve, or fetch it. */
+export function resolveDocumentURL(value: string | null | undefined, policy: DocumentURLPolicy): string | undefined {
+ if (!value) return undefined;
+ if (policy.controlCharacters === "reject" && /[\u0000-\u001f\u007f]/.test(value)) return undefined;
+ const inspected = policy.controlCharacters === "ignore-for-scheme" ? value.replace(/[\u0000-\u0020\u007f]/g, "") : value;
+ const scheme = /^([a-z][a-z\d+.-]*):/i.exec(inspected)?.[1]?.toLowerCase();
+ if (scheme) return policy.schemes.some(allowed => allowed.toLowerCase() === scheme) ? value : undefined;
+ return policy.relative === "any" || /^(\/|\.\/|\.\.\/|#|\?)/.test(inspected) ? value : undefined;
+}
diff --git a/packages/json-document-url/tests/url.test.ts b/packages/json-document-url/tests/url.test.ts
new file mode 100644
index 000000000..5f1f6f3a8
--- /dev/null
+++ b/packages/json-document-url/tests/url.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from "vitest";
+import { resolveDocumentURL, type DocumentURLPolicy } from "../src/index.js";
+const link: DocumentURLPolicy = {schemes:["http","https","mailto","tel"],relative:"any",controlCharacters:"ignore-for-scheme"};
+const strict: DocumentURLPolicy = {...link,relative:"explicit",controlCharacters:"reject"};
+const image: DocumentURLPolicy = {...link,schemes:["http","https"]};
+test.each([
+ ["https://example.test/a",true,true,true], ["HTTP://example.test",true,true,true],
+ ["mailto:a@example.test",true,true,false], ["tel:123",true,true,false],
+ ["notes/page",true,false,true], ["./notes",true,true,true], ["../notes",true,true,true],
+ ["/notes",true,true,true], ["//example.test",true,true,true], ["#part",true,true,true], ["?q=1",true,true,true],
+ ["https://example.test/a b",true,true,true], [" https://example.test",true,false,true],
+ ["https:\n//example.test",true,false,true], ["java\tscript:alert(1)",false,false,false],
+ ["javascript:alert(1)",false,false,false], ["data:text/html,x",false,false,false],
+ ["ftp://example.test",false,false,false], ["custom+scheme:value",false,false,false],
+ ["",false,false,false], [null,false,false,false], [undefined,false,false,false],
+] as const)("preserves source and target policy for %s", (value, markdown, richText, picture) => {
+ for (const [policy, allowed] of [[link,markdown],[strict,richText],[image,picture]] as const)
+ expect(resolveDocumentURL(value,policy)).toBe(allowed ? value : undefined);
+});
diff --git a/packages/json-document-url/tsconfig.json b/packages/json-document-url/tsconfig.json
new file mode 100644
index 000000000..dc9b0736f
--- /dev/null
+++ b/packages/json-document-url/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig/library.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "tsBuildInfoFile": "dist/.tsbuildinfo"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/json-document-url/tsconfig.test.json b/packages/json-document-url/tsconfig.test.json
new file mode 100644
index 000000000..adf237ce6
--- /dev/null
+++ b/packages/json-document-url/tsconfig.test.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "composite": false,
+ "noEmit": true,
+ "rootDir": "../..",
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
+}
diff --git a/packages/json-document-url/vitest.config.ts b/packages/json-document-url/vitest.config.ts
new file mode 100644
index 000000000..9abee8bf9
--- /dev/null
+++ b/packages/json-document-url/vitest.config.ts
@@ -0,0 +1,2 @@
+import { defineNodeProject } from "../../test/vitest.shared.js";
+export default defineNodeProject("json-document-url");
diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs
index e4954c4ff..f9f06d9a8 100644
--- a/scripts/ci-plan.mjs
+++ b/scripts/ci-plan.mjs
@@ -18,6 +18,7 @@ const alwaysFull = new Set([
]);
const packageBrowserSpecs = new Map([
+ ["@interactive-os/json-document-url", ["site/tests/browser/document-url.spec.ts", "site/tests/browser/markdown-caret.spec.ts"]],
["@interactive-os/json-document-markdown", ["site/tests/browser/markdown-caret.spec.ts"]],
["@interactive-os/json-document-markdown-web", ["site/tests/browser/markdown-caret.spec.ts"]],
["@interactive-os/json-document-markdown-react", ["site/tests/browser/markdown-caret.spec.ts"]],
diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs
index af3d6e412..ed393cf32 100644
--- a/scripts/ci-plan.test.mjs
+++ b/scripts/ci-plan.test.mjs
@@ -42,7 +42,7 @@ test("기반 패키지 변경은 모든 역방향 소비자를 선택한다", ()
assert.equal(plan.full, false);
assert.deepEqual(
libraries.map((library) => library.manifest.name).filter((name) => !plan.packageWorkspaces.includes(name)),
- ["@interactive-os/json-document-selection", "@interactive-os/json-document-animation-react", "@interactive-os/json-document-markdown"],
+ ["@interactive-os/json-document-selection", "@interactive-os/json-document-animation-react", "@interactive-os/json-document-markdown", "@interactive-os/json-document-url"],
);
assert.equal(plan.standards, true);
assert.equal(plan.externalKit, true);
@@ -137,3 +137,11 @@ test("선택기가 반환하는 모든 browser 경로가 존재한다", () => {
}
}
});
+
+
+test("URL 정책 변경은 Markdown과 Rich Text 소비자 검증을 선택한다", () => {
+ const plan = createPlan(["packages/json-document-url/src/index.ts"]);
+ for (const name of ["json-document-url", "json-document-markdown-web", "json-document-rich-text", "json-document-rich-text-web"])
+ assert.ok(plan.packageWorkspaces.includes(`@interactive-os/${name}`));
+ assert.ok(plan.browserSpecs.includes("site/tests/browser/document-url.spec.ts"));
+});
diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs
index 53c01baeb..6055abebb 100644
--- a/scripts/release-package.mjs
+++ b/scripts/release-package.mjs
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { readJson, repositoryRoot } from "./workspace-graph.mjs";
export const releases = [
+ release("json-document-url", "packages/json-document-url/package.json", "@interactive-os/json-document-url"),
release("json-document-markdown", "packages/json-document-markdown/package.json", "@interactive-os/json-document-markdown"),
release("json-document-markdown-web", "packages/json-document-markdown-web/package.json", "@interactive-os/json-document-markdown-web"),
release("json-document-a2ui", "packages/json-document-a2ui/package.json", "@interactive-os/json-document-a2ui"),
diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts
index cb99fa126..3500e3e52 100644
--- a/site/config/json-document-source-aliases.ts
+++ b/site/config/json-document-source-aliases.ts
@@ -7,6 +7,7 @@ export interface SourceAlias {
export function jsonDocumentSourceAliases(): SourceAlias[] {
return [
+ { find: "@interactive-os/json-document-url", replacement: sourceFile("packages/json-document-url/src/index.ts") },
{ find: "@interactive-os/json-document-web/interaction-recording", replacement: sourceFile("packages/json-document-web/src/interaction-recording-index.ts") },
{ find: "@interactive-os/json-document-contenteditable/text-projection.css", replacement: sourceFile("packages/json-document-contenteditable/src/dom/text-projection.css") },
{ find: "@interactive-os/json-document-markdown-web/markdown-editor.css", replacement: sourceFile("packages/json-document-markdown-web/src/markdown-editor.css") },
diff --git a/site/package.json b/site/package.json
index 134216d43..39964303d 100644
--- a/site/package.json
+++ b/site/package.json
@@ -100,7 +100,8 @@
"rxjs": "^7.8.2",
"shiki": "^4.4.3",
"tailwind-variants": "^3.3.1",
- "zod": "^4.0.0"
+ "zod": "^4.0.0",
+ "@interactive-os/json-document-url": "*"
},
"devDependencies": {
"@tanstack/router-plugin": "^1.168.30",
diff --git a/site/site-routes.json b/site/site-routes.json
index 202353166..b0ee3828e 100644
--- a/site/site-routes.json
+++ b/site/site-routes.json
@@ -221,7 +221,10 @@
"description": "@interactive-os/json-document-rich-text의 전체 public API입니다.",
"language": "ko",
"navigationGroup": "Editing",
- "documentSource": "docs/api-reference/rich-text.md"
+ "documentSource": "docs/api-reference/rich-text.md",
+ "documentIncludes": [
+ "packages/json-document-rich-text/docs/link-url.md"
+ ]
},
{
"path": "/docs/api/file-intake",
@@ -1692,5 +1695,28 @@
"language": "ko",
"navigationGroup": "Editing",
"documentSource": "docs/public/editing.md"
+ },
+ {
+ "path": "/docs/api/document-url",
+ "label": "API · Document URL",
+ "title": "Document URL API - json-document",
+ "description": "문서 URL의 scheme·상대 참조·제어문자 허용 정책입니다.",
+ "language": "ko",
+ "navigationGroup": "JSON Document",
+ "relatedDemoPath": "/demo/document-url",
+ "relatedDemoLabel": "Document URL",
+ "documentSource": "docs/api-reference/document-url.md",
+ "documentIncludes": [
+ "packages/json-document-url/docs/api.md"
+ ]
+ },
+ {
+ "path": "/demo/document-url",
+ "label": "Document URL",
+ "title": "Document URL - json-document",
+ "heading": "Document URL",
+ "description": "공개 문서 URL API로 링크·이미지·Rich Text 정책을 비교합니다.",
+ "language": "ko",
+ "navigationGroup": "JSON Document"
}
]
diff --git a/site/src/app/live-demo-registry.tsx b/site/src/app/live-demo-registry.tsx
index 98761433f..a57605b71 100644
--- a/site/src/app/live-demo-registry.tsx
+++ b/site/src/app/live-demo-registry.tsx
@@ -12,6 +12,7 @@ function demo(importer: () => Promise, exportName: string, source: stri
}
const liveDemos: Readonly> = {
+ "/demo/document-url": demo(() => import("../routes/document-url/DocumentURLRoute"), "DocumentURLRoute", "routes/document-url/DocumentURLRoute.tsx"),
"/demo": demo(() => import("../routes/document-demo/DocumentDemoRoute"), "DocumentDemoRoute", "routes/document-demo/DocumentDemoRoute.tsx"),
"/demo/topology": demo(() => import("../routes/editing-demos/TopologyDemoRoute"), "TopologyDemoRoute", "routes/editing-demos/TopologyDemoRoute.tsx"),
"/demo/selection": demo(() => import("../routes/editing-demos/SelectionDemoRoute"), "SelectionDemoRoute", "routes/editing-demos/SelectionDemoRoute.tsx"),
diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts
index aba0604c6..55c504d51 100644
--- a/site/src/app/routeTree.gen.ts
+++ b/site/src/app/routeTree.gen.ts
@@ -42,6 +42,7 @@ import { Route as PageDemoClipboardRouteImport } from "./routes/_page/demo/clipb
import { Route as PageDemoComposerRouteImport } from "./routes/_page/demo/composer";
import { Route as PageDemoDatabaseRouteImport } from "./routes/_page/demo/database";
import { Route as PageDemoDateControlsRouteImport } from "./routes/_page/demo/date-controls";
+import { Route as PageDemoDocumentUrlRouteImport } from "./routes/_page/demo/document-url";
import { Route as PageDemoHistoryRouteImport } from "./routes/_page/demo/history";
import { Route as PageDemoKanbanRouteImport } from "./routes/_page/demo/kanban";
import { Route as PageDemoMarkdownRouteImport } from "./routes/_page/demo/markdown";
@@ -146,6 +147,7 @@ import { Route as PageDocsApiComposerReactRouteImport } from "./routes/_page/doc
import { Route as PageDocsApiContenteditableRouteImport } from "./routes/_page/docs/api/contenteditable";
import { Route as PageDocsApiContenteditableCollaborationRouteImport } from "./routes/_page/docs/api/contenteditable-collaboration";
import { Route as PageDocsApiDatabaseRouteImport } from "./routes/_page/docs/api/database";
+import { Route as PageDocsApiDocumentUrlRouteImport } from "./routes/_page/docs/api/document-url";
import { Route as PageDocsApiEditingRouteImport } from "./routes/_page/docs/api/editing";
import { Route as PageDocsApiFileIntakeRouteImport } from "./routes/_page/docs/api/file-intake";
import { Route as PageDocsApiJsonDocumentRouteImport } from "./routes/_page/docs/api/json-document";
@@ -346,6 +348,11 @@ const PageDemoDateControlsRoute = PageDemoDateControlsRouteImport.update({
path: "/demo/date-controls",
getParentRoute: () => PageRoute,
} as any);
+const PageDemoDocumentUrlRoute = PageDemoDocumentUrlRouteImport.update({
+ id: "/demo/document-url",
+ path: "/demo/document-url",
+ getParentRoute: () => PageRoute,
+} as any);
const PageDemoHistoryRoute = PageDemoHistoryRouteImport.update({
id: "/demo/history",
path: "/demo/history",
@@ -897,6 +904,11 @@ const PageDocsApiDatabaseRoute = PageDocsApiDatabaseRouteImport.update({
path: "/docs/api/database",
getParentRoute: () => PageRoute,
} as any);
+const PageDocsApiDocumentUrlRoute = PageDocsApiDocumentUrlRouteImport.update({
+ id: "/docs/api/document-url",
+ path: "/docs/api/document-url",
+ getParentRoute: () => PageRoute,
+} as any);
const PageDocsApiEditingRoute = PageDocsApiEditingRouteImport.update({
id: "/docs/api/editing",
path: "/docs/api/editing",
@@ -1090,6 +1102,7 @@ export interface FileRoutesByFullPath {
"/demo/composer": typeof PageDemoComposerRoute;
"/demo/database": typeof PageDemoDatabaseRoute;
"/demo/date-controls": typeof PageDemoDateControlsRoute;
+ "/demo/document-url": typeof PageDemoDocumentUrlRoute;
"/demo/history": typeof PageDemoHistoryRoute;
"/demo/kanban": typeof PageDemoKanbanRoute;
"/demo/markdown": typeof PageDemoMarkdownRoute;
@@ -1195,6 +1208,7 @@ export interface FileRoutesByFullPath {
"/docs/api/contenteditable": typeof PageDocsApiContenteditableRoute;
"/docs/api/contenteditable-collaboration": typeof PageDocsApiContenteditableCollaborationRoute;
"/docs/api/database": typeof PageDocsApiDatabaseRoute;
+ "/docs/api/document-url": typeof PageDocsApiDocumentUrlRoute;
"/docs/api/editing": typeof PageDocsApiEditingRoute;
"/docs/api/file-intake": typeof PageDocsApiFileIntakeRoute;
"/docs/api/json-document": typeof PageDocsApiJsonDocumentRoute;
@@ -1257,6 +1271,7 @@ export interface FileRoutesByTo {
"/demo/composer": typeof PageDemoComposerRoute;
"/demo/database": typeof PageDemoDatabaseRoute;
"/demo/date-controls": typeof PageDemoDateControlsRoute;
+ "/demo/document-url": typeof PageDemoDocumentUrlRoute;
"/demo/history": typeof PageDemoHistoryRoute;
"/demo/kanban": typeof PageDemoKanbanRoute;
"/demo/markdown": typeof PageDemoMarkdownRoute;
@@ -1362,6 +1377,7 @@ export interface FileRoutesByTo {
"/docs/api/contenteditable": typeof PageDocsApiContenteditableRoute;
"/docs/api/contenteditable-collaboration": typeof PageDocsApiContenteditableCollaborationRoute;
"/docs/api/database": typeof PageDocsApiDatabaseRoute;
+ "/docs/api/document-url": typeof PageDocsApiDocumentUrlRoute;
"/docs/api/editing": typeof PageDocsApiEditingRoute;
"/docs/api/file-intake": typeof PageDocsApiFileIntakeRoute;
"/docs/api/json-document": typeof PageDocsApiJsonDocumentRoute;
@@ -1426,6 +1442,7 @@ export interface FileRoutesById {
"/_page/demo/composer": typeof PageDemoComposerRoute;
"/_page/demo/database": typeof PageDemoDatabaseRoute;
"/_page/demo/date-controls": typeof PageDemoDateControlsRoute;
+ "/_page/demo/document-url": typeof PageDemoDocumentUrlRoute;
"/_page/demo/history": typeof PageDemoHistoryRoute;
"/_page/demo/kanban": typeof PageDemoKanbanRoute;
"/_page/demo/markdown": typeof PageDemoMarkdownRoute;
@@ -1531,6 +1548,7 @@ export interface FileRoutesById {
"/_page/docs/api/contenteditable": typeof PageDocsApiContenteditableRoute;
"/_page/docs/api/contenteditable-collaboration": typeof PageDocsApiContenteditableCollaborationRoute;
"/_page/docs/api/database": typeof PageDocsApiDatabaseRoute;
+ "/_page/docs/api/document-url": typeof PageDocsApiDocumentUrlRoute;
"/_page/docs/api/editing": typeof PageDocsApiEditingRoute;
"/_page/docs/api/file-intake": typeof PageDocsApiFileIntakeRoute;
"/_page/docs/api/json-document": typeof PageDocsApiJsonDocumentRoute;
@@ -1595,6 +1613,7 @@ export interface FileRouteTypes {
| "/demo/composer"
| "/demo/database"
| "/demo/date-controls"
+ | "/demo/document-url"
| "/demo/history"
| "/demo/kanban"
| "/demo/markdown"
@@ -1700,6 +1719,7 @@ export interface FileRouteTypes {
| "/docs/api/contenteditable"
| "/docs/api/contenteditable-collaboration"
| "/docs/api/database"
+ | "/docs/api/document-url"
| "/docs/api/editing"
| "/docs/api/file-intake"
| "/docs/api/json-document"
@@ -1762,6 +1782,7 @@ export interface FileRouteTypes {
| "/demo/composer"
| "/demo/database"
| "/demo/date-controls"
+ | "/demo/document-url"
| "/demo/history"
| "/demo/kanban"
| "/demo/markdown"
@@ -1867,6 +1888,7 @@ export interface FileRouteTypes {
| "/docs/api/contenteditable"
| "/docs/api/contenteditable-collaboration"
| "/docs/api/database"
+ | "/docs/api/document-url"
| "/docs/api/editing"
| "/docs/api/file-intake"
| "/docs/api/json-document"
@@ -1930,6 +1952,7 @@ export interface FileRouteTypes {
| "/_page/demo/composer"
| "/_page/demo/database"
| "/_page/demo/date-controls"
+ | "/_page/demo/document-url"
| "/_page/demo/history"
| "/_page/demo/kanban"
| "/_page/demo/markdown"
@@ -2035,6 +2058,7 @@ export interface FileRouteTypes {
| "/_page/docs/api/contenteditable"
| "/_page/docs/api/contenteditable-collaboration"
| "/_page/docs/api/database"
+ | "/_page/docs/api/document-url"
| "/_page/docs/api/editing"
| "/_page/docs/api/file-intake"
| "/_page/docs/api/json-document"
@@ -2308,6 +2332,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof PageDemoDateControlsRouteImport;
parentRoute: typeof PageRoute;
};
+ "/_page/demo/document-url": {
+ id: "/_page/demo/document-url";
+ path: "/demo/document-url";
+ fullPath: "/demo/document-url";
+ preLoaderRoute: typeof PageDemoDocumentUrlRouteImport;
+ parentRoute: typeof PageRoute;
+ };
"/_page/demo/history": {
id: "/_page/demo/history";
path: "/demo/history";
@@ -3036,6 +3067,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof PageDocsApiDatabaseRouteImport;
parentRoute: typeof PageRoute;
};
+ "/_page/docs/api/document-url": {
+ id: "/_page/docs/api/document-url";
+ path: "/docs/api/document-url";
+ fullPath: "/docs/api/document-url";
+ preLoaderRoute: typeof PageDocsApiDocumentUrlRouteImport;
+ parentRoute: typeof PageRoute;
+ };
"/_page/docs/api/editing": {
id: "/_page/docs/api/editing";
path: "/docs/api/editing";
@@ -3269,6 +3307,7 @@ interface PageRouteChildren {
PageDemoComposerRoute: typeof PageDemoComposerRoute;
PageDemoDatabaseRoute: typeof PageDemoDatabaseRoute;
PageDemoDateControlsRoute: typeof PageDemoDateControlsRoute;
+ PageDemoDocumentUrlRoute: typeof PageDemoDocumentUrlRoute;
PageDemoHistoryRoute: typeof PageDemoHistoryRoute;
PageDemoKanbanRoute: typeof PageDemoKanbanRoute;
PageDemoMarkdownRoute: typeof PageDemoMarkdownRoute;
@@ -3374,6 +3413,7 @@ interface PageRouteChildren {
PageDocsApiContenteditableRoute: typeof PageDocsApiContenteditableRoute;
PageDocsApiContenteditableCollaborationRoute: typeof PageDocsApiContenteditableCollaborationRoute;
PageDocsApiDatabaseRoute: typeof PageDocsApiDatabaseRoute;
+ PageDocsApiDocumentUrlRoute: typeof PageDocsApiDocumentUrlRoute;
PageDocsApiEditingRoute: typeof PageDocsApiEditingRoute;
PageDocsApiFileIntakeRoute: typeof PageDocsApiFileIntakeRoute;
PageDocsApiJsonDocumentRoute: typeof PageDocsApiJsonDocumentRoute;
@@ -3435,6 +3475,7 @@ const PageRouteChildren: PageRouteChildren = {
PageDemoComposerRoute: PageDemoComposerRoute,
PageDemoDatabaseRoute: PageDemoDatabaseRoute,
PageDemoDateControlsRoute: PageDemoDateControlsRoute,
+ PageDemoDocumentUrlRoute: PageDemoDocumentUrlRoute,
PageDemoHistoryRoute: PageDemoHistoryRoute,
PageDemoKanbanRoute: PageDemoKanbanRoute,
PageDemoMarkdownRoute: PageDemoMarkdownRoute,
@@ -3541,6 +3582,7 @@ const PageRouteChildren: PageRouteChildren = {
PageDocsApiContenteditableCollaborationRoute:
PageDocsApiContenteditableCollaborationRoute,
PageDocsApiDatabaseRoute: PageDocsApiDatabaseRoute,
+ PageDocsApiDocumentUrlRoute: PageDocsApiDocumentUrlRoute,
PageDocsApiEditingRoute: PageDocsApiEditingRoute,
PageDocsApiFileIntakeRoute: PageDocsApiFileIntakeRoute,
PageDocsApiJsonDocumentRoute: PageDocsApiJsonDocumentRoute,
diff --git a/site/src/app/routes/_page/demo/document-url.tsx b/site/src/app/routes/_page/demo/document-url.tsx
new file mode 100644
index 000000000..1577a84d0
--- /dev/null
+++ b/site/src/app/routes/_page/demo/document-url.tsx
@@ -0,0 +1,8 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { defineDemo } from "../../../../shared/demo-workbench/define-demo";
+import { DocumentURLRoute } from "../../../../routes/document-url/DocumentURLRoute";
+
+export const Route = createFileRoute("/_page/demo/document-url")({
+ component: DocumentURLRoute,
+ ...defineDemo({source:"routes/document-url/DocumentURLRoute.tsx"}),
+});
diff --git a/site/src/app/routes/_page/docs/api/document-url.tsx b/site/src/app/routes/_page/docs/api/document-url.tsx
new file mode 100644
index 000000000..b9b5f326a
--- /dev/null
+++ b/site/src/app/routes/_page/docs/api/document-url.tsx
@@ -0,0 +1,3 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { DocsRoute } from "../../../../../routes/docs/DocsRoute";
+export const Route = createFileRoute("/_page/docs/api/document-url")({component: () => });
diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx
index 3c4fb59be..31bb0fb02 100644
--- a/site/src/routes/docs/DocsRoute.tsx
+++ b/site/src/routes/docs/DocsRoute.tsx
@@ -3,6 +3,7 @@ import { docPages, type DocPageId } from "./doc-pages";
import { DocumentationPage } from "./DocumentationPage";
const docIllustrations: Record = {
+ documentURLApi: "braces",
overview: "package",
concepts: "sleep",
foundation: "braces",
diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts
index 0d70e0f8c..a3192a2b7 100644
--- a/site/src/routes/docs/doc-pages.ts
+++ b/site/src/routes/docs/doc-pages.ts
@@ -18,6 +18,7 @@ function docPage(path: string) {
}
export const docPages = {
+ documentURLApi: docPage("/docs/api/document-url"),
markdownApi: docPage("/docs/api/markdown"),
markdownWebApi: docPage("/docs/api/markdown-web"),
canvasApi: docPage("/docs/api/canvas"),
diff --git a/site/src/routes/document-url/DocumentURLRoute.tsx b/site/src/routes/document-url/DocumentURLRoute.tsx
new file mode 100644
index 000000000..ba6468c13
--- /dev/null
+++ b/site/src/routes/document-url/DocumentURLRoute.tsx
@@ -0,0 +1,23 @@
+import { useState } from "react";
+import { resolveDocumentURL } from "@interactive-os/json-document-url";
+import { resolveRichTextLinkURL } from "@interactive-os/json-document-rich-text";
+import { Field } from "@interactive-os/json-document-ui-primitives-react";
+import { PageHeader } from "../../shared/ui/primitives";
+import { DemoPage } from "../../shared/demo-workbench/DemoPage";
+
+export function DocumentURLRoute() {
+ const [value, setValue] = useState("notes/page");
+ const results = [
+ ["일반 링크", resolveDocumentURL(value, {schemes:["http","https","mailto","tel"],relative:"any",controlCharacters:"ignore-for-scheme"})],
+ ["이미지", resolveDocumentURL(value, {schemes:["http","https"],relative:"any",controlCharacters:"ignore-for-scheme"})],
+ ["Rich Text 링크", resolveRichTextLinkURL(value)],
+ ];
+ return 같은 URL을 링크·이미지·Rich Text에 적용한 결과입니다. 허용한 원문은 그대로 유지합니다. notes/page, ../notes, mailto:a@example.test를 비교해 보세요.}>
+
+
+
| 대상 | 결과 |
+ {results.map(([name,result]) => | {name} | {result === undefined ? "거절" : result} |
)}
+
+
+ ;
+}
diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts
index bb1b70835..87800807a 100644
--- a/site/src/shared/demo-workbench/demo-sources.ts
+++ b/site/src/shared/demo-workbench/demo-sources.ts
@@ -1,3 +1,5 @@
+import documentURLSource from "../../../../packages/json-document-url/src/index.ts?raw";
+import richTextLinkURLSource from "../../../../packages/json-document-rich-text/src/link-url.ts?raw";
import textNavigationSource from "../../../../packages/json-document-contenteditable/src/dom/text-navigation.ts?raw";
import caretVisibilitySource from "../../../../packages/json-document-contenteditable/src/dom/caret-visibility.ts?raw";
import markdownParagraphSource from "../../../../packages/json-document-markdown/src/paragraph.ts?raw";
@@ -188,6 +190,7 @@ export type DemoSourceFile = {
};
const packageReferencePaths = new Map([
+ ["packages/json-document-url/", "/docs/api/document-url"],
["packages/json-document-markdown/", "/docs/api/markdown"],
["packages/json-document-markdown-web/", "/docs/api/markdown-web"],
["packages/json-document-object-document/", "/docs/api/object-document"],
@@ -287,6 +290,8 @@ const registeredUsageSources = new Map([
["packages/json-document-animation-react/src/animations.tsx", animationSource],
["packages/json-document-markdown-react/src/MarkdownRenderer.tsx", markdownRendererSource],
["packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx", markdownEditingSource],
+ ["packages/json-document-url/src/index.ts", documentURLSource],
+ ["packages/json-document-rich-text/src/link-url.ts", richTextLinkURLSource],
["packages/json-document-markdown/src/paragraph.ts", markdownParagraphSource],
["packages/json-document-contenteditable/src/dom/text-navigation.ts", textNavigationSource],
["packages/json-document-contenteditable/src/dom/caret-visibility.ts", caretVisibilitySource],
@@ -430,6 +435,16 @@ const registeredImplementationSources = new Map>([
["packages/json-document-database/src/database-hand.tsx", ["packages/json-document-database/src/database-property-control.tsx", "packages/json-document-database/src/database-view-controls.tsx"]],
]);
const registeredPublicUsages = [
+ {
+ packageName: "@interactive-os/json-document-url",
+ symbol: "resolveDocumentURL",
+ sourcePath: "packages/json-document-url/src/index.ts",
+ },
+ {
+ packageName: "@interactive-os/json-document-rich-text",
+ symbol: "resolveRichTextLinkURL",
+ sourcePath: "packages/json-document-rich-text/src/link-url.ts",
+ },
...["createWebInteractionRecorder", "traceWebInteraction", "registerWebInteractionSource"].map(symbol => ({ packageName: "@interactive-os/json-document-web/interaction-recording", symbol, sourcePath: "packages/json-document-web/src/interaction-recording.ts" })),
...["createWebRecordingArchive", "bindWebRecordingArchive", "downloadWebInteractionRecording"].map(symbol => ({ packageName: "@interactive-os/json-document-web/interaction-recording", symbol, sourcePath: "packages/json-document-web/src/interaction-recording-archive.ts" })),
...["createWebClipboardSurface", "routeWebClipboardEvent"].flatMap(symbol => [
diff --git a/site/tests/browser/document-url.spec.ts b/site/tests/browser/document-url.spec.ts
new file mode 100644
index 000000000..aaa6d7263
--- /dev/null
+++ b/site/tests/browser/document-url.spec.ts
@@ -0,0 +1,20 @@
+import { expect, test } from "@playwright/test";
+
+test("URL Usage consumes the public policies and links to the owner documentation", async ({page}) => {
+ await page.goto("/demo/document-url");
+ const input = page.getByRole("textbox", {name:"문서 URL"});
+ await expect(input).toHaveValue("notes/page");
+ await expect(page.getByLabel("일반 링크", {exact:true})).toHaveText("notes/page");
+ await expect(page.getByLabel("Rich Text 링크", {exact:true})).toHaveText("거절");
+ await input.fill("mailto:a@example.test");
+ await expect(page.getByLabel("이미지", {exact:true})).toHaveText("거절");
+ await expect(page.getByLabel("Rich Text 링크", {exact:true})).toHaveText("mailto:a@example.test");
+ await input.fill("javascript:alert(1)");
+ for (const name of ["일반 링크","이미지","Rich Text 링크"]) await expect(page.getByLabel(name, {exact:true})).toHaveText("거절");
+ await input.fill("../notes");
+ for (const name of ["일반 링크","이미지","Rich Text 링크"]) await expect(page.getByLabel(name, {exact:true})).toHaveText("../notes");
+ await page.screenshot({path:test.info().outputPath("url-usage.png")});
+ await page.goto("/docs/api/document-url");
+ await expect(page.locator("[data-doc-content]")).toContainText("resolveDocumentURL(value:");
+ await expect(page.locator("[data-doc-content]")).toContainText("controlCharacters");
+});
diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx
index 4bd366303..215bbf767 100644
--- a/site/tests/unit/demo-workbench.test.tsx
+++ b/site/tests/unit/demo-workbench.test.tsx
@@ -425,3 +425,12 @@ test("Markdown Usage exposes the canonical text projection and restoration sourc
expect(markdown).toContain("createTextProjectionDOMAdapter");
expect(markdown).not.toContain("setBaseAndExtent");
});
+
+
+test("Document URL Usage exposes both the policy engine and the Rich Text contract", async () => {
+ const sources = await discoverDemoSources("routes/document-url/DocumentURLRoute.tsx");
+ expect(sources.map(file => file.path)).toEqual(expect.arrayContaining([
+ "packages/json-document-url/src/index.ts", "packages/json-document-rich-text/src/link-url.ts",
+ ]));
+ expect(sources.find(file => file.path === "packages/json-document-url/src/index.ts")?.referencePath).toBe("/docs/api/document-url");
+});
diff --git a/tsconfig.build.json b/tsconfig.build.json
index e8f75b42e..f498c8343 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"files": [],
"references": [
+ { "path": "./packages/json-document-url" },
{ "path": "./packages/json-document" },
{ "path": "./packages/json-document-selection" },
{ "path": "./packages/json-document-object-document" },