json-document core는 JSON editing foundation만 소유합니다. UI, persistence, system clipboard, collection command, stable id lookup 같은 앱별 책임은 extension이나 host code에서 조립합니다.
Extension은 core에 plugin으로 등록하지 않습니다. public JSONDocument surface를 받아 함수로 compose합니다.
필요한 package만 설치합니다.
npm install @interactive-os/json-document @interactive-os/json-document-collection공식 extension 목록과 lab 후보 목록은 repo catalog에서 생성됩니다. packages/*에 있는 publishable @interactive-os/json-document-* package가 공식 extension이고, labs/extensions/*는 후보입니다. public 문서에서 lab package를 공식 extension이라고 부르지 않습니다.
Lab package는 제품 feature 압력을 검증하기 위한 실험입니다. 설치 가능한 공식 package로 안내하지 않고, 제품별 조합은 Recipes에서 먼저 확인합니다.
| 상황 | 먼저 쓰는 표면 |
|---|---|
| 한 위치를 정확히 바꿈 | core doc.insert, doc.replace, doc.delete, doc.move |
| ordered array item UX | @interactive-os/json-document-collection |
| outliner promote/demote | @interactive-os/json-document-outline |
| JSONPath 결과 일괄 변경 | @interactive-os/json-document-bulk-edit |
| temporary invalid form input | @interactive-os/json-document-form-draft |
| protected JSON Pointer edit guard | @interactive-os/json-document-protected-ranges |
| reusable JSON payload insertion | @interactive-os/json-document-snippets |
| 저장됨/dirty 표시 | @interactive-os/json-document-dirty-state |
| local draft save/restore | @interactive-os/json-document-persist-web |
| browser clipboard I/O | @interactive-os/json-document-clipboard-web |
| stable id를 현재 Pointer로 해석 | @interactive-os/json-document-id-resolver |
| apply 전 patch dry-run | @interactive-os/json-document-patch-preview |
| JSON string field search/replace | @interactive-os/json-document-search-replace |
| sibling item structural group/ungroup | @interactive-os/json-document-grouping |
| proposed patch accept/reject | @interactive-os/json-document-proposed-changes |
| review comments anchored to Pointer | @interactive-os/json-document-comments |
| product search ranking, focus, keyboard, rendered value 검색 | host app |
| 제품 패턴 | 맞는 extension | host가 남기는 책임 |
|---|---|---|
| spreadsheet tabs/order | collection, persist-web, dirty-state, bulk-edit, search-replace |
grid selection, formula/rendered-value search, TSV clipboard |
| rich editor JSON truth layer | persist-web, dirty-state, collection |
ProseMirror/DOM selection adapter, markdown/parser semantics, editor command 이름 |
| outliner rows | outline, collection, clipboard-web, persist-web |
focus recovery, keyboard policy, default node factory |
| object or card id commands | id-resolver, collection, dirty-state |
id generation, routing, selection UI |
| import/review dry-run | patch-preview, proposed-changes, patch-log, dirty-state |
visual diff, approval workflow, storage |
| review/copy cleanup | search-replace, comments |
review workflow, UI thread state, publish policy |
| 이름 | 의미하지 않는 것 |
|---|---|
clipboard-web |
TSV/CSV spreadsheet clipboard engine |
schema-form |
rendered form UI |
grouping |
Airtable group-by view |
calculated-fields |
formula language/runtime |
protected-ranges |
2D grid selection UI, auth, role model, or server authorization |
drag-drop |
DOM drag/drop event handling |
persist-web |
server sync |
patch-log |
product activity feed |
id-resolver |
id generator or relation graph |
patch-preview |
visual diff or approval workflow |
search-replace |
regex engine, rendered text extraction, or search UI |
proposed-changes |
slash or mention autocomplete |
comments |
rendered comment UI, collaboration, moderation, or persistence |
form-draft |
rendered form components, masking, focus, or parser library |
snippets |
slash command UI, palette search, parser, or id generation policy |
ProseMirror 같은 editor는 DOM/contenteditable state를 소유하고, json-document document는 저장할 JSON truth layer를 소유합니다.
const persistence = createDocumentPersistence(doc, { key: "article-draft" });
editorView.dispatch(editorTransaction);
doc.commit([
{ op: "replace", path: "/doc", value: prosemirrorToJson(editorView.state.doc) },
], { label: "edit rich text", origin: "prosemirror" });
await persistence.save();Editor selection, schema-specific parsing, Markdown/HTML serialization, IME handling은 host editor 책임입니다. json-document는 최종 JSON payload의 validation, persistence, dirty state, undo/redo boundary를 조립합니다.
import { createCollection } from "@interactive-os/json-document-collection";
const collection = createCollection(doc);
collection.moveAfter("/lists/0/cards/0", "/lists/1/cards/0");
collection.duplicateAfter("/slides/0", {
rekey: { fields: ["id"], strategy: "suffix" },
});
collection.deleteItems(["/tabs/1", "/tabs/3"]);import { createOutline } from "@interactive-os/json-document-outline";
const outline = createOutline(doc);
outline.demote("/children/1");
outline.promote("/children/0/children/1");import { createSchemaForm } from "@interactive-os/json-document-schema-form";
const form = createSchemaForm(doc, "/settings");
if (form.ok) {
const title = form.fields.find((field) => field.key === "title");
title?.set("Published");
}import { createFormDraft } from "@interactive-os/json-document-form-draft";
const drafts = createFormDraft(doc, {
parse({ input }) {
const value = Number(input);
return Number.isFinite(value)
? { ok: true, value }
: { ok: false, reason: "not a number" };
},
});
drafts.set("/settings/count", "12");
if (drafts.canCommit("/settings/count").ok) {
drafts.commit("/settings/count");
}import { createProtectedRanges } from "@interactive-os/json-document-protected-ranges";
const protectedRanges = createProtectedRanges(doc, [
{ id: "published-slug", pointer: "/slug", label: "Published slug" },
]);
if (protectedRanges.canReplace("/slug", "next").ok) {
protectedRanges.replace("/slug", "next");
}import { createSnippets } from "@interactive-os/json-document-snippets";
const snippets = createSnippets(doc, [
{
id: "todo-card",
label: "Todo card",
payload: { id: "todo", title: "New card", done: false },
},
]);
if (snippets.canInsert("todo-card", "/cards/-", {
rekey: { fields: ["id"], strategy: "suffix" },
}).ok) {
snippets.insert("todo-card", "/cards/-", {
rekey: { fields: ["id"], strategy: "suffix" },
});
}import { createDirtyState } from "@interactive-os/json-document-dirty-state";
const dirty = createDirtyState(doc);
dirty.isDirty();
dirty.markClean();import { createBulkEdit } from "@interactive-os/json-document-bulk-edit";
const bulk = createBulkEdit(doc);
if (bulk.canReplaceAll("$.items[*].done", true).ok) {
bulk.replaceAll("$.items[*].done", true);
}import { createSearchReplace } from "@interactive-os/json-document-search-replace";
const text = createSearchReplace(doc);
const matches = text.find("draft", {
include: ({ pointer }) => pointer.endsWith("/title") || pointer.endsWith("/body"),
});
if (matches.ok) {
text.replaceMatch({
pointer: matches.matches[0].pointer,
range: matches.matches[0].ranges[0],
}, "published");
}search-replace owns literal JSON string-field find/replace, stale match
checks, schema preflight, and atomic document mutation. Regex, fuzzy search,
stemming, tokenization, rendered text extraction, match ranking, and advanced
search UI are host-owned. If a host computes a regex match range for a current
JSON string field, it can still pass { pointer, range } to canReplaceMatch
or replaceMatch; regex replace-all planning and capture substitution remain
host-owned.
import { createProposedChanges } from "@interactive-os/json-document-proposed-changes";
const proposedChanges = createProposedChanges(doc);
proposedChanges.propose({
id: "rename-title",
operations: { op: "replace", path: "/title", value: "Reviewed" },
});
if (proposedChanges.canAccept("rename-title").ok) {
proposedChanges.accept("rename-title", { label: "accept proposed change" });
}import { createComments } from "@interactive-os/json-document-comments";
const comments = createComments(doc);
comments.add({
id: "review-title",
pointer: "/title",
text: "Needs a clearer title",
});
comments.resolve("review-title");import { createPatchLog } from "@interactive-os/json-document-patch-log";
const log = createPatchLog(doc);
doc.replace("/title", "Next");
log.replayInto(otherDoc);import { createDocumentPersistence } from "@interactive-os/json-document-persist-web";
const persistence = createDocumentPersistence(doc, { key: "draft" });
await persistence.save();
await persistence.restore({ restoreSelection: true });
const watch = persistence.watch();
doc.replace("/title", "Draft");
await watch.flush();
watch.stop();import { createPatchPreview } from "@interactive-os/json-document-patch-preview";
const previewer = createPatchPreview(Schema, doc);
const preview = previewer.preview([
{ op: "replace", path: "/title", value: "Next" },
]);
if (preview.ok) {
preview.value;
preview.applied;
}import { createIdResolver } from "@interactive-os/json-document-id-resolver";
const ids = createIdResolver(doc, {
scopes: [
{
scope: "card",
query: "$.columns[*].cards[*]",
readId: (value) => isCard(value) ? value.id : undefined,
},
],
});
ids.resolve("card", "card-1");
ids.current();import { createWebClipboard } from "@interactive-os/json-document-clipboard-web";
const webClipboard = createWebClipboard(doc);
await webClipboard.copy("/lists/0/cards/0");
await webClipboard.paste("/lists/1/cards/-", {
rekey: { fields: ["id"], strategy: "suffix" },
});