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(document: RichTextDocument, adapter: RichTextRenderAdapter): RichTextRenderResult renderRichText(document: RichTextDocument, schema: RichTextSchema | null, adapter: RichTextRenderAdapter): RichTextRenderResult ``` +## `resolveRichTextLinkURL` + +```ts +resolveRichTextLinkURL(value: string | null | undefined): string | undefined +``` ## `RICH_TEXT_CLIPBOARD_MIME` ```ts diff --git a/package-lock.json b/package-lock.json index 447130fb9..8b04a6d83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "packages/json-document-rich-text-react", "packages/json-document-collaboration", "packages/contenteditable-collaboration", - "site" + "site", + "packages/json-document-url" ], "devDependencies": { "@playwright/test": "^1.60.0", @@ -1246,6 +1247,10 @@ "resolved": "packages/json-document-ui-primitives-react", "link": true }, + "node_modules/@interactive-os/json-document-url": { + "resolved": "packages/json-document-url", + "link": true + }, "node_modules/@interactive-os/json-document-web": { "resolved": "packages/json-document-web", "link": true @@ -6898,16 +6903,18 @@ "@interactive-os/json-document-contenteditable": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-markdown": "*", + "@interactive-os/json-document-url": "*", + "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "jsdom": "^29.1.1", "typescript": "^5.0.0", - "vitest": "^4.1.7", - "@interactive-os/json-document-web": "*" + "vitest": "^4.1.7" }, "peerDependencies": { "@interactive-os/json-document-contenteditable": "^0.1.0-rc.0", "@interactive-os/json-document-editing": "^0.1.0-rc.0", "@interactive-os/json-document-markdown": "^0.1.0-rc.0", + "@interactive-os/json-document-url": "^0.1.0-rc.0", "@interactive-os/json-document-web": "^0.1.0-rc.0" } }, @@ -6992,6 +6999,7 @@ "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-selection": "*", + "@interactive-os/json-document-url": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" @@ -6999,7 +7007,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" } }, "packages/json-document-rich-text-mention": { @@ -7177,6 +7186,16 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-url": { + "name": "@interactive-os/json-document-url", + "version": "0.1.0-rc.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + } + }, "packages/json-document-web": { "name": "@interactive-os/json-document-web", "version": "0.1.0-rc.0", @@ -7252,6 +7271,7 @@ "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-tanstack-table": "*", "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-url": "*", "@interactive-os/json-document-web": "*", "@interactive-os/json-document-zod": "*", "@js-temporal/polyfill": "^0.5.1", diff --git a/package.json b/package.json index a428f3b6d..828f71e34 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/json-document-markdown-web/docs/api.md b/packages/json-document-markdown-web/docs/api.md index 5c6bd206d..47df64fa7 100644 --- a/packages/json-document-markdown-web/docs/api.md +++ b/packages/json-document-markdown-web/docs/api.md @@ -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) diff --git a/packages/json-document-markdown-web/package.json b/packages/json-document-markdown-web/package.json index 66dcb4a55..cb52bf780 100644 --- a/packages/json-document-markdown-web/package.json +++ b/packages/json-document-markdown-web/package.json @@ -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", @@ -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": "*" } } diff --git a/packages/json-document-markdown-web/src/source-runs.ts b/packages/json-document-markdown-web/src/source-runs.ts index 77237a879..160c70fc9 100644 --- a/packages/json-document-markdown-web/src/source-runs.ts +++ b/packages/json-document-markdown-web/src/source-runs.ts @@ -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 } @@ -23,15 +24,6 @@ const tags: Record = { const concealedGaps = new Set(["heading", "emphasis", "strong", "delete", "link", "linkReference", "table", "tableRow", "tableCell"]); const markerGaps = new Set([...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; @@ -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"; diff --git a/packages/json-document-markdown-web/tests/markdown-dom.test.ts b/packages/json-document-markdown-web/tests/markdown-dom.test.ts index 13c3111a8..4b530cd73 100644 --- a/packages/json-document-markdown-web/tests/markdown-dom.test.ts +++ b/packages/json-document-markdown-web/tests/markdown-dom.test.ts @@ -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}>)`); +}); diff --git a/packages/json-document-markdown-web/tsconfig.json b/packages/json-document-markdown-web/tsconfig.json index 0c88c9b59..1edf2c846 100644 --- a/packages/json-document-markdown-web/tsconfig.json +++ b/packages/json-document-markdown-web/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "../json-document-web" + }, + { + "path": "../json-document-url" } ], "include": [ diff --git a/packages/json-document-rich-text-web/src/clipboard.ts b/packages/json-document-rich-text-web/src/clipboard.ts index 1ad7e32eb..ee2d777b4 100644 --- a/packages/json-document-rich-text-web/src/clipboard.ts +++ b/packages/json-document-rich-text-web/src/clipboard.ts @@ -1,4 +1,5 @@ import { + resolveRichTextLinkURL, RICH_TEXT_CLIPBOARD_MIME, RICH_TEXT_PROFILE_V1, richTextSchemaV1, @@ -124,7 +125,7 @@ function wrapMark(mark: RichTextMark, children: string): string { if (mark.type === "strikethrough") return `${children}`; if (mark.type === "code") return `${children}`; if (mark.type !== "link") return children; - return safeHref(mark.attrs.href) + return resolveRichTextLinkURL(mark.attrs.href) ? `${children}` : children; } @@ -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 } : {}) } }; } @@ -321,10 +322,7 @@ function collectIds(nodes: ReadonlyArray): Set { 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("&", "&").replaceAll("<", "<").replaceAll(">", ">"); diff --git a/packages/json-document-rich-text-web/tests/clipboard.test.ts b/packages/json-document-rich-text-web/tests/clipboard.test.ts index 11f13b740..0755332f4 100644 --- a/packages/json-document-rich-text-web/tests/clipboard.test.ts +++ b/packages/json-document-rich-text-web/tests/clipboard.test.ts @@ -99,3 +99,13 @@ describe("Official Rich Text Web clipboard", () => { expect(parsed?.html).toBe("

Kept

"); }); }); + + +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("label

`,()=>`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" },