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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/api-reference/json-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ buildPointer(segments: ReadonlyArray<string | number>, options?: { readonly uriF
```ts
createJSONDocument(initial: unknown, options?: JSONDocumentOptions): JSONDocument
```
## `isJSONValue`

```ts
isJSONValue(value: unknown): value is JSONValue
```
## `JSONAppliedChange`

```ts
Expand Down Expand Up @@ -167,6 +172,11 @@ type QueryResult =
readonly reason?: string;
};
```
## `readPointer`

```ts
readPointer(value: JSONValue, pointer: Pointer): ReadResult
```
## `ReadResult`

```ts
Expand Down
14 changes: 12 additions & 2 deletions docs/public/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ import { jsonEqual } from "@interactive-os/json-document";
jsonEqual({ title: "Draft", tags: [] }, { tags: [], title: "Draft" }); // true
```

## 문서 없이 JSON 값 검증·조회하기

Snapshot 조회와 일반 JSON 값 검증에는 `readPointer(value, pointer)`와
`isJSONValue(value)`를 사용합니다. 두 함수는 값을 복제하거나 정규화하지
않습니다. 주소 조회는 `document.at`과 같은 문법·실패 결과를 사용하고 원본
참조를 반환합니다. 상세 제약과 예제는 [Core package 문서](https://github.com/developer-1px/json-document/blob/main/packages/json-document/README.md)의
순수 core 항목에서 확인할 수 있습니다.

## 문서 없이 patch 적용하기

`applyPatch(value, operations)`는 document 상태를 만들지 않고 RFC 6902
Expand Down Expand Up @@ -287,6 +295,8 @@ type Failure = {
| --- | --- | --- |
| 현재 값 | `document.value` | `JSONValue` |
| 한 위치 읽기 | `document.at(pointer)` | `ReadResult` |
| snapshot에서 한 위치 읽기 | `readPointer(value, pointer)` | `ReadResult` |
| JSON 값 검사 | `isJSONValue(value)` | boolean/type guard |
| 여러 위치 찾기 | `document.query(jsonPath)` | `QueryResult` |
| patch 검사 | `document.validatePatch(operations)` | `JSONPatchValidationResult` |
| 상태 변경 | `document.commit(operations, options?)` | `JSONDocumentCommitResult` |
Expand All @@ -298,13 +308,13 @@ type Failure = {

## 공개 export

Package root는 다음 23개 symbol을 공개합니다.
Package root는 다음 25개 symbol을 공개합니다.

```txt
values
applyPatch, createJSONDocument
appendSegment, buildPointer, parentPointer, parsePointer
jsonEqual, parseArrayIndex, trackPointer, tryParsePointer
isJSONValue, jsonEqual, parseArrayIndex, readPointer, trackPointer, tryParsePointer

types
JSONValue, Pointer, JSONPatchOperation
Expand Down
5 changes: 3 additions & 2 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ import {
```

Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다.
공개 Root는 정확히 다음 23개 symbol이다.
공개 Root는 정확히 다음 25개 symbol이다.

```txt
values
appendSegment, applyPatch, buildPointer, createJSONDocument
jsonEqual, parentPointer, parseArrayIndex, parsePointer, trackPointer, tryParsePointer
isJSONValue, jsonEqual, parentPointer, parseArrayIndex, parsePointer
readPointer, trackPointer, tryParsePointer

types
JSONAppliedChange, JSONPatchValidationResult
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

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

6 changes: 0 additions & 6 deletions packages/json-document-editing/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,3 @@ export function cutEditingClipboard<Payload, Result>(
export function isClipboardRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function isClipboardJSONValue(value: unknown): boolean {
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
if (Array.isArray(value)) return value.every(isClipboardJSONValue);
return isClipboardRecord(value) && Object.values(value).every(isClipboardJSONValue);
}
8 changes: 5 additions & 3 deletions packages/json-document-editing/src/database.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
buildPointer,
isJSONValue,
jsonEqual,
type JSONPatchOperation,
type JSONValue,
Expand All @@ -13,7 +14,7 @@ import {
import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js";
import type { EditingHistoryOptions } from "./history.js";
import { reconcileRangeSelection } from "./range-selection.js";
import { isClipboardJSONValue, isClipboardRecord } from "./clipboard.js";
import { isClipboardRecord } from "./clipboard.js";
import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds } from "./topology.js";
import { acceptsDatabaseValue, defaultDatabaseValue } from "./database-property-value.js";
import { assertDatabaseDocument, assertDatabaseView } from "./database-validation.js";
Expand Down Expand Up @@ -121,10 +122,10 @@ export interface DatabaseClipboard extends Record<string, JSONValue> {
export const databaseClipboardFormat = {
mimeType: "application/vnd.interactive-os.database+json" as const,
parse(value: unknown): DatabaseClipboard | null {
if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null;
if (!isJSONValue(value) || !isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null;
if (!Array.isArray(value.cells) || value.cells.length === 0 || !Array.isArray(value.cells[0])) return null;
const width = value.cells[0].length;
return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width && row.every(isClipboardJSONValue))
return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width)
? value as DatabaseClipboard : null;
},
};
Expand Down Expand Up @@ -341,6 +342,7 @@ function paste(
topology?: DatabaseTopology,
index?: DatabaseIndex,
): EditingResult<DatabaseSelection> {
if (!isJSONValue(clipboard)) return failure("clipboard.invalid");
const focus = session.snapshot.selection.focus;
if (focus === null) return failure("selection.empty");
if (clipboard.cells.length === 0 || clipboard.cells.some((row) => row.length === 0)) {
Expand Down
8 changes: 5 additions & 3 deletions packages/json-document-editing/src/sheet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
buildPointer,
isJSONValue,
type JSONPatchOperation,
type JSONValue,
} from "@interactive-os/json-document";
Expand All @@ -12,7 +13,7 @@ import {
import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js";
import type { EditingHistoryOptions } from "./history.js";
import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js";
import { cutEditingClipboard, isClipboardJSONValue, isClipboardRecord } from "./clipboard.js";
import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js";
import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds, type GridTopology } from "./topology.js";
import { assertSheetDocument, assertUniqueSheetIds } from "./sheet-validation.js";
import {
Expand Down Expand Up @@ -74,10 +75,10 @@ export interface SheetClipboard extends Record<string, JSONValue> {
export const sheetClipboardFormat = {
mimeType: "application/vnd.interactive-os.sheet+json" as const,
parse(value: unknown): SheetClipboard | null {
if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null;
if (!isJSONValue(value) || !isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null;
if (!Array.isArray(value.cells) || value.cells.length === 0 || !Array.isArray(value.cells[0])) return null;
const width = value.cells[0].length;
return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width && row.every(isClipboardJSONValue))
return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width)
? value as SheetClipboard : null;
},
};
Expand Down Expand Up @@ -304,6 +305,7 @@ function paste(
topology?: SheetTopology,
index?: SheetIndex,
): EditingResult<SheetSelection> {
if (!isJSONValue(clipboard)) return failure("clipboard.invalid");
const focus = session.snapshot.selection.focus;
if (focus === null) return failure("selection.empty");
if (clipboard.cells.length === 0 || clipboard.cells.some((row) => row.length === 0)) {
Expand Down
28 changes: 28 additions & 0 deletions packages/json-document-editing/tests/clipboard-surface.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
import { describe, expect, test } from "vitest";
import { applyPatch, type JSONValue } from "@interactive-os/json-document";
import {
createDatabaseEditor,
createDocumentEditor,
createObjectEditor,
createOrderEditor,
createSheetEditor,
createTreeEditor,
sheetClipboardFormat,
databaseClipboardFormat,
} from "../src/index.js";

describe.each([sheetClipboardFormat, databaseClipboardFormat])("$mimeType JSON boundary", (format) => {
const cycle: unknown[] = [];
cycle.push(cycle);
const invalidValues = [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }];

test.each(invalidValues.map((value, index) => ({ value, index })))("rejects non-JSON cell $index as Core does", ({ value }) => {
expect(applyPatch(null, [{ op: "replace", path: "", value: value as JSONValue }]).ok).toBe(false);
expect(format.parse({ type: format.mimeType, cells: [[value]], text: "x" })).toBeNull();
});

test("rejects holes in either matrix dimension and does not invoke cell accessors", () => {
let reads = 0;
const cell = Object.defineProperty({}, "value", { enumerable: true, get: () => { reads++; return 1; } });
for (const cells of [Array(1), [Array(1)], [[cell]]]) {
expect(format.parse({ type: format.mimeType, cells, text: "x" })).toBeNull();
}
expect(reads).toBe(0);
});

test("preserves valid nested JSON without normalization", () => {
const payload = { type: format.mimeType, cells: [[{ "a/b~": [1, true, null] }]], text: "x" };
expect(format.parse(payload)).toBe(payload);
});
});

describe("editing clipboard surface", () => {
test("every domain editor copies a structured payload and a text projection", () => {
const document = createDocumentEditor({ blocks: [{ id: "a", text: "A" }] });
Expand Down
23 changes: 23 additions & 0 deletions packages/json-document-editing/tests/database-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JSONValue } from "@interactive-os/json-document";
import { describe, expect, test } from "vitest";
import {
acceptsDatabaseValue,
Expand Down Expand Up @@ -36,6 +37,28 @@ const initial: DatabaseDocument = {
};

describe("Database editor", () => {

test("rejects non-JSON paste before cloning and preserves selection, redo, and publication", () => {
const editor = createDatabaseEditor(initial);
expect(editor.dispatch({ type: "cell.commit", recordId: "r1", propertyId: "score", value: 9 }).ok).toBe(true);
expect(editor.undo().ok).toBe(true);
const before = editor.snapshot;
let publications = 0;
const unsubscribe = editor.subscribe(() => { publications++; });
const cycle: unknown[] = [];
cycle.push(cycle);
for (const value of [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]) {
expect(editor.dispatch({
type: "clipboard.paste",
clipboard: { type: "application/vnd.interactive-os.database+json", cells: [[value as JSONValue]], text: "x" },
})).toMatchObject({ ok: false, code: "clipboard.invalid" });
expect(editor.snapshot).toEqual(before);
}
expect(publications).toBe(0);
expect(editor.snapshot.canRedo).toBe(true);
expect(editor.redo().ok).toBe(true);
unsubscribe();
});
test("owns the canonical property value semantics", () => {
const [, , score, status, done] = initial.schema.properties;
expect(defaultDatabaseValue(score!)).toBe(0);
Expand Down
23 changes: 23 additions & 0 deletions packages/json-document-editing/tests/sheet-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JSONValue } from "@interactive-os/json-document";
import { describe, expect, test } from "vitest";
import { createSheetEditor, type SheetDocument } from "../src/index.js";

Expand All @@ -15,6 +16,28 @@ const initial: SheetDocument = {
};

describe("sheet editing vertical slice", () => {

test("rejects non-JSON paste before cloning and preserves selection, redo, and publication", () => {
const editor = createSheetEditor(initial);
expect(editor.dispatch({ type: "cell.commit", rowId: "r1", columnId: "score", value: 9 }).ok).toBe(true);
expect(editor.undo().ok).toBe(true);
const before = editor.snapshot;
let publications = 0;
const unsubscribe = editor.subscribe(() => { publications++; });
const cycle: unknown[] = [];
cycle.push(cycle);
for (const value of [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]) {
expect(editor.dispatch({
type: "clipboard.paste",
clipboard: { type: "application/vnd.interactive-os.sheet+json", cells: [[value as JSONValue]], text: "x" },
})).toMatchObject({ ok: false, code: "clipboard.invalid" });
expect(editor.snapshot).toEqual(before);
}
expect(publications).toBe(0);
expect(editor.snapshot.canRedo).toBe(true);
expect(editor.redo().ok).toBe(true);
unsubscribe();
});
test("selects a rectangular range and copies row-major JSON with TSV", () => {
const editor = createSheetEditor(initial);

Expand Down
5 changes: 5 additions & 0 deletions packages/json-document-rich-text-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ scope whose copy text comes from the canonical Rich Text model projection.

Official React renderer and `contenteditable` surface for the json-document Rich Text v1 profile.

An editor's `pointer` may bind the root, a nested JSON Pointer, or its URI
fragment form. The surface reads that snapshot through Core's `readPointer`
without cloning the document. Escaped keys and fragment addresses retain the
same rendering, change observation, and history behavior as ordinary pointers.

`RichTextRenderer` renders canonical semantic HTML. `RichTextEditorSurface` connects that rendering to the official editor, DOM Selection, `beforeinput`, IME, Clipboard, and history integration.

```tsx
Expand Down
2 changes: 2 additions & 0 deletions packages/json-document-rich-text-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@
"verify": "npm run typecheck && npm test && npm run build"
},
"peerDependencies": {
"@interactive-os/json-document": "^3.0.0",
"@interactive-os/json-document-react": "^0.1.0-rc.0",
"@interactive-os/json-document-rich-text": "^0.1.0-rc.0",
"@interactive-os/json-document-rich-text-web": "^0.1.0-rc.0",
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@interactive-os/json-document": "*",
"@interactive-os/json-document-react": "*",
"@interactive-os/json-document-rich-text": "*",
"@interactive-os/json-document-rich-text-web": "*",
Expand Down
15 changes: 6 additions & 9 deletions packages/json-document-rich-text-react/src/render-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildPointer, parsePointer, readPointer, type JSONValue } from "@interactive-os/json-document";
import {
appliedOperationsFor,
hasRichTextContent,
Expand All @@ -22,7 +23,7 @@ export interface RichTextRenderStore {
}

export function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore {
const pointer = editor.pointer ?? "";
const pointer = buildPointer(parsePointer(editor.pointer ?? ""));
let document = documentAtPointer(editor.snapshot.value, pointer);
let blockIds: ReadonlyArray<string> = document.content.map((node) => node.id);
let placeholderBlockId: string | null = null;
Expand Down Expand Up @@ -231,14 +232,10 @@ function relativeOperations(
});
}

function documentAtPointer(value: unknown, pointer: string): RichTextDocument {
if (pointer === "") return value as RichTextDocument;
let current = value;
for (const segment of pointer.slice(1).split("/").map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))) {
if (current === null || typeof current !== "object") throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`);
current = (current as Readonly<Record<string, unknown>>)[segment];
}
return current as RichTextDocument;
function documentAtPointer(value: JSONValue, pointer: string): RichTextDocument {
const result = readPointer(value, pointer);
if (!result.ok) throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`);
return result.value as RichTextDocument;
}

function contentStructureChanged(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

import { createJSONDocument } from "@interactive-os/json-document";
import { buildPointer, createJSONDocument } from "@interactive-os/json-document";
import {
createRichTextBlockFixture,
createRichTextEditor,
Expand All @@ -20,6 +20,32 @@ import {
import { createRichTextRenderStore } from "../src/render-store.js";

describe("Rich Text React locality", () => {
it.each([false, true])("observes nested edits, history, and external changes (fragment: %s)", (uriFragment) => {
const key = "a/b~ #한";
const value = createRichTextBlockFixture(3, { idPrefix: "nested" });
const document = createJSONDocument({ [key]: value });
const pointer = buildPointer([key], { uriFragment });
const editor = createRichTextEditor({ document, pointer, selection: collapsed("nested-text-1", 1) });
const store = createRichTextRenderStore(editor);
let changed = 0;
const unsubscribe = store.subscribeNode("nested-text-1", () => { changed++; });
const untouched = store.getNode("nested-0");
expect(editor.dispatch({ type: "text.insert", text: "y" }).ok).toBe(true);
expect(changed).toBe(1);
expect(store.getNode("nested-text-1")).toMatchObject({ text: "xy" });
expect(store.getNode("nested-0")).toBe(untouched);
expect(lastRenderStoreBlockScan()).toBe(1);
expect(editor.undo().ok).toBe(true);
expect(store.getNode("nested-text-1")).toMatchObject({ text: "x" });
expect(editor.redo().ok).toBe(true);
expect(store.getNode("nested-text-1")).toMatchObject({ text: "xy" });
expect(document.commit([{ op: "replace", path: buildPointer([key, "content", 1, "content", 0, "text"]), value: "remote" }]).ok).toBe(true);
expect(store.getNode("nested-text-1")).toMatchObject({ text: "remote" });
expect(store.getNode("nested-0")).toBe(untouched);
expect(editor.pointer).toBe(pointer);
unsubscribe();
});

it("catches up after disconnected structural and leaf edits", () => {
const editor = createRichTextEditor({
document: createJSONDocument(createRichTextBlockFixture(3, { idPrefix: "offline" })),
Expand Down
Loading