diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 3b149ed8e6..fd2aae091c 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -156,6 +156,12 @@ const INTENT_NAMES = { 'doc.toc.listEntries': 'list_table_of_contents_entries', 'doc.toc.getEntry': 'get_table_of_contents_entry', 'doc.toc.editEntry': 'edit_table_of_contents_entry', + 'doc.hyperlinks.list': 'list_hyperlinks', + 'doc.hyperlinks.get': 'get_hyperlink', + 'doc.hyperlinks.wrap': 'wrap_hyperlink', + 'doc.hyperlinks.insert': 'insert_hyperlink', + 'doc.hyperlinks.patch': 'patch_hyperlink', + 'doc.hyperlinks.remove': 'remove_hyperlink', 'doc.query.match': 'query_match', 'doc.mutations.preview': 'preview_mutations', 'doc.mutations.apply': 'apply_mutations', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 29bb80c72e..0f3b8fbc16 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -186,6 +186,71 @@ async function prepareSeparatedSecondListTarget( return { docPath, target }; } +function requireHyperlinkAddress(item: Record, context: string): Record { + const address = item.address; + if (!address || typeof address !== 'object') { + throw new Error(`Missing hyperlink address for ${context}.`); + } + return address as Record; +} + +async function resolveFirstHyperlinkAddress( + harness: ConformanceHarness, + stateDir: string, + docPath: string, + context: string, +): Promise> { + const listed = await harness.runCli([...commandTokens('doc.hyperlinks.list'), docPath, '--limit', '10'], stateDir); + if (listed.result.code !== 0 || listed.envelope.ok !== true) { + throw new Error(`Failed to list hyperlinks for ${context}.`); + } + + const items = extractDiscoveryItems(listed.envelope.data); + const first = items[0]; + if (!first) { + throw new Error(`No hyperlinks available for ${context}.`); + } + + return requireHyperlinkAddress(first, context); +} + +async function createHyperlinkFixture( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise<{ docPath: string; address: Record }> { + const sourceDoc = await harness.copyFixtureDoc(`${label}-source`); + const target = await harness.firstTextRange(sourceDoc, stateDir); + const collapsedTarget = { + kind: 'text', + blockId: target.blockId, + range: { start: target.range.start, end: target.range.start }, + }; + const outputDoc = harness.createOutputPath(`${label}-with-hyperlink`); + + const inserted = await harness.runCli( + [ + ...commandTokens('doc.hyperlinks.insert'), + sourceDoc, + '--target-json', + JSON.stringify(collapsedTarget), + '--text', + 'Conformance hyperlink', + '--link-json', + JSON.stringify({ destination: { href: 'https://example.com' } }), + '--out', + outputDoc, + ], + stateDir, + ); + if (inserted.result.code !== 0 || inserted.envelope.ok !== true) { + throw new Error(`Failed to create hyperlink fixture for ${label}.`); + } + + const address = await resolveFirstHyperlinkAddress(harness, stateDir, outputDoc, label); + return { docPath: outputDoc, address }; +} + function sectionMutationScenario( operationId: CliOperationId, label: string, @@ -799,6 +864,99 @@ export const SUCCESS_SCENARIOS = { args: ['comments', 'list', fixture.docPath, '--include-resolved', 'false'], }; }, + 'doc.hyperlinks.list': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-hyperlinks-list-success'); + const fixture = await createHyperlinkFixture(harness, stateDir, 'doc-hyperlinks-list'); + return { + stateDir, + args: [...commandTokens('doc.hyperlinks.list'), fixture.docPath, '--limit', '10'], + }; + }, + 'doc.hyperlinks.get': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-hyperlinks-get-success'); + const fixture = await createHyperlinkFixture(harness, stateDir, 'doc-hyperlinks-get'); + return { + stateDir, + args: [...commandTokens('doc.hyperlinks.get'), fixture.docPath, '--target-json', JSON.stringify(fixture.address)], + }; + }, + 'doc.hyperlinks.wrap': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-hyperlinks-wrap-success'); + const docPath = await harness.copyFixtureDoc('doc-hyperlinks-wrap'); + const target = await harness.firstTextRange(docPath, stateDir); + return { + stateDir, + args: [ + ...commandTokens('doc.hyperlinks.wrap'), + docPath, + '--target-json', + JSON.stringify(target), + '--link-json', + JSON.stringify({ destination: { href: 'https://example.com/wrap' } }), + '--out', + harness.createOutputPath('doc-hyperlinks-wrap-output'), + ], + }; + }, + 'doc.hyperlinks.insert': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-hyperlinks-insert-success'); + const docPath = await harness.copyFixtureDoc('doc-hyperlinks-insert'); + const target = await harness.firstTextRange(docPath, stateDir); + const collapsedTarget = { + kind: 'text', + blockId: target.blockId, + range: { start: target.range.start, end: target.range.start }, + }; + return { + stateDir, + args: [ + ...commandTokens('doc.hyperlinks.insert'), + docPath, + '--target-json', + JSON.stringify(collapsedTarget), + '--text', + 'Conformance hyperlink insert', + '--link-json', + JSON.stringify({ destination: { href: 'https://example.com/insert' } }), + '--out', + harness.createOutputPath('doc-hyperlinks-insert-output'), + ], + }; + }, + 'doc.hyperlinks.patch': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-hyperlinks-patch-success'); + const fixture = await createHyperlinkFixture(harness, stateDir, 'doc-hyperlinks-patch'); + return { + stateDir, + args: [ + ...commandTokens('doc.hyperlinks.patch'), + fixture.docPath, + '--target-json', + JSON.stringify(fixture.address), + '--patch-json', + JSON.stringify({ tooltip: 'Conformance hyperlink patch' }), + '--out', + harness.createOutputPath('doc-hyperlinks-patch-output'), + ], + }; + }, + 'doc.hyperlinks.remove': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-hyperlinks-remove-success'); + const fixture = await createHyperlinkFixture(harness, stateDir, 'doc-hyperlinks-remove'); + return { + stateDir, + args: [ + ...commandTokens('doc.hyperlinks.remove'), + fixture.docPath, + '--target-json', + JSON.stringify(fixture.address), + '--mode', + 'unwrap', + '--out', + harness.createOutputPath('doc-hyperlinks-remove-output'), + ], + }; + }, 'doc.getText': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-get-text-success'); const docPath = await harness.copyFixtureDoc('doc-get-text'); diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 8efbaf38a1..d6f2bf9bdb 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -21,6 +21,7 @@ Use the tables below to see what operations are available and where each one is | Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) | | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | +| Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | | Images | 13 | 0 | 13 | [Reference](/document-api/reference/images/index) | | Lists | 28 | 1 | 29 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | @@ -106,6 +107,12 @@ Use the tables below to see what operations are available and where each one is | editor.doc.history.get(...) | [`history.get`](/document-api/reference/history/get) | | editor.doc.history.undo(...) | [`history.undo`](/document-api/reference/history/undo) | | editor.doc.history.redo(...) | [`history.redo`](/document-api/reference/history/redo) | +| editor.doc.hyperlinks.list(...) | [`hyperlinks.list`](/document-api/reference/hyperlinks/list) | +| editor.doc.hyperlinks.get(...) | [`hyperlinks.get`](/document-api/reference/hyperlinks/get) | +| editor.doc.hyperlinks.wrap(...) | [`hyperlinks.wrap`](/document-api/reference/hyperlinks/wrap) | +| editor.doc.hyperlinks.insert(...) | [`hyperlinks.insert`](/document-api/reference/hyperlinks/insert) | +| editor.doc.hyperlinks.patch(...) | [`hyperlinks.patch`](/document-api/reference/hyperlinks/patch) | +| editor.doc.hyperlinks.remove(...) | [`hyperlinks.remove`](/document-api/reference/hyperlinks/remove) | | editor.doc.images.list(...) | [`images.list`](/document-api/reference/images/list) | | editor.doc.images.get(...) | [`images.get`](/document-api/reference/images/get) | | editor.doc.images.delete(...) | [`images.delete`](/document-api/reference/images/delete) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 8fb16094cd..7122aaf6d2 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -93,6 +93,13 @@ "apps/docs/document-api/reference/history/index.mdx", "apps/docs/document-api/reference/history/redo.mdx", "apps/docs/document-api/reference/history/undo.mdx", + "apps/docs/document-api/reference/hyperlinks/get.mdx", + "apps/docs/document-api/reference/hyperlinks/index.mdx", + "apps/docs/document-api/reference/hyperlinks/insert.mdx", + "apps/docs/document-api/reference/hyperlinks/list.mdx", + "apps/docs/document-api/reference/hyperlinks/patch.mdx", + "apps/docs/document-api/reference/hyperlinks/remove.mdx", + "apps/docs/document-api/reference/hyperlinks/wrap.mdx", "apps/docs/document-api/reference/images/convert-to-floating.mdx", "apps/docs/document-api/reference/images/convert-to-inline.mdx", "apps/docs/document-api/reference/images/delete.mdx", @@ -552,8 +559,22 @@ ], "pagePath": "apps/docs/document-api/reference/images/index.mdx", "title": "Images" + }, + { + "aliasMemberPaths": [], + "key": "hyperlinks", + "operationIds": [ + "hyperlinks.list", + "hyperlinks.get", + "hyperlinks.wrap", + "hyperlinks.insert", + "hyperlinks.patch", + "hyperlinks.remove" + ], + "pagePath": "apps/docs/document-api/reference/hyperlinks/index.mdx", + "title": "Hyperlinks" } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "185b9ab20679d6709e676a2b2b810adebddb201298e6a1682b3af62e600e4eb1" + "sourceHash": "61970f6e7637f11451e0f7fb218a66bc84f39b7b512a5002d8996286708c2d31" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index a7bc604e75..0629674540 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -692,6 +692,36 @@ _No fields._ | `operations.history.undo.dryRun` | boolean | yes | | | `operations.history.undo.reasons` | enum[] | no | | | `operations.history.undo.tracked` | boolean | yes | | +| `operations.hyperlinks.get` | object | yes | | +| `operations.hyperlinks.get.available` | boolean | yes | | +| `operations.hyperlinks.get.dryRun` | boolean | yes | | +| `operations.hyperlinks.get.reasons` | enum[] | no | | +| `operations.hyperlinks.get.tracked` | boolean | yes | | +| `operations.hyperlinks.insert` | object | yes | | +| `operations.hyperlinks.insert.available` | boolean | yes | | +| `operations.hyperlinks.insert.dryRun` | boolean | yes | | +| `operations.hyperlinks.insert.reasons` | enum[] | no | | +| `operations.hyperlinks.insert.tracked` | boolean | yes | | +| `operations.hyperlinks.list` | object | yes | | +| `operations.hyperlinks.list.available` | boolean | yes | | +| `operations.hyperlinks.list.dryRun` | boolean | yes | | +| `operations.hyperlinks.list.reasons` | enum[] | no | | +| `operations.hyperlinks.list.tracked` | boolean | yes | | +| `operations.hyperlinks.patch` | object | yes | | +| `operations.hyperlinks.patch.available` | boolean | yes | | +| `operations.hyperlinks.patch.dryRun` | boolean | yes | | +| `operations.hyperlinks.patch.reasons` | enum[] | no | | +| `operations.hyperlinks.patch.tracked` | boolean | yes | | +| `operations.hyperlinks.remove` | object | yes | | +| `operations.hyperlinks.remove.available` | boolean | yes | | +| `operations.hyperlinks.remove.dryRun` | boolean | yes | | +| `operations.hyperlinks.remove.reasons` | enum[] | no | | +| `operations.hyperlinks.remove.tracked` | boolean | yes | | +| `operations.hyperlinks.wrap` | object | yes | | +| `operations.hyperlinks.wrap.available` | boolean | yes | | +| `operations.hyperlinks.wrap.dryRun` | boolean | yes | | +| `operations.hyperlinks.wrap.reasons` | enum[] | no | | +| `operations.hyperlinks.wrap.tracked` | boolean | yes | | | `operations.images.convertToFloating` | object | yes | | | `operations.images.convertToFloating.available` | boolean | yes | | | `operations.images.convertToFloating.dryRun` | boolean | yes | | @@ -2285,6 +2315,54 @@ _No fields._ ], "tracked": true }, + "hyperlinks.get": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "hyperlinks.insert": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "hyperlinks.list": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "hyperlinks.patch": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "hyperlinks.remove": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "hyperlinks.wrap": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "images.convertToFloating": { "available": true, "dryRun": true, @@ -7882,6 +7960,216 @@ _No fields._ ], "type": "object" }, + "hyperlinks.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "hyperlinks.insert": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "hyperlinks.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "hyperlinks.patch": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "hyperlinks.remove": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "hyperlinks.wrap": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "images.convertToFloating": { "additionalProperties": false, "properties": { @@ -12395,7 +12683,13 @@ _No fields._ "images.setWrapDistances", "images.setPosition", "images.setAnchorOptions", - "images.setZOrder" + "images.setZOrder", + "hyperlinks.list", + "hyperlinks.get", + "hyperlinks.wrap", + "hyperlinks.insert", + "hyperlinks.patch", + "hyperlinks.remove" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/hyperlinks/get.mdx b/apps/docs/document-api/reference/hyperlinks/get.mdx new file mode 100644 index 0000000000..69e75d3475 --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/get.mdx @@ -0,0 +1,217 @@ +--- +title: hyperlinks.get +sidebarTitle: hyperlinks.get +description: Retrieve details of a specific hyperlink by its inline address. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Retrieve details of a specific hyperlink by its inline address. + +- Operation ID: `hyperlinks.get` +- API member path: `editor.doc.hyperlinks.get(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HyperlinkInfo object with the address, destination properties, and display text. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | object(kind="inline") | yes | | +| `target.anchor` | InlineAnchor | yes | InlineAnchor | +| `target.anchor.end` | Position | yes | Position | +| `target.anchor.end.blockId` | string | yes | | +| `target.anchor.end.offset` | integer | yes | | +| `target.anchor.start` | Position | yes | Position | +| `target.anchor.start.blockId` | string | yes | | +| `target.anchor.start.offset` | integer | yes | | +| `target.kind` | `"inline"` | yes | Constant: `"inline"` | +| `target.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | + +### Example request + +```json +{ + "target": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `address` | object(kind="inline") | yes | | +| `address.anchor` | InlineAnchor | yes | InlineAnchor | +| `address.anchor.end` | Position | yes | Position | +| `address.anchor.end.blockId` | string | yes | | +| `address.anchor.end.offset` | integer | yes | | +| `address.anchor.start` | Position | yes | Position | +| `address.anchor.start.blockId` | string | yes | | +| `address.anchor.start.offset` | integer | yes | | +| `address.kind` | `"inline"` | yes | Constant: `"inline"` | +| `address.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | +| `properties` | object | yes | | +| `properties.anchor` | string | no | | +| `properties.docLocation` | string | no | | +| `properties.href` | string | no | | +| `properties.rel` | string | no | | +| `properties.target` | string | no | | +| `properties.tooltip` | string | no | | +| `text` | string | no | | + +### Example response + +```json +{ + "address": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + }, + "properties": { + "anchor": "example", + "href": "example" + }, + "text": "Hello, world." +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "properties": { + "additionalProperties": false, + "properties": { + "anchor": { + "type": "string" + }, + "docLocation": { + "type": "string" + }, + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "target": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "address", + "properties" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/hyperlinks/index.mdx b/apps/docs/document-api/reference/hyperlinks/index.mdx new file mode 100644 index 0000000000..76bfabdaa6 --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/index.mdx @@ -0,0 +1,23 @@ +--- +title: Hyperlinks operations +sidebarTitle: Hyperlinks +description: Hyperlinks operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Hyperlink discovery, creation, and metadata management. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| hyperlinks.list | `hyperlinks.list` | No | `idempotent` | No | No | +| hyperlinks.get | `hyperlinks.get` | No | `idempotent` | No | No | +| hyperlinks.wrap | `hyperlinks.wrap` | Yes | `conditional` | No | Yes | +| hyperlinks.insert | `hyperlinks.insert` | Yes | `non-idempotent` | No | Yes | +| hyperlinks.patch | `hyperlinks.patch` | Yes | `conditional` | No | Yes | +| hyperlinks.remove | `hyperlinks.remove` | Yes | `conditional` | No | Yes | + diff --git a/apps/docs/document-api/reference/hyperlinks/insert.mdx b/apps/docs/document-api/reference/hyperlinks/insert.mdx new file mode 100644 index 0000000000..852f621bbc --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/insert.mdx @@ -0,0 +1,346 @@ +--- +title: hyperlinks.insert +sidebarTitle: hyperlinks.insert +description: Insert new linked text at a target position. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Insert new linked text at a target position. + +- Operation ID: `hyperlinks.insert` +- API member path: `editor.doc.hyperlinks.insert(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HyperlinkMutationResult with the created hyperlink address on success, or a failure code. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `link` | object | yes | | +| `link.destination` | object | yes | | +| `link.destination.anchor` | string | no | | +| `link.destination.docLocation` | string | no | | +| `link.destination.href` | string | no | | +| `link.rel` | string | no | | +| `link.target` | string | no | | +| `link.tooltip` | string | no | | +| `target` | TextAddress | no | TextAddress | +| `target.blockId` | string | no | | +| `target.kind` | `"text"` | no | Constant: `"text"` | +| `target.range` | Range | no | Range | +| `target.range.end` | integer | no | | +| `target.range.start` | integer | no | | +| `text` | string | yes | | + +### Example request + +```json +{ + "link": { + "destination": { + "anchor": "example", + "href": "example" + }, + "target": "example", + "tooltip": "example" + }, + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + }, + "text": "Hello, world." +} +``` + +## Output fields + +### Variant 1 (hyperlink.kind="inline") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `hyperlink` | object(kind="inline") | yes | | +| `hyperlink.anchor` | InlineAnchor | yes | InlineAnchor | +| `hyperlink.anchor.end` | Position | yes | Position | +| `hyperlink.anchor.end.blockId` | string | yes | | +| `hyperlink.anchor.end.offset` | integer | yes | | +| `hyperlink.anchor.start` | Position | yes | Position | +| `hyperlink.anchor.start.blockId` | string | yes | | +| `hyperlink.anchor.start.offset` | integer | yes | | +| `hyperlink.kind` | `"inline"` | yes | Constant: `"inline"` | +| `hyperlink.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"` | +| `failure.details` | object | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "hyperlink": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_TARGET` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "link": { + "additionalProperties": false, + "properties": { + "destination": { + "additionalProperties": false, + "properties": { + "anchor": { + "type": "string" + }, + "docLocation": { + "type": "string" + }, + "href": { + "type": "string" + } + }, + "type": "object" + }, + "rel": { + "type": "string" + }, + "target": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "required": [ + "destination" + ], + "type": "object" + }, + "target": { + "$ref": "#/$defs/TextAddress" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "link" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/hyperlinks/list.mdx b/apps/docs/document-api/reference/hyperlinks/list.mdx new file mode 100644 index 0000000000..74c3978873 --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/list.mdx @@ -0,0 +1,224 @@ +--- +title: hyperlinks.list +sidebarTitle: hyperlinks.list +description: List all hyperlinks in the document, with optional filtering by href, anchor, or display text. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +List all hyperlinks in the document, with optional filtering by href, anchor, or display text. + +- Operation ID: `hyperlinks.list` +- API member path: `editor.doc.hyperlinks.list(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HyperlinksListResult with an array of hyperlink discovery items and pagination metadata. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `anchor` | string | no | | +| `hrefPattern` | string | no | | +| `limit` | integer | no | | +| `offset` | integer | no | | +| `textPattern` | string | no | | +| `within` | NodeAddress | no | NodeAddress | + +### Example request + +```json +{ + "hrefPattern": "example", + "within": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `evaluatedRevision` | string | yes | | +| `items` | object[] | yes | | +| `page` | PageInfo | yes | PageInfo | +| `page.limit` | integer | yes | | +| `page.offset` | integer | yes | | +| `page.returned` | integer | yes | | +| `total` | integer | yes | | + +### Example response + +```json +{ + "evaluatedRevision": "rev-001", + "items": [ + { + "address": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + }, + "properties": { + "anchor": "example", + "href": "example" + }, + "text": "Hello, world." + } + ], + "page": { + "limit": 50, + "offset": 0, + "returned": 1 + }, + "total": 1 +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "anchor": { + "type": "string" + }, + "hrefPattern": { + "type": "string" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "textPattern": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "evaluatedRevision": { + "type": "string" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "properties": { + "additionalProperties": false, + "properties": { + "anchor": { + "type": "string" + }, + "docLocation": { + "type": "string" + }, + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "target": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "address", + "properties" + ], + "type": "object" + }, + "type": "array" + }, + "page": { + "$ref": "#/$defs/PageInfo" + }, + "total": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "evaluatedRevision", + "total", + "items", + "page" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/hyperlinks/patch.mdx b/apps/docs/document-api/reference/hyperlinks/patch.mdx new file mode 100644 index 0000000000..fdf77fa413 --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/patch.mdx @@ -0,0 +1,395 @@ +--- +title: hyperlinks.patch +sidebarTitle: hyperlinks.patch +description: Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. + +- Operation ID: `hyperlinks.patch` +- API member path: `editor.doc.hyperlinks.patch(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HyperlinkMutationResult with the updated hyperlink address on success, or NO_OP if unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `patch` | object | yes | | +| `patch.anchor` | string \\| null | no | One of: string, null | +| `patch.docLocation` | string \\| null | no | One of: string, null | +| `patch.href` | string \\| null | no | One of: string, null | +| `patch.rel` | string \\| null | no | One of: string, null | +| `patch.target` | string \\| null | no | One of: string, null | +| `patch.tooltip` | string \\| null | no | One of: string, null | +| `target` | object(kind="inline") | yes | | +| `target.anchor` | InlineAnchor | yes | InlineAnchor | +| `target.anchor.end` | Position | yes | Position | +| `target.anchor.end.blockId` | string | yes | | +| `target.anchor.end.offset` | integer | yes | | +| `target.anchor.start` | Position | yes | Position | +| `target.anchor.start.blockId` | string | yes | | +| `target.anchor.start.offset` | integer | yes | | +| `target.kind` | `"inline"` | yes | Constant: `"inline"` | +| `target.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | + +### Example request + +```json +{ + "patch": { + "anchor": "example", + "href": "example" + }, + "target": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + } +} +``` + +## Output fields + +### Variant 1 (hyperlink.kind="inline") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `hyperlink` | object(kind="inline") | yes | | +| `hyperlink.anchor` | InlineAnchor | yes | InlineAnchor | +| `hyperlink.anchor.end` | Position | yes | Position | +| `hyperlink.anchor.end.blockId` | string | yes | | +| `hyperlink.anchor.end.offset` | integer | yes | | +| `hyperlink.anchor.start` | Position | yes | Position | +| `hyperlink.anchor.start.blockId` | string | yes | | +| `hyperlink.anchor.start.offset` | integer | yes | | +| `hyperlink.kind` | `"inline"` | yes | Constant: `"inline"` | +| `hyperlink.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"` | +| `failure.details` | object | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "hyperlink": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "patch": { + "additionalProperties": false, + "properties": { + "anchor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "docLocation": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "href": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rel": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "target": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tooltip": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + }, + "required": [ + "target", + "patch" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/hyperlinks/remove.mdx b/apps/docs/document-api/reference/hyperlinks/remove.mdx new file mode 100644 index 0000000000..22ef399f4d --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/remove.mdx @@ -0,0 +1,325 @@ +--- +title: hyperlinks.remove +sidebarTitle: hyperlinks.remove +description: "Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. + +- Operation ID: `hyperlinks.remove` +- API member path: `editor.doc.hyperlinks.remove(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HyperlinkMutationResult with the removed hyperlink address on success, or a failure code on no-op. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `mode` | enum | no | `"unwrap"`, `"deleteText"` | +| `target` | object(kind="inline") | yes | | +| `target.anchor` | InlineAnchor | yes | InlineAnchor | +| `target.anchor.end` | Position | yes | Position | +| `target.anchor.end.blockId` | string | yes | | +| `target.anchor.end.offset` | integer | yes | | +| `target.anchor.start` | Position | yes | Position | +| `target.anchor.start.blockId` | string | yes | | +| `target.anchor.start.offset` | integer | yes | | +| `target.kind` | `"inline"` | yes | Constant: `"inline"` | +| `target.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | + +### Example request + +```json +{ + "mode": "unwrap", + "target": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + } +} +``` + +## Output fields + +### Variant 1 (hyperlink.kind="inline") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `hyperlink` | object(kind="inline") | yes | | +| `hyperlink.anchor` | InlineAnchor | yes | InlineAnchor | +| `hyperlink.anchor.end` | Position | yes | Position | +| `hyperlink.anchor.end.blockId` | string | yes | | +| `hyperlink.anchor.end.offset` | integer | yes | | +| `hyperlink.anchor.start` | Position | yes | Position | +| `hyperlink.anchor.start.blockId` | string | yes | | +| `hyperlink.anchor.start.offset` | integer | yes | | +| `hyperlink.kind` | `"inline"` | yes | Constant: `"inline"` | +| `hyperlink.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"` | +| `failure.details` | object | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "hyperlink": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "unwrap", + "deleteText" + ] + }, + "target": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/hyperlinks/wrap.mdx b/apps/docs/document-api/reference/hyperlinks/wrap.mdx new file mode 100644 index 0000000000..050de1d159 --- /dev/null +++ b/apps/docs/document-api/reference/hyperlinks/wrap.mdx @@ -0,0 +1,340 @@ +--- +title: hyperlinks.wrap +sidebarTitle: hyperlinks.wrap +description: Wrap an existing text range with a hyperlink. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Wrap an existing text range with a hyperlink. + +- Operation ID: `hyperlinks.wrap` +- API member path: `editor.doc.hyperlinks.wrap(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HyperlinkMutationResult with the created hyperlink address on success, or a failure code on no-op. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `link` | object | yes | | +| `link.destination` | object | yes | | +| `link.destination.anchor` | string | no | | +| `link.destination.docLocation` | string | no | | +| `link.destination.href` | string | no | | +| `link.rel` | string | no | | +| `link.target` | string | no | | +| `link.tooltip` | string | no | | +| `target` | TextAddress | yes | TextAddress | +| `target.blockId` | string | yes | | +| `target.kind` | `"text"` | yes | Constant: `"text"` | +| `target.range` | Range | yes | Range | +| `target.range.end` | integer | yes | | +| `target.range.start` | integer | yes | | + +### Example request + +```json +{ + "link": { + "destination": { + "anchor": "example", + "href": "example" + }, + "target": "example", + "tooltip": "example" + }, + "target": { + "blockId": "block-abc123", + "kind": "text", + "range": { + "end": 10, + "start": 0 + } + } +} +``` + +## Output fields + +### Variant 1 (hyperlink.kind="inline") + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `hyperlink` | object(kind="inline") | yes | | +| `hyperlink.anchor` | InlineAnchor | yes | InlineAnchor | +| `hyperlink.anchor.end` | Position | yes | Position | +| `hyperlink.anchor.end.blockId` | string | yes | | +| `hyperlink.anchor.end.offset` | integer | yes | | +| `hyperlink.anchor.start` | Position | yes | Position | +| `hyperlink.anchor.start.blockId` | string | yes | | +| `hyperlink.anchor.start.offset` | integer | yes | | +| `hyperlink.kind` | `"inline"` | yes | Constant: `"inline"` | +| `hyperlink.nodeType` | `"hyperlink"` | yes | Constant: `"hyperlink"` | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"` | +| `failure.details` | object | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "hyperlink": { + "anchor": { + "end": { + "blockId": "block-abc123", + "offset": 0 + }, + "start": { + "blockId": "block-abc123", + "offset": 0 + } + }, + "kind": "inline", + "nodeType": "hyperlink" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "link": { + "additionalProperties": false, + "properties": { + "destination": { + "additionalProperties": false, + "properties": { + "anchor": { + "type": "string" + }, + "docLocation": { + "type": "string" + }, + "href": { + "type": "string" + } + }, + "type": "object" + }, + "rel": { + "type": "string" + }, + "target": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "required": [ + "destination" + ], + "type": "object" + }, + "target": { + "$ref": "#/$defs/TextAddress" + } + }, + "required": [ + "target", + "link" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "hyperlink": { + "additionalProperties": false, + "properties": { + "anchor": { + "$ref": "#/$defs/InlineAnchor" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "hyperlink" + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "hyperlink" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": { + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 9e1790c953..75ab607fe8 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -38,6 +38,7 @@ Document API is currently alpha and subject to breaking changes. | History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | | Table of Contents | 10 | 0 | 10 | [Open](/document-api/reference/toc/index) | | Images | 13 | 0 | 13 | [Open](/document-api/reference/images/index) | +| Hyperlinks | 6 | 0 | 6 | [Open](/document-api/reference/hyperlinks/index) | ## Available operations @@ -341,3 +342,14 @@ The tables below are grouped by namespace. | images.setPosition | editor.doc.images.setPosition(...) | Set the anchor position for a floating image. | | images.setAnchorOptions | editor.doc.images.setAnchorOptions(...) | Set anchor behavior options for a floating image. | | images.setZOrder | editor.doc.images.setZOrder(...) | Set the z-order (relativeHeight) for a floating image. | + +#### Hyperlinks + +| Operation | API member path | Description | +| --- | --- | --- | +| hyperlinks.list | editor.doc.hyperlinks.list(...) | List all hyperlinks in the document, with optional filtering by href, anchor, or display text. | +| hyperlinks.get | editor.doc.hyperlinks.get(...) | Retrieve details of a specific hyperlink by its inline address. | +| hyperlinks.wrap | editor.doc.hyperlinks.wrap(...) | Wrap an existing text range with a hyperlink. | +| hyperlinks.insert | editor.doc.hyperlinks.insert(...) | Insert new linked text at a target position. | +| hyperlinks.patch | editor.doc.hyperlinks.patch(...) | Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. | +| hyperlinks.remove | editor.doc.hyperlinks.remove(...) | Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 50700a4132..2987914c50 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -350,26 +350,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p -#### Core - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | -| `doc.getNode` | `get-node` | Retrieve a single node by target position. | -| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | -| `doc.getText` | `get-text` | Extract the plain-text content of the document. | -| `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | -| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | -| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | -| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | -| `doc.delete` | `delete` | Delete content at a target position. | -| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | -| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | -| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | -| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | -| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | - #### Format | Operation | CLI command | Description | @@ -418,26 +398,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.numSpacing` | `format num-spacing` | Set or clear the `numSpacing` inline run property on the target text range. | | `doc.format.stylisticSets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextualAlternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | -| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | -| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | -| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | -| `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | -| `doc.format.paragraph.clearAlignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | -| `doc.format.paragraph.setIndentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | -| `doc.format.paragraph.clearIndentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | -| `doc.format.paragraph.setSpacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | -| `doc.format.paragraph.clearSpacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | -| `doc.format.paragraph.setKeepOptions` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | -| `doc.format.paragraph.setOutlineLevel` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | -| `doc.format.paragraph.setFlowOptions` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | -| `doc.format.paragraph.setTabStop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | -| `doc.format.paragraph.clearTabStop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | -| `doc.format.paragraph.clearAllTabStops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | -| `doc.format.paragraph.setBorder` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | -| `doc.format.paragraph.clearBorder` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | -| `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | -| `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | #### Create @@ -616,39 +576,110 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | -| `doc.close` | `close` | Close the active editing session and clean up resources. | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | | `doc.session.list` | `session list` | List all active editing sessions. | | `doc.session.save` | `session save` | Persist the current session state. | | `doc.session.close` | `session close` | Close a specific editing session by ID. | | `doc.session.setDefault` | `session set-default` | Set the default session for subsequent commands. | - - +#### Blocks + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | -#### Core +#### Capabilities + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | + +#### Format / Paragraph + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | +| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | +| `doc.format.paragraph.clearAlignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | +| `doc.format.paragraph.setIndentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | +| `doc.format.paragraph.clearIndentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | +| `doc.format.paragraph.setSpacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | +| `doc.format.paragraph.clearSpacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | +| `doc.format.paragraph.setKeepOptions` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | +| `doc.format.paragraph.setOutlineLevel` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | +| `doc.format.paragraph.setFlowOptions` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | +| `doc.format.paragraph.setTabStop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | +| `doc.format.paragraph.clearTabStop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | +| `doc.format.paragraph.clearAllTabStops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | +| `doc.format.paragraph.setBorder` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | +| `doc.format.paragraph.clearBorder` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | +| `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | +| `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | + +#### Hyperlinks + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.hyperlinks.list` | `hyperlinks list` | List all hyperlinks in the document, with optional filtering by href, anchor, or display text. | +| `doc.hyperlinks.get` | `hyperlinks get` | Retrieve details of a specific hyperlink by its inline address. | +| `doc.hyperlinks.wrap` | `hyperlinks wrap` | Wrap an existing text range with a hyperlink. | +| `doc.hyperlinks.insert` | `hyperlinks insert` | Insert new linked text at a target position. | +| `doc.hyperlinks.patch` | `hyperlinks patch` | Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. | +| `doc.hyperlinks.remove` | `hyperlinks remove` | Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. | + +#### Introspection + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.status` | `status` | Show the current session status and document metadata. | +| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | +| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | + +#### Lifecycle + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.close` | `close` | Close the active editing session and clean up resources. | + +#### Mutation | Operation | CLI command | Description | | --- | --- | --- | -| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | -| `doc.get_node` | `get-node` | Retrieve a single node by target position. | -| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. | -| `doc.get_text` | `get-text` | Extract the plain-text content of the document. | -| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | -| `doc.get_html` | `get-html` | Extract the document content as an HTML string. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | | `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | | `doc.delete` | `delete` | Delete content at a target position. | -| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | +| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | + +#### Query + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | +| `doc.getNode` | `get-node` | Retrieve a single node by target position. | +| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.getText` | `get-text` | Extract the plain-text content of the document. | +| `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | +| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | +| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | | `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | | `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | -| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | -| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | + +#### Styles + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | + +#### Styles / Paragraph + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | + + + #### Format @@ -698,26 +729,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.num_spacing` | `format num-spacing` | Set or clear the `numSpacing` inline run property on the target text range. | | `doc.format.stylistic_sets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextual_alternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | -| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | -| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | -| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | -| `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | -| `doc.format.paragraph.clear_alignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | -| `doc.format.paragraph.set_indentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | -| `doc.format.paragraph.clear_indentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | -| `doc.format.paragraph.set_spacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | -| `doc.format.paragraph.clear_spacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | -| `doc.format.paragraph.set_keep_options` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | -| `doc.format.paragraph.set_outline_level` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | -| `doc.format.paragraph.set_flow_options` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | -| `doc.format.paragraph.set_tab_stop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | -| `doc.format.paragraph.clear_tab_stop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | -| `doc.format.paragraph.clear_all_tab_stops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | -| `doc.format.paragraph.set_border` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | -| `doc.format.paragraph.clear_border` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | -| `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | -| `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | #### Create @@ -896,17 +907,108 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | -| `doc.close` | `close` | Close the active editing session and clean up resources. | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | | `doc.session.list` | `session list` | List all active editing sessions. | | `doc.session.save` | `session save` | Persist the current session state. | | `doc.session.close` | `session close` | Close a specific editing session by ID. | | `doc.session.set_default` | `session set-default` | Set the default session for subsequent commands. | +#### Blocks + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | + +#### Capabilities + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | + +#### Format / Paragraph + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | +| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | +| `doc.format.paragraph.clear_alignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | +| `doc.format.paragraph.set_indentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | +| `doc.format.paragraph.clear_indentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | +| `doc.format.paragraph.set_spacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | +| `doc.format.paragraph.clear_spacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | +| `doc.format.paragraph.set_keep_options` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | +| `doc.format.paragraph.set_outline_level` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | +| `doc.format.paragraph.set_flow_options` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | +| `doc.format.paragraph.set_tab_stop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | +| `doc.format.paragraph.clear_tab_stop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | +| `doc.format.paragraph.clear_all_tab_stops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | +| `doc.format.paragraph.set_border` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | +| `doc.format.paragraph.clear_border` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | +| `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | +| `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | + +#### Hyperlinks + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.hyperlinks.list` | `hyperlinks list` | List all hyperlinks in the document, with optional filtering by href, anchor, or display text. | +| `doc.hyperlinks.get` | `hyperlinks get` | Retrieve details of a specific hyperlink by its inline address. | +| `doc.hyperlinks.wrap` | `hyperlinks wrap` | Wrap an existing text range with a hyperlink. | +| `doc.hyperlinks.insert` | `hyperlinks insert` | Insert new linked text at a target position. | +| `doc.hyperlinks.patch` | `hyperlinks patch` | Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. | +| `doc.hyperlinks.remove` | `hyperlinks remove` | Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. | + +#### Introspection + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.status` | `status` | Show the current session status and document metadata. | +| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | +| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | + +#### Lifecycle + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.close` | `close` | Close the active editing session and clean up resources. | + +#### Mutation + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | +| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | +| `doc.delete` | `delete` | Delete content at a target position. | +| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | + +#### Query + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | +| `doc.get_node` | `get-node` | Retrieve a single node by target position. | +| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.get_text` | `get-text` | Extract the plain-text content of the document. | +| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | +| `doc.get_html` | `get-html` | Extract the document content as an HTML string. | +| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | +| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | + +#### Styles + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | + +#### Styles / Paragraph + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | + {/* SDK_OPERATIONS_END */} diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 4b950d8dde..e8580605d3 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -174,6 +174,7 @@ describe('document-api contract catalog', () => { 'history', 'toc', 'images', + 'hyperlinks', ]; for (const id of OPERATION_IDS) { expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 42161e6980..72cc496644 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -50,7 +50,8 @@ export type ReferenceGroupKey = | 'tables' | 'history' | 'toc' - | 'images'; + | 'images' + | 'hyperlinks'; // --------------------------------------------------------------------------- // Entry shape @@ -2751,6 +2752,104 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'images/set-z-order.mdx', referenceGroup: 'images', }, + + // ------------------------------------------------------------------------- + // Hyperlinks: discovery + CRUD + // ------------------------------------------------------------------------- + + 'hyperlinks.list': { + memberPath: 'hyperlinks.list', + description: 'List all hyperlinks in the document, with optional filtering by href, anchor, or display text.', + expectedResult: + 'Returns a HyperlinksListResult with an array of hyperlink discovery items and pagination metadata.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + }), + referenceDocPath: 'hyperlinks/list.mdx', + referenceGroup: 'hyperlinks', + }, + 'hyperlinks.get': { + memberPath: 'hyperlinks.get', + description: 'Retrieve details of a specific hyperlink by its inline address.', + expectedResult: 'Returns a HyperlinkInfo object with the address, destination properties, and display text.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET'], + }), + referenceDocPath: 'hyperlinks/get.mdx', + referenceGroup: 'hyperlinks', + }, + 'hyperlinks.wrap': { + memberPath: 'hyperlinks.wrap', + description: 'Wrap an existing text range with a hyperlink.', + expectedResult: + 'Returns a HyperlinkMutationResult with the created hyperlink address on success, or a failure code on no-op.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + deterministicTargetResolution: true, + possibleFailureCodes: ['NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'hyperlinks/wrap.mdx', + referenceGroup: 'hyperlinks', + }, + 'hyperlinks.insert': { + memberPath: 'hyperlinks.insert', + description: 'Insert new linked text at a target position.', + expectedResult: + 'Returns a HyperlinkMutationResult with the created hyperlink address on success, or a failure code.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + deterministicTargetResolution: true, + possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'hyperlinks/insert.mdx', + referenceGroup: 'hyperlinks', + }, + 'hyperlinks.patch': { + memberPath: 'hyperlinks.patch', + description: 'Update hyperlink metadata (destination, tooltip, target, rel) without changing display text.', + expectedResult: + 'Returns a HyperlinkMutationResult with the updated hyperlink address on success, or NO_OP if unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + deterministicTargetResolution: true, + possibleFailureCodes: ['NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'hyperlinks/patch.mdx', + referenceGroup: 'hyperlinks', + }, + 'hyperlinks.remove': { + memberPath: 'hyperlinks.remove', + description: + "Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely.", + expectedResult: + 'Returns a HyperlinkMutationResult with the removed hyperlink address on success, or a failure code on no-op.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + deterministicTargetResolution: true, + possibleFailureCodes: ['NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT'], + }), + referenceDocPath: 'hyperlinks/remove.mdx', + referenceGroup: 'hyperlinks', + }, } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index c0e316dec6..9a7c5546be 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -229,6 +229,17 @@ import type { TablesSetDefaultStyleInput, TablesClearDefaultStyleInput, } from '../types/table-operations.types.js'; +import type { + HyperlinksListQuery, + HyperlinksListResult, + HyperlinksGetInput, + HyperlinkInfo, + HyperlinksWrapInput, + HyperlinksInsertInput, + HyperlinksPatchInput, + HyperlinksRemoveInput, + HyperlinkMutationResult, +} from '../hyperlinks/hyperlinks.types.js'; type FormatInlineAliasOperationRegistry = { [K in InlineRunPatchKey as `format.${K}`]: { @@ -663,6 +674,14 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'images.setPosition': { input: SetPositionInput; options: MutationOptions; output: ImagesMutationResult }; 'images.setAnchorOptions': { input: SetAnchorOptionsInput; options: MutationOptions; output: ImagesMutationResult }; 'images.setZOrder': { input: SetZOrderInput; options: MutationOptions; output: ImagesMutationResult }; + + // --- hyperlinks.* --- + 'hyperlinks.list': { input: HyperlinksListQuery | undefined; options: never; output: HyperlinksListResult }; + 'hyperlinks.get': { input: HyperlinksGetInput; options: never; output: HyperlinkInfo }; + 'hyperlinks.wrap': { input: HyperlinksWrapInput; options: MutationOptions; output: HyperlinkMutationResult }; + 'hyperlinks.insert': { input: HyperlinksInsertInput; options: MutationOptions; output: HyperlinkMutationResult }; + 'hyperlinks.patch': { input: HyperlinksPatchInput; options: MutationOptions; output: HyperlinkMutationResult }; + 'hyperlinks.remove': { input: HyperlinksRemoveInput; options: MutationOptions; output: HyperlinkMutationResult }; } // --- Bidirectional completeness checks --- diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index 80cba678ca..8a2957b0c6 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -111,6 +111,11 @@ const GROUP_METADATA: Record = { find: { input: findInputSchema, @@ -4271,6 +4367,50 @@ const operationSchemas: Record = { ['success', 'failure'], ), }, + + // --- hyperlinks.* --- + 'hyperlinks.list': { + input: objectSchema({ + within: nodeAddressSchema, + hrefPattern: { type: 'string' }, + anchor: { type: 'string' }, + textPattern: { type: 'string' }, + limit: { type: 'integer' }, + offset: { type: 'integer' }, + }), + output: discoveryResultSchema(hyperlinkDomainSchema), + }, + 'hyperlinks.get': { + input: objectSchema({ target: hyperlinkTargetSchema }, ['target']), + output: hyperlinkInfoSchema, + }, + 'hyperlinks.wrap': { + input: objectSchema({ target: textAddressSchema, link: hyperlinkSpecSchema }, ['target', 'link']), + output: hyperlinkMutationResultSchema(), + success: hyperlinkMutationSuccessSchema, + failure: hyperlinkMutationFailureSchema, + }, + 'hyperlinks.insert': { + input: objectSchema({ target: textAddressSchema, text: { type: 'string' }, link: hyperlinkSpecSchema }, [ + 'text', + 'link', + ]), + output: hyperlinkMutationResultSchema(), + success: hyperlinkMutationSuccessSchema, + failure: hyperlinkMutationFailureSchema, + }, + 'hyperlinks.patch': { + input: objectSchema({ target: hyperlinkTargetSchema, patch: hyperlinkPatchSchema }, ['target', 'patch']), + output: hyperlinkMutationResultSchema(), + success: hyperlinkMutationSuccessSchema, + failure: hyperlinkMutationFailureSchema, + }, + 'hyperlinks.remove': { + input: objectSchema({ target: hyperlinkTargetSchema, mode: { enum: ['unwrap', 'deleteText'] } }, ['target']), + output: hyperlinkMutationResultSchema(), + success: hyperlinkMutationSuccessSchema, + failure: hyperlinkMutationFailureSchema, + }, }; /** diff --git a/packages/document-api/src/hyperlinks/hyperlinks.test.ts b/packages/document-api/src/hyperlinks/hyperlinks.test.ts new file mode 100644 index 0000000000..b43dd955b3 --- /dev/null +++ b/packages/document-api/src/hyperlinks/hyperlinks.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + executeHyperlinksList, + executeHyperlinksGet, + executeHyperlinksWrap, + executeHyperlinksInsert, + executeHyperlinksPatch, + executeHyperlinksRemove, + type HyperlinksAdapter, +} from './hyperlinks.js'; +import { DocumentApiValidationError } from '../errors.js'; +import type { + HyperlinksListResult, + HyperlinkInfo, + HyperlinkMutationResult, + HyperlinkTarget, +} from './hyperlinks.types.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeAdapter(overrides: Partial = {}): HyperlinksAdapter { + const defaultResult: HyperlinkMutationResult = { + success: true, + hyperlink: validTarget(), + }; + const defaultInfo: HyperlinkInfo = { + address: validTarget(), + properties: { href: 'https://example.com' }, + text: 'Example', + }; + const defaultList: HyperlinksListResult = { + evaluatedRevision: '1', + total: 0, + items: [], + page: { limit: 100, offset: 0, returned: 0 }, + }; + + return { + list: vi.fn(() => defaultList), + get: vi.fn(() => defaultInfo), + wrap: vi.fn(() => defaultResult), + insert: vi.fn(() => defaultResult), + patch: vi.fn(() => defaultResult), + remove: vi.fn(() => defaultResult), + ...overrides, + }; +} + +function validTarget(): HyperlinkTarget { + return { + kind: 'inline', + nodeType: 'hyperlink', + anchor: { + start: { blockId: 'p1', offset: 0 }, + end: { blockId: 'p1', offset: 5 }, + }, + }; +} + +function validTextAddress(start = 0, end = 5) { + return { kind: 'text' as const, blockId: 'p1', range: { start, end } }; +} + +function validLink() { + return { destination: { href: 'https://example.com' } }; +} + +function expectValidationError(fn: () => void, code: string, messagePattern?: RegExp) { + try { + fn(); + throw new Error('Expected DocumentApiValidationError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + expect((err as DocumentApiValidationError).code).toBe(code); + if (messagePattern) { + expect((err as DocumentApiValidationError).message).toMatch(messagePattern); + } + } +} + +// --------------------------------------------------------------------------- +// hyperlinks.list +// --------------------------------------------------------------------------- + +describe('executeHyperlinksList', () => { + it('delegates to adapter.list', () => { + const adapter = makeAdapter(); + executeHyperlinksList(adapter); + expect(adapter.list).toHaveBeenCalledTimes(1); + }); + + it('passes query through', () => { + const adapter = makeAdapter(); + const query = { hrefPattern: 'example', limit: 10 }; + executeHyperlinksList(adapter, query); + expect(adapter.list).toHaveBeenCalledWith(query); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.get +// --------------------------------------------------------------------------- + +describe('executeHyperlinksGet', () => { + it('delegates to adapter.get with valid target', () => { + const adapter = makeAdapter(); + executeHyperlinksGet(adapter, { target: validTarget() }); + expect(adapter.get).toHaveBeenCalledTimes(1); + }); + + it('rejects missing target', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeHyperlinksGet(adapter, { target: undefined as never }), 'INVALID_TARGET'); + }); + + it('rejects target with wrong kind', () => { + const adapter = makeAdapter(); + const badTarget = { ...validTarget(), kind: 'block' } as never; + expectValidationError(() => executeHyperlinksGet(adapter, { target: badTarget }), 'INVALID_TARGET'); + }); + + it('rejects target with wrong nodeType', () => { + const adapter = makeAdapter(); + const badTarget = { ...validTarget(), nodeType: 'comment' } as never; + expectValidationError(() => executeHyperlinksGet(adapter, { target: badTarget }), 'INVALID_TARGET'); + }); + + it('rejects target with missing anchor', () => { + const adapter = makeAdapter(); + const badTarget = { kind: 'inline', nodeType: 'hyperlink' } as never; + expectValidationError(() => executeHyperlinksGet(adapter, { target: badTarget }), 'INVALID_TARGET'); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.wrap +// --------------------------------------------------------------------------- + +describe('executeHyperlinksWrap', () => { + it('delegates to adapter.wrap with valid input', () => { + const adapter = makeAdapter(); + executeHyperlinksWrap(adapter, { target: validTextAddress(), link: validLink() }); + expect(adapter.wrap).toHaveBeenCalledTimes(1); + }); + + it('rejects non-TextAddress target', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksWrap(adapter, { target: { kind: 'block' } as never, link: validLink() }), + 'INVALID_TARGET', + ); + }); + + it('rejects collapsed range (start === end)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksWrap(adapter, { target: validTextAddress(3, 3), link: validLink() }), + 'INVALID_TARGET', + /non-collapsed/, + ); + }); + + it('rejects link with no destination href or anchor', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksWrap(adapter, { target: validTextAddress(), link: { destination: {} } }), + 'INVALID_INPUT', + /at least one of/, + ); + }); + + it('accepts link with anchor-only destination', () => { + const adapter = makeAdapter(); + executeHyperlinksWrap(adapter, { + target: validTextAddress(), + link: { destination: { anchor: 'bookmark1' } }, + }); + expect(adapter.wrap).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.insert +// --------------------------------------------------------------------------- + +describe('executeHyperlinksInsert', () => { + it('delegates to adapter.insert with valid input and target', () => { + const adapter = makeAdapter(); + executeHyperlinksInsert(adapter, { target: validTextAddress(3, 3), text: 'Click', link: validLink() }); + expect(adapter.insert).toHaveBeenCalledTimes(1); + }); + + it('allows omitted target (target-less insert)', () => { + const adapter = makeAdapter(); + executeHyperlinksInsert(adapter, { text: 'Click', link: validLink() }); + expect(adapter.insert).toHaveBeenCalledTimes(1); + }); + + it('rejects empty text', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksInsert(adapter, { text: '', link: validLink() }), + 'INVALID_INPUT', + /non-empty text/, + ); + }); + + it('rejects non-TextAddress target', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksInsert(adapter, { target: { kind: 'block' } as never, text: 'Click', link: validLink() }), + 'INVALID_TARGET', + ); + }); + + it('rejects non-collapsed target range', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksInsert(adapter, { target: validTextAddress(0, 5), text: 'Click', link: validLink() }), + 'INVALID_TARGET', + /collapsed range/, + ); + }); + + it('rejects link with no destination', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksInsert(adapter, { text: 'Click', link: { destination: {} } }), + 'INVALID_INPUT', + /at least one of/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.patch +// --------------------------------------------------------------------------- + +describe('executeHyperlinksPatch', () => { + it('delegates to adapter.patch with valid input', () => { + const adapter = makeAdapter(); + executeHyperlinksPatch(adapter, { target: validTarget(), patch: { href: 'https://new.com' } }); + expect(adapter.patch).toHaveBeenCalledTimes(1); + }); + + it('rejects invalid target', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksPatch(adapter, { target: {} as never, patch: { href: 'https://new.com' } }), + 'INVALID_TARGET', + ); + }); + + it('rejects non-object patch', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksPatch(adapter, { target: validTarget(), patch: 'bad' as never }), + 'INVALID_INPUT', + ); + }); + + it('rejects unknown patch fields', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksPatch(adapter, { target: validTarget(), patch: { unknown: 'x' } as never }), + 'INVALID_INPUT', + /Unknown field/, + ); + }); + + it('rejects empty patch (no fields set)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksPatch(adapter, { target: validTarget(), patch: {} }), + 'INVALID_INPUT', + /at least one field/, + ); + }); + + it('accepts null values for clearing fields', () => { + const adapter = makeAdapter(); + executeHyperlinksPatch(adapter, { target: validTarget(), patch: { tooltip: null } }); + expect(adapter.patch).toHaveBeenCalledTimes(1); + }); + + it('rejects non-string, non-null field values', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksPatch(adapter, { target: validTarget(), patch: { href: 42 as never } }), + 'INVALID_INPUT', + /string, null, or omitted/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.remove +// --------------------------------------------------------------------------- + +describe('executeHyperlinksRemove', () => { + it('delegates to adapter.remove with valid input', () => { + const adapter = makeAdapter(); + executeHyperlinksRemove(adapter, { target: validTarget() }); + expect(adapter.remove).toHaveBeenCalledTimes(1); + }); + + it('accepts mode: unwrap', () => { + const adapter = makeAdapter(); + executeHyperlinksRemove(adapter, { target: validTarget(), mode: 'unwrap' }); + expect(adapter.remove).toHaveBeenCalledTimes(1); + }); + + it('accepts mode: deleteText', () => { + const adapter = makeAdapter(); + executeHyperlinksRemove(adapter, { target: validTarget(), mode: 'deleteText' }); + expect(adapter.remove).toHaveBeenCalledTimes(1); + }); + + it('rejects invalid mode', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeHyperlinksRemove(adapter, { target: validTarget(), mode: 'invalid' as never }), + 'INVALID_INPUT', + /unwrap.*deleteText/, + ); + }); + + it('rejects invalid target', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeHyperlinksRemove(adapter, { target: null as never }), 'INVALID_TARGET'); + }); +}); diff --git a/packages/document-api/src/hyperlinks/hyperlinks.ts b/packages/document-api/src/hyperlinks/hyperlinks.ts new file mode 100644 index 0000000000..7c22f1827f --- /dev/null +++ b/packages/document-api/src/hyperlinks/hyperlinks.ts @@ -0,0 +1,216 @@ +import type { MutationOptions } from '../write/write.js'; +import { normalizeMutationOptions } from '../write/write.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, isTextAddress } from '../validation-primitives.js'; +import type { + HyperlinkTarget, + HyperlinksListQuery, + HyperlinksListResult, + HyperlinksGetInput, + HyperlinkInfo, + HyperlinksWrapInput, + HyperlinkMutationResult, + HyperlinksInsertInput, + HyperlinksPatchInput, + HyperlinksRemoveInput, + HyperlinkPatch, + HyperlinkSpec, +} from './hyperlinks.types.js'; + +// --------------------------------------------------------------------------- +// Adapter / API interfaces +// --------------------------------------------------------------------------- + +export interface HyperlinksApi { + list(query?: HyperlinksListQuery): HyperlinksListResult; + get(input: HyperlinksGetInput): HyperlinkInfo; + wrap(input: HyperlinksWrapInput, options?: MutationOptions): HyperlinkMutationResult; + insert(input: HyperlinksInsertInput, options?: MutationOptions): HyperlinkMutationResult; + patch(input: HyperlinksPatchInput, options?: MutationOptions): HyperlinkMutationResult; + remove(input: HyperlinksRemoveInput, options?: MutationOptions): HyperlinkMutationResult; +} + +export type HyperlinksAdapter = HyperlinksApi; + +// --------------------------------------------------------------------------- +// Target validation helpers +// --------------------------------------------------------------------------- + +function isHyperlinkTarget(value: unknown): value is HyperlinkTarget { + if (!isRecord(value)) return false; + if (value.kind !== 'inline' || value.nodeType !== 'hyperlink') return false; + const anchor = value.anchor; + if (!isRecord(anchor)) return false; + const start = anchor.start; + const end = anchor.end; + if (!isRecord(start) || !isRecord(end)) return false; + return ( + typeof start.blockId === 'string' && + typeof start.offset === 'number' && + typeof end.blockId === 'string' && + typeof end.offset === 'number' + ); +} + +function validateHyperlinkTarget(target: unknown, operationName: string): asserts target is HyperlinkTarget { + if (target === undefined || target === null) { + throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`); + } + if (!isHyperlinkTarget(target)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} target must be a HyperlinkTarget with kind 'inline', nodeType 'hyperlink', and a valid anchor.`, + { target }, + ); + } +} + +// --------------------------------------------------------------------------- +// Destination validation +// --------------------------------------------------------------------------- + +function validateDestination( + destination: unknown, + operationName: string, +): asserts destination is { href?: string; anchor?: string; docLocation?: string } { + if (!isRecord(destination)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operationName} requires a destination object.`); + } + const hasHref = typeof destination.href === 'string' && destination.href.length > 0; + const hasAnchor = typeof destination.anchor === 'string' && destination.anchor.length > 0; + if (!hasHref && !hasAnchor) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${operationName} destination must have at least one of 'href' or 'anchor'.`, + ); + } +} + +function validateHyperlinkSpec(link: unknown, operationName: string): asserts link is HyperlinkSpec { + if (!isRecord(link)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operationName} requires a link specification object.`); + } + validateDestination(link.destination, operationName); +} + +// --------------------------------------------------------------------------- +// Patch validation +// --------------------------------------------------------------------------- + +const PATCH_FIELDS = new Set(['href', 'anchor', 'docLocation', 'tooltip', 'target', 'rel']); + +function validatePatch(patch: unknown, operationName: string): asserts patch is HyperlinkPatch { + if (!isRecord(patch)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operationName} requires a patch object.`); + } + // Check for unknown fields + for (const key of Object.keys(patch)) { + if (!PATCH_FIELDS.has(key)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown field "${key}" on ${operationName} patch. Allowed: ${[...PATCH_FIELDS].join(', ')}.`, + { field: key }, + ); + } + } + // Ensure at least one field is set (not undefined) + const hasField = Object.keys(patch).some((k) => patch[k] !== undefined); + if (!hasField) { + throw new DocumentApiValidationError('INVALID_INPUT', `${operationName} patch must set at least one field.`); + } + // Validate field types: each must be string, null, or undefined + for (const key of PATCH_FIELDS) { + const val = patch[key]; + if (val !== undefined && val !== null && typeof val !== 'string') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${operationName} patch.${key} must be a string, null, or omitted.`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Execute wrappers +// --------------------------------------------------------------------------- + +export function executeHyperlinksList(adapter: HyperlinksAdapter, query?: HyperlinksListQuery): HyperlinksListResult { + return adapter.list(query); +} + +export function executeHyperlinksGet(adapter: HyperlinksAdapter, input: HyperlinksGetInput): HyperlinkInfo { + validateHyperlinkTarget(input.target, 'hyperlinks.get'); + return adapter.get(input); +} + +export function executeHyperlinksWrap( + adapter: HyperlinksAdapter, + input: HyperlinksWrapInput, + options?: MutationOptions, +): HyperlinkMutationResult { + if (!isTextAddress(input.target)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + "hyperlinks.wrap requires a valid TextAddress target with kind 'text', blockId, and range.", + ); + } + if (input.target.range.start === input.target.range.end) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'hyperlinks.wrap requires a non-collapsed range (start !== end).', + ); + } + validateHyperlinkSpec(input.link, 'hyperlinks.wrap'); + return adapter.wrap(input, normalizeMutationOptions(options)); +} + +export function executeHyperlinksInsert( + adapter: HyperlinksAdapter, + input: HyperlinksInsertInput, + options?: MutationOptions, +): HyperlinkMutationResult { + if (typeof input.text !== 'string' || input.text.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'hyperlinks.insert requires a non-empty text string.'); + } + if (input.target !== undefined) { + if (!isTextAddress(input.target)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + "hyperlinks.insert target (if provided) must be a valid TextAddress with kind 'text', blockId, and range.", + ); + } + if (input.target.range.start !== input.target.range.end) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'hyperlinks.insert target must be a collapsed range (start === end) indicating the insertion point.', + ); + } + } + validateHyperlinkSpec(input.link, 'hyperlinks.insert'); + return adapter.insert(input, normalizeMutationOptions(options)); +} + +export function executeHyperlinksPatch( + adapter: HyperlinksAdapter, + input: HyperlinksPatchInput, + options?: MutationOptions, +): HyperlinkMutationResult { + validateHyperlinkTarget(input.target, 'hyperlinks.patch'); + validatePatch(input.patch, 'hyperlinks.patch'); + return adapter.patch(input, normalizeMutationOptions(options)); +} + +export function executeHyperlinksRemove( + adapter: HyperlinksAdapter, + input: HyperlinksRemoveInput, + options?: MutationOptions, +): HyperlinkMutationResult { + validateHyperlinkTarget(input.target, 'hyperlinks.remove'); + if (input.mode !== undefined && input.mode !== 'unwrap' && input.mode !== 'deleteText') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `hyperlinks.remove mode must be 'unwrap' or 'deleteText', got '${String(input.mode)}'.`, + ); + } + return adapter.remove(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/hyperlinks/hyperlinks.types.ts b/packages/document-api/src/hyperlinks/hyperlinks.types.ts new file mode 100644 index 0000000000..61c11a7c37 --- /dev/null +++ b/packages/document-api/src/hyperlinks/hyperlinks.types.ts @@ -0,0 +1,155 @@ +import type { InlineNodeAddress, NodeAddress } from '../types/base.js'; +import type { TextAddress } from '../types/address.js'; +import type { DiscoveryOutput } from '../types/discovery.js'; +import type { ReceiptFailure } from '../types/receipt.js'; + +// --------------------------------------------------------------------------- +// Addressing +// --------------------------------------------------------------------------- + +/** Narrowed inline address for hyperlink targets. */ +export type HyperlinkTarget = InlineNodeAddress & { nodeType: 'hyperlink' }; + +// --------------------------------------------------------------------------- +// Destination model +// --------------------------------------------------------------------------- + +/** + * Canonical hyperlink destination specification for write operations. + * + * Destination mode rules (enforced on write — wrap/insert/patch): + * - External mode: `href` is set and non-empty + * - Internal mode: `anchor` is set and non-empty + * - Mixed mode: both `href` and `anchor` may be set (OOXML allows this — + * anchor is used when href target is the same document, href is fallback) + * - Invalid: neither `href` nor `anchor` is set + * + * Read operations (list/get) faithfully report whatever the document contains, + * including documents where both href and anchor are present. + */ +export interface HyperlinkDestination { + /** External/mailto/file URL. Sanitized via @superdoc/url-validation on write. */ + href?: string; + /** OOXML bookmark target name (w:anchor). */ + anchor?: string; + /** Location within target document (w:docLocation). */ + docLocation?: string; +} + +// --------------------------------------------------------------------------- +// Write specs +// --------------------------------------------------------------------------- + +/** Full hyperlink specification for write operations (wrap/insert). */ +export interface HyperlinkSpec { + destination: HyperlinkDestination; + /** Tooltip text (w:tooltip). */ + tooltip?: string; + /** Link target frame (_blank, _self, etc.). */ + target?: string; + /** Relationship attribute string. */ + rel?: string; +} + +/** + * Patch payload for hyperlinks.patch — metadata only, no text mutation. + * + * Set a field to `null` to explicitly clear it. + * Omit a field to leave it unchanged. + */ +export interface HyperlinkPatch { + href?: string | null; + anchor?: string | null; + docLocation?: string | null; + tooltip?: string | null; + target?: string | null; + rel?: string | null; +} + +// --------------------------------------------------------------------------- +// Read types +// --------------------------------------------------------------------------- + +/** All readable hyperlink properties — faithfully reports document state. */ +export interface HyperlinkReadProperties { + href?: string; + anchor?: string; + docLocation?: string; + tooltip?: string; + target?: string; + rel?: string; +} + +/** Domain payload for discovery items in hyperlinks.list. */ +export interface HyperlinkDomain { + address: HyperlinkTarget; + properties: HyperlinkReadProperties; + text?: string; +} + +/** Full info for a single hyperlink via hyperlinks.get. */ +export interface HyperlinkInfo { + address: HyperlinkTarget; + properties: HyperlinkReadProperties; + text?: string; +} + +// --------------------------------------------------------------------------- +// Mutation results +// --------------------------------------------------------------------------- + +export interface HyperlinkMutationSuccess { + success: true; + hyperlink: HyperlinkTarget; +} + +export interface HyperlinkMutationFailure { + success: false; + failure: ReceiptFailure; +} + +export type HyperlinkMutationResult = HyperlinkMutationSuccess | HyperlinkMutationFailure; + +// --------------------------------------------------------------------------- +// Discovery result alias +// --------------------------------------------------------------------------- + +export type HyperlinksListResult = DiscoveryOutput; + +// --------------------------------------------------------------------------- +// Operation inputs +// --------------------------------------------------------------------------- + +export interface HyperlinksListQuery { + within?: NodeAddress; + hrefPattern?: string; + anchor?: string; + textPattern?: string; + limit?: number; + offset?: number; +} + +export interface HyperlinksGetInput { + target: HyperlinkTarget; +} + +export interface HyperlinksWrapInput { + target: TextAddress; + link: HyperlinkSpec; +} + +export interface HyperlinksInsertInput { + target?: TextAddress; + text: string; + link: HyperlinkSpec; +} + +export interface HyperlinksPatchInput { + target: HyperlinkTarget; + patch: HyperlinkPatch; +} + +export interface HyperlinksRemoveInput { + target: HyperlinkTarget; + mode?: 'unwrap' | 'deleteText'; +} diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index ea251d7876..fcad2436e8 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -397,6 +397,26 @@ import type { TocEditEntryInput, TocEntryMutationResult, } from './toc/toc.types.js'; +import type { HyperlinksApi, HyperlinksAdapter } from './hyperlinks/hyperlinks.js'; +import { + executeHyperlinksList, + executeHyperlinksGet, + executeHyperlinksWrap, + executeHyperlinksInsert, + executeHyperlinksPatch, + executeHyperlinksRemove, +} from './hyperlinks/hyperlinks.js'; +import type { + HyperlinksListQuery, + HyperlinksListResult, + HyperlinksGetInput, + HyperlinkInfo, + HyperlinksWrapInput, + HyperlinksInsertInput, + HyperlinksPatchInput, + HyperlinksRemoveInput, + HyperlinkMutationResult, +} from './hyperlinks/hyperlinks.types.js'; export type { FindAdapter, FindOptions } from './find/find.js'; export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; @@ -554,6 +574,26 @@ export type { TocEntryDomain, TocEntryProperties, } from './toc/toc.types.js'; +export type { HyperlinksApi, HyperlinksAdapter } from './hyperlinks/hyperlinks.js'; +export type { + HyperlinkTarget, + HyperlinkDestination, + HyperlinkSpec, + HyperlinkPatch, + HyperlinkReadProperties, + HyperlinkDomain, + HyperlinkInfo, + HyperlinkMutationResult, + HyperlinkMutationSuccess, + HyperlinkMutationFailure, + HyperlinksListResult, + HyperlinksListQuery, + HyperlinksGetInput, + HyperlinksWrapInput, + HyperlinksInsertInput, + HyperlinksPatchInput, + HyperlinksRemoveInput, +} from './hyperlinks/hyperlinks.types.js'; export type { ListsAdapter } from './lists/lists.js'; export type { SectionsAdapter } from './sections/sections.js'; export type { ParagraphsAdapter, ParagraphFormatApi, ParagraphStylesApi } from './paragraphs/paragraphs.js'; @@ -911,6 +951,10 @@ export interface DocumentApi { * Image lifecycle and placement operations. */ images: ImagesApi; + /** + * Hyperlink discovery, creation, and metadata management. + */ + hyperlinks: HyperlinksApi; /** * Selector-based query with cardinality contracts for mutation targeting. */ @@ -966,6 +1010,7 @@ export interface DocumentApiAdapters { tables: TablesAdapter; toc: TocAdapter; images: ImagesAdapter & CreateImageAdapter; + hyperlinks: HyperlinksAdapter; query: QueryAdapter; mutations: MutationsAdapter; history: HistoryAdapter; @@ -1691,6 +1736,26 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeTocEditEntry(adapters.toc, input, options); }, }, + hyperlinks: { + list(query?: HyperlinksListQuery): HyperlinksListResult { + return executeHyperlinksList(adapters.hyperlinks, query); + }, + get(input: HyperlinksGetInput): HyperlinkInfo { + return executeHyperlinksGet(adapters.hyperlinks, input); + }, + wrap(input: HyperlinksWrapInput, options?: MutationOptions): HyperlinkMutationResult { + return executeHyperlinksWrap(adapters.hyperlinks, input, options); + }, + insert(input: HyperlinksInsertInput, options?: MutationOptions): HyperlinkMutationResult { + return executeHyperlinksInsert(adapters.hyperlinks, input, options); + }, + patch(input: HyperlinksPatchInput, options?: MutationOptions): HyperlinkMutationResult { + return executeHyperlinksPatch(adapters.hyperlinks, input, options); + }, + remove(input: HyperlinksRemoveInput, options?: MutationOptions): HyperlinkMutationResult { + return executeHyperlinksRemove(adapters.hyperlinks, input, options); + }, + }, query: { match(input: QueryMatchInput): QueryMatchOutput { return adapters.query.match(input); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 50d1ecf65e..9f7de2aaaa 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -272,5 +272,13 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'images.setPosition': (input, options) => api.images.setPosition(input, options), 'images.setAnchorOptions': (input, options) => api.images.setAnchorOptions(input, options), 'images.setZOrder': (input, options) => api.images.setZOrder(input, options), + + // --- hyperlinks.* --- + 'hyperlinks.list': (input) => api.hyperlinks.list(input), + 'hyperlinks.get': (input) => api.hyperlinks.get(input), + 'hyperlinks.wrap': (input, options) => api.hyperlinks.wrap(input, options), + 'hyperlinks.insert': (input, options) => api.hyperlinks.insert(input, options), + 'hyperlinks.patch': (input, options) => api.hyperlinks.patch(input, options), + 'hyperlinks.remove': (input, options) => api.hyperlinks.remove(input, options), }; } diff --git a/packages/layout-engine/layout-bridge/test/performance.test.ts b/packages/layout-engine/layout-bridge/test/performance.test.ts index f732f4a044..ceb9123053 100644 --- a/packages/layout-engine/layout-bridge/test/performance.test.ts +++ b/packages/layout-engine/layout-bridge/test/performance.test.ts @@ -24,7 +24,7 @@ beforeAll(() => { const describeIfRealCanvas = usingStub ? describe.skip : describe; const IS_CI = Boolean(process.env.CI); -const NON_CI_LATENCY_VARIANCE_FACTOR = 1.05; +const NON_CI_LATENCY_VARIANCE_FACTOR = 1.06; const LATENCY_TARGETS = IS_CI ? { // CI environments are slower and more variable; use generous buffers diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 564e21797c..3736db8a41 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -108,6 +108,12 @@ import { imagesSetAnchorOptionsWrapper, imagesSetZOrderWrapper, } from '../plan-engine/images-wrappers.js'; +import { + hyperlinksWrapWrapper, + hyperlinksInsertWrapper, + hyperlinksPatchWrapper, + hyperlinksRemoveWrapper, +} from '../plan-engine/hyperlinks-wrappers.js'; import { listsInsertWrapper, listsIndentWrapper, @@ -140,6 +146,7 @@ import * as listSequenceHelpers from '../helpers/list-sequence-helpers.js'; import { LevelFormattingHelpers } from '../../core/helpers/list-level-formatting-helpers.js'; import * as planWrappers from '../plan-engine/plan-wrappers.js'; import { trackChangesAcceptWrapper, trackChangesRejectWrapper } from '../plan-engine/track-changes-wrappers.js'; +import * as hyperlinkMutationHelper from '../helpers/hyperlink-mutation-helper.js'; import { registerBuiltInExecutors } from '../plan-engine/register-executors.js'; import { getRevision, initRevision } from '../plan-engine/revision-tracker.js'; import { executePlan } from '../plan-engine/executor.js'; @@ -2074,6 +2081,79 @@ function makeMultiBlockImageEditor(): Editor { } as unknown as Editor; } +function makeHyperlinkTarget(blockId: string, start: number, end: number) { + return { + kind: 'inline' as const, + nodeType: 'hyperlink' as const, + anchor: { + start: { blockId, offset: start }, + end: { blockId, offset: end }, + }, + }; +} + +function makeHyperlinkEditor( + options: { + withLink?: boolean; + text?: string; + linkAttrs?: Record; + } = {}, +): Editor { + const text = options.text ?? 'Hello'; + const withLink = options.withLink ?? true; + const linkAttrs = options.linkAttrs ?? { href: 'https://example.com' }; + + const linkMark = { + type: { name: 'link' }, + attrs: linkAttrs, + }; + + const textNode = createNode('text', [], { text }); + (textNode as unknown as { marks: unknown[] }).marks = withLink ? [linkMark] : []; + + const paragraph = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + + const doc = createNode('doc', [paragraph], { isBlock: false }); + ( + doc as unknown as { resolve: (pos: number) => { depth: number; node: (depth: number) => ProseMirrorNode } } + ).resolve = (_pos: number) => ({ + depth: 1, + node: (_depth: number) => paragraph, + }); + + const dispatch = vi.fn(); + const tr = { + insertText: vi.fn().mockReturnThis(), + addMark: vi.fn().mockReturnThis(), + removeMark: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + steps: [{}], + doc, + }; + + const linkMarkType = { + create: vi.fn((attrs: Record) => ({ + type: { name: 'link' }, + attrs, + })), + }; + + return { + state: { doc, tr, schema: { marks: { link: linkMarkType } } }, + dispatch, + schema: { marks: { link: linkMarkType } }, + options: { mode: 'html' }, + on: () => {}, + } as unknown as Editor; +} + const mutationVectors: Partial> = { 'blocks.delete': { throwCase: () => { @@ -4816,6 +4896,138 @@ const mutationVectors: Partial> = { { changeMode: 'direct' }, ), }, + + // ------------------------------------------------------------------------- + // Hyperlink operations + // ------------------------------------------------------------------------- + 'hyperlinks.wrap': { + throwCase: () => + hyperlinksWrapWrapper( + makeHyperlinkEditor({ withLink: false }), + { + target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ), + failureCase: () => { + const wrapSpy = vi.spyOn(hyperlinkMutationHelper, 'wrapWithLink').mockReturnValueOnce(false); + try { + return hyperlinksWrapWrapper( + makeHyperlinkEditor({ withLink: false }), + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ); + } finally { + wrapSpy.mockRestore(); + } + }, + applyCase: () => + hyperlinksWrapWrapper( + makeHyperlinkEditor({ withLink: false }), + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ), + }, + 'hyperlinks.insert': { + throwCase: () => + hyperlinksInsertWrapper( + makeHyperlinkEditor({ withLink: false }), + { + target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 0 } }, + text: 'X', + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ), + failureCase: () => { + const insertSpy = vi.spyOn(hyperlinkMutationHelper, 'insertLinkedText').mockReturnValueOnce(false); + try { + return hyperlinksInsertWrapper( + makeHyperlinkEditor({ withLink: false }), + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, + text: 'X', + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ); + } finally { + insertSpy.mockRestore(); + } + }, + applyCase: () => + hyperlinksInsertWrapper( + makeHyperlinkEditor({ withLink: false }), + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, + text: 'X', + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ), + }, + 'hyperlinks.patch': { + throwCase: () => + hyperlinksPatchWrapper( + makeHyperlinkEditor({ withLink: true }), + { + target: makeHyperlinkTarget('p1', 1, 3), + patch: { href: 'https://example.com/updated' }, + }, + { changeMode: 'direct' }, + ), + failureCase: () => + hyperlinksPatchWrapper( + makeHyperlinkEditor({ withLink: true, linkAttrs: { href: 'https://example.com' } }), + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://example.com' }, + }, + { changeMode: 'direct' }, + ), + applyCase: () => + hyperlinksPatchWrapper( + makeHyperlinkEditor({ withLink: true, linkAttrs: { href: 'https://example.com' } }), + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://example.com/updated' }, + }, + { changeMode: 'direct' }, + ), + }, + 'hyperlinks.remove': { + throwCase: () => + hyperlinksRemoveWrapper( + makeHyperlinkEditor({ withLink: true }), + { target: makeHyperlinkTarget('p1', 1, 3) }, + { changeMode: 'direct' }, + ), + failureCase: () => { + const unwrapSpy = vi.spyOn(hyperlinkMutationHelper, 'unwrapLink').mockReturnValueOnce(false); + try { + return hyperlinksRemoveWrapper( + makeHyperlinkEditor({ withLink: true }), + { target: makeHyperlinkTarget('p1', 0, 5) }, + { changeMode: 'direct' }, + ); + } finally { + unwrapSpy.mockRestore(); + } + }, + applyCase: () => + hyperlinksRemoveWrapper( + makeHyperlinkEditor({ withLink: true }), + { target: makeHyperlinkTarget('p1', 0, 5) }, + { changeMode: 'direct' }, + ), + }, }; const dryRunVectors: Partial unknown>> = { @@ -6068,6 +6280,64 @@ const dryRunVectors: Partial unknown>> = { expect(dispatch).not.toHaveBeenCalled(); return result; }, + + // ------------------------------------------------------------------------- + // Hyperlink operations — dryRun vectors + // ------------------------------------------------------------------------- + 'hyperlinks.wrap': () => { + const editor = makeHyperlinkEditor({ withLink: false }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = hyperlinksWrapWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'hyperlinks.insert': () => { + const editor = makeHyperlinkEditor({ withLink: false }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = hyperlinksInsertWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, + text: 'X', + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'hyperlinks.patch': () => { + const editor = makeHyperlinkEditor({ withLink: true, linkAttrs: { href: 'https://example.com' } }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://example.com/updated' }, + }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'hyperlinks.remove': () => { + const editor = makeHyperlinkEditor({ withLink: true }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = hyperlinksRemoveWrapper( + editor, + { target: makeHyperlinkTarget('p1', 0, 5) }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, }; beforeEach(() => { diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 0ecabeae70..649e063ea8 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -178,6 +178,14 @@ import { imagesSetAnchorOptionsWrapper, imagesSetZOrderWrapper, } from './plan-engine/images-wrappers.js'; +import { + hyperlinksListWrapper, + hyperlinksGetWrapper, + hyperlinksWrapWrapper, + hyperlinksInsertWrapper, + hyperlinksPatchWrapper, + hyperlinksRemoveWrapper, +} from './plan-engine/hyperlinks-wrappers.js'; /** * Assembles all document-api adapters for the given editor instance. @@ -386,6 +394,14 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters setAnchorOptions: (input, options) => imagesSetAnchorOptionsWrapper(editor, input, options), setZOrder: (input, options) => imagesSetZOrderWrapper(editor, input, options), }, + hyperlinks: { + list: (query) => hyperlinksListWrapper(editor, query), + get: (input) => hyperlinksGetWrapper(editor, input), + wrap: (input, options) => hyperlinksWrapWrapper(editor, input, options), + insert: (input, options) => hyperlinksInsertWrapper(editor, input, options), + patch: (input, options) => hyperlinksPatchWrapper(editor, input, options), + remove: (input, options) => hyperlinksRemoveWrapper(editor, input, options), + }, query: { match: (input) => queryMatchAdapter(editor, input), }, diff --git a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts index 3531f4f8a3..c5d3851a7c 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/adapter-utils.ts @@ -320,7 +320,7 @@ export function dedupeDiagnostics(diagnostics: UnknownNodeDiagnostic[]): Unknown */ export function resolveWithinScope( index: BlockIndex, - query: Query, + query: Pick, diagnostics: UnknownNodeDiagnostic[], ): WithinResult { if (!query.within) return { ok: true, range: undefined }; diff --git a/packages/super-editor/src/document-api-adapters/helpers/hyperlink-mutation-helper.test.ts b/packages/super-editor/src/document-api-adapters/helpers/hyperlink-mutation-helper.test.ts new file mode 100644 index 0000000000..b6ad68ada2 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/hyperlink-mutation-helper.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { Mark, MarkType } from 'prosemirror-model'; + +vi.mock('@superdoc/url-validation', () => ({ + sanitizeHref: vi.fn((href: string) => { + if (href.startsWith('javascript:')) return null; + return { href }; + }), +})); + +vi.mock('../../core/super-converter/docx-helpers/document-rels.js', () => ({ + insertNewRelationship: vi.fn(() => 'rId-mock'), +})); + +vi.mock('./transaction-meta.js', () => ({ + applyDirectMutationMeta: vi.fn(), +})); + +import { + sanitizeHrefOrThrow, + buildMarkAttrs, + wrapWithLink, + insertLinkedText, + patchLinkMark, + unwrapLink, + deleteLinkedText, +} from './hyperlink-mutation-helper.js'; + +// --------------------------------------------------------------------------- +// Mock editor factory +// --------------------------------------------------------------------------- + +function makeMockTr() { + const tr = { + docChanged: true, + addMark: vi.fn().mockReturnThis(), + removeMark: vi.fn().mockReturnThis(), + insertText: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + }; + return tr; +} + +function makeMockMarkType(): MarkType { + return { + create: vi.fn((attrs: Record) => ({ type: { name: 'link' }, attrs })), + } as unknown as MarkType; +} + +function makeMockEditor(opts: { mode?: string } = {}): { + editor: Editor; + tr: ReturnType; + markType: MarkType; +} { + const tr = makeMockTr(); + const markType = makeMockMarkType(); + const dispatch = vi.fn(); + + const editor = { + state: { tr }, + schema: { marks: { link: markType } }, + options: { mode: opts.mode ?? 'html' }, + dispatch, + } as unknown as Editor; + + return { editor, tr, markType }; +} + +function makeMark(attrs: Record): Mark { + return { type: { name: 'link' }, attrs } as unknown as Mark; +} + +// --------------------------------------------------------------------------- +// sanitizeHrefOrThrow +// --------------------------------------------------------------------------- + +describe('sanitizeHrefOrThrow', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('returns sanitized href for valid URLs', () => { + expect(sanitizeHrefOrThrow('https://example.com')).toBe('https://example.com'); + }); + + it('throws INVALID_INPUT for blocked protocols', () => { + try { + sanitizeHrefOrThrow('javascript:alert(1)'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as Error).message).toContain('Blocked or invalid href'); + expect((err as { code: string }).code).toBe('INVALID_INPUT'); + } + }); +}); + +// --------------------------------------------------------------------------- +// buildMarkAttrs +// --------------------------------------------------------------------------- + +describe('buildMarkAttrs', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('builds attrs with href', () => { + const { editor } = makeMockEditor(); + const attrs = buildMarkAttrs(editor, { href: 'https://example.com' }); + expect(attrs.href).toBe('https://example.com'); + expect(attrs.rId).toBeNull(); // html mode → no rId + }); + + it('creates rId in docx mode', () => { + const { editor } = makeMockEditor({ mode: 'docx' }); + const attrs = buildMarkAttrs(editor, { href: 'https://example.com' }); + expect(attrs.rId).toBe('rId-mock'); + }); + + it('synthesizes href for anchor-only spec', () => { + const { editor } = makeMockEditor(); + const attrs = buildMarkAttrs(editor, { anchor: 'bookmark1' }); + expect(attrs.anchor).toBe('bookmark1'); + expect(attrs.href).toBe('#bookmark1'); + }); + + it('does not synthesize href when both href and anchor are provided', () => { + const { editor } = makeMockEditor(); + const attrs = buildMarkAttrs(editor, { href: 'https://example.com', anchor: 'bookmark1' }); + expect(attrs.href).toBe('https://example.com'); + expect(attrs.anchor).toBe('bookmark1'); + }); + + it('passes through optional metadata fields', () => { + const { editor } = makeMockEditor(); + const attrs = buildMarkAttrs(editor, { + href: 'https://example.com', + tooltip: 'Tip', + target: '_blank', + rel: 'noopener', + docLocation: 'sheet1', + }); + expect(attrs.tooltip).toBe('Tip'); + expect(attrs.target).toBe('_blank'); + expect(attrs.rel).toBe('noopener'); + expect(attrs.docLocation).toBe('sheet1'); + }); +}); + +// --------------------------------------------------------------------------- +// wrapWithLink +// --------------------------------------------------------------------------- + +describe('wrapWithLink', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('calls tr.addMark with correct range and mark', () => { + const { editor, tr, markType } = makeMockEditor(); + wrapWithLink(editor, 5, 10, { href: 'https://example.com' }); + + expect(tr.addMark).toHaveBeenCalledTimes(1); + expect(tr.addMark.mock.calls[0]![0]).toBe(5); + expect(tr.addMark.mock.calls[0]![1]).toBe(10); + expect(markType.create).toHaveBeenCalledTimes(1); + expect(editor.dispatch).toHaveBeenCalledTimes(1); + }); + + it('returns true', () => { + const { editor } = makeMockEditor(); + expect(wrapWithLink(editor, 0, 5, { href: 'https://example.com' })).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// insertLinkedText +// --------------------------------------------------------------------------- + +describe('insertLinkedText', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('inserts text then applies mark over the inserted range', () => { + const { editor, tr } = makeMockEditor(); + insertLinkedText(editor, 3, 'Click here', { href: 'https://example.com' }); + + expect(tr.insertText).toHaveBeenCalledWith('Click here', 3); + expect(tr.addMark).toHaveBeenCalledTimes(1); + expect(tr.addMark.mock.calls[0]![0]).toBe(3); + expect(tr.addMark.mock.calls[0]![1]).toBe(3 + 'Click here'.length); + }); +}); + +// --------------------------------------------------------------------------- +// patchLinkMark +// --------------------------------------------------------------------------- + +describe('patchLinkMark', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('merges patch onto existing attrs', () => { + const { editor, tr, markType } = makeMockEditor(); + const existing = makeMark({ href: 'https://old.com', tooltip: 'Old tip' }); + + patchLinkMark(editor, 0, 5, existing, { tooltip: 'New tip' }); + + expect(tr.removeMark).toHaveBeenCalledWith(0, 5, existing); + expect(markType.create).toHaveBeenCalledTimes(1); + const newAttrs = (markType.create as ReturnType).mock.calls[0]![0]; + expect(newAttrs.href).toBe('https://old.com'); + expect(newAttrs.tooltip).toBe('New tip'); + }); + + it('clears fields set to null', () => { + const { editor, markType } = makeMockEditor(); + const existing = makeMark({ href: 'https://example.com', tooltip: 'Tip' }); + + patchLinkMark(editor, 0, 5, existing, { tooltip: null }); + + const newAttrs = (markType.create as ReturnType).mock.calls[0]![0]; + expect(newAttrs.tooltip).toBeNull(); + }); + + it('ignores undefined patch fields', () => { + const { editor, markType } = makeMockEditor(); + const existing = makeMark({ href: 'https://example.com', tooltip: 'Tip' }); + + patchLinkMark(editor, 0, 5, existing, { tooltip: undefined }); + + const newAttrs = (markType.create as ReturnType).mock.calls[0]![0]; + expect(newAttrs.tooltip).toBe('Tip'); + }); + + it('re-synthesizes href when anchor changes and existing href is synthetic', () => { + const { editor, markType } = makeMockEditor(); + const existing = makeMark({ href: '#oldBookmark', anchor: 'oldBookmark' }); + + patchLinkMark(editor, 0, 5, existing, { anchor: 'newBookmark' }); + + const newAttrs = (markType.create as ReturnType).mock.calls[0]![0]; + expect(newAttrs.anchor).toBe('newBookmark'); + expect(newAttrs.href).toBe('#newBookmark'); + }); + + it('does not re-synthesize href when anchor changes but href is external', () => { + const { editor, markType } = makeMockEditor(); + const existing = makeMark({ href: 'https://example.com', anchor: 'oldBookmark' }); + + patchLinkMark(editor, 0, 5, existing, { anchor: 'newBookmark' }); + + const newAttrs = (markType.create as ReturnType).mock.calls[0]![0]; + expect(newAttrs.anchor).toBe('newBookmark'); + expect(newAttrs.href).toBe('https://example.com'); + }); + + it('synthesizes href when href is cleared and anchor remains', () => { + const { editor, markType } = makeMockEditor(); + const existing = makeMark({ href: 'https://example.com', anchor: 'bm1' }); + + patchLinkMark(editor, 0, 5, existing, { href: null }); + + const newAttrs = (markType.create as ReturnType).mock.calls[0]![0]; + expect(newAttrs.href).toBe('#bm1'); + expect(newAttrs.rId).toBeNull(); + }); + + it('returns false and does not dispatch when transaction is a no-op', () => { + const { editor, tr } = makeMockEditor(); + const existing = makeMark({ href: 'https://example.com' }); + tr.docChanged = false; + + const applied = patchLinkMark(editor, 0, 5, existing, { tooltip: 'New tip' }); + + expect(applied).toBe(false); + expect(editor.dispatch).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// unwrapLink / deleteLinkedText +// --------------------------------------------------------------------------- + +describe('unwrapLink', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('removes the link mark type from the range', () => { + const { editor, tr } = makeMockEditor(); + unwrapLink(editor, 2, 8); + + expect(tr.removeMark).toHaveBeenCalledTimes(1); + expect(tr.removeMark.mock.calls[0]![0]).toBe(2); + expect(tr.removeMark.mock.calls[0]![1]).toBe(8); + }); + + it('returns false when removing marks produces no document change', () => { + const { editor, tr } = makeMockEditor(); + tr.docChanged = false; + + const applied = unwrapLink(editor, 2, 8); + + expect(applied).toBe(false); + expect(editor.dispatch).not.toHaveBeenCalled(); + }); +}); + +describe('deleteLinkedText', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('deletes the text range', () => { + const { editor, tr } = makeMockEditor(); + deleteLinkedText(editor, 2, 8); + + expect(tr.delete).toHaveBeenCalledWith(2, 8); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/hyperlink-mutation-helper.ts b/packages/super-editor/src/document-api-adapters/helpers/hyperlink-mutation-helper.ts new file mode 100644 index 0000000000..0964cd6724 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/hyperlink-mutation-helper.ts @@ -0,0 +1,228 @@ +/** + * Dedicated hyperlink mark mutation helper for document-api. + * + * Builds raw ProseMirror transactions — does NOT reuse the editor's + * setLink/unsetLink commands, which have UI-specific side effects + * (auto-underline, selection expansion, display text fallback). + */ + +import type { Editor } from '../../core/Editor.js'; +import type { MarkType, Mark } from 'prosemirror-model'; +import { insertNewRelationship } from '../../core/super-converter/docx-helpers/document-rels.js'; +import { sanitizeHref } from '@superdoc/url-validation'; +import { applyDirectMutationMeta } from './transaction-meta.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HyperlinkMarkAttrs { + href?: string | null; + anchor?: string | null; + docLocation?: string | null; + tooltip?: string | null; + target?: string | null; + rel?: string | null; + rId?: string | null; +} + +export interface HyperlinkWriteSpec { + href?: string; + anchor?: string; + docLocation?: string; + tooltip?: string; + target?: string; + rel?: string; +} + +// --------------------------------------------------------------------------- +// Shared utilities +// --------------------------------------------------------------------------- + +function getLinkMarkType(editor: Editor): MarkType { + const markType = editor.schema.marks.link; + if (!markType) { + throw new Error('Link mark type is not defined in the editor schema.'); + } + return markType; +} + +function dispatchTransaction(editor: Editor, tr: import('prosemirror-state').Transaction): void { + editor.dispatch(tr); +} + +function dispatchIfChanged(editor: Editor, tr: import('prosemirror-state').Transaction): boolean { + if (!tr.docChanged) return false; + dispatchTransaction(editor, tr); + return true; +} + +/** + * Creates an rId for an href in DOCX mode, returns null otherwise. + */ +function createRelationshipId(editor: Editor, href: string): string | null { + if (editor.options.mode !== 'docx') return null; + try { + return insertNewRelationship(href, 'hyperlink', editor); + } catch { + return null; + } +} + +/** + * Validates and sanitizes an href string. + * Returns the sanitized href or throws if the protocol is blocked. + */ +export function sanitizeHrefOrThrow(href: string): string { + const result = sanitizeHref(href); + if (!result) { + throw Object.assign(new Error(`Blocked or invalid href: "${href}"`), { code: 'INVALID_INPUT' }); + } + return result.href; +} + +/** + * Builds PM mark attrs from a hyperlink write spec. + * Handles rId creation and anchor-to-href synthesis. + */ +export function buildMarkAttrs(editor: Editor, spec: HyperlinkWriteSpec): HyperlinkMarkAttrs { + const attrs: HyperlinkMarkAttrs = {}; + + if (spec.href) { + attrs.href = sanitizeHrefOrThrow(spec.href); + attrs.rId = createRelationshipId(editor, attrs.href); + } + + if (spec.anchor) { + attrs.anchor = spec.anchor; + // Synthesize href for editor rendering compatibility when anchor-only + if (!spec.href) { + attrs.href = `#${spec.anchor}`; + } + } + + if (spec.docLocation) attrs.docLocation = spec.docLocation; + if (spec.tooltip) attrs.tooltip = spec.tooltip; + if (spec.target) attrs.target = spec.target; + if (spec.rel) attrs.rel = spec.rel; + + return attrs; +} + +// --------------------------------------------------------------------------- +// Wrap: apply link mark to existing text range +// --------------------------------------------------------------------------- + +export function wrapWithLink(editor: Editor, from: number, to: number, spec: HyperlinkWriteSpec): boolean { + const linkMarkType = getLinkMarkType(editor); + const attrs = buildMarkAttrs(editor, spec); + const tr = editor.state.tr; + tr.addMark(from, to, linkMarkType.create(attrs)); + applyDirectMutationMeta(tr); + dispatchTransaction(editor, tr); + return true; +} + +// --------------------------------------------------------------------------- +// Insert: insert text with link mark +// --------------------------------------------------------------------------- + +export function insertLinkedText(editor: Editor, pos: number, text: string, spec: HyperlinkWriteSpec): boolean { + const linkMarkType = getLinkMarkType(editor); + const attrs = buildMarkAttrs(editor, spec); + const tr = editor.state.tr; + const mark = linkMarkType.create(attrs); + tr.insertText(text, pos); + // Apply link mark over the inserted text range + tr.addMark(pos, pos + text.length, mark); + applyDirectMutationMeta(tr); + dispatchTransaction(editor, tr); + return true; +} + +// --------------------------------------------------------------------------- +// Patch: update mark attrs on existing link +// --------------------------------------------------------------------------- + +export interface PatchLinkAttrs { + href?: string | null; + anchor?: string | null; + docLocation?: string | null; + tooltip?: string | null; + target?: string | null; + rel?: string | null; +} + +export function patchLinkMark( + editor: Editor, + from: number, + to: number, + existingMark: Mark, + patch: PatchLinkAttrs, +): boolean { + const linkMarkType = getLinkMarkType(editor); + const oldAttrs = existingMark.attrs as Record; + + // Merge patch onto existing attrs + const merged: Record = { ...oldAttrs }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; // omitted = no change + if (value === null) { + merged[key] = null; // explicit clear + } else { + merged[key] = value; + } + } + + // Handle href sanitization for new href values + if (typeof patch.href === 'string') { + merged.href = sanitizeHrefOrThrow(patch.href); + merged.rId = createRelationshipId(editor, merged.href as string); + } + + // Handle anchor-only: synthesize href when href was explicitly cleared + if (patch.href === null && typeof merged.anchor === 'string') { + merged.href = `#${merged.anchor}`; + merged.rId = null; + } + + // Re-synthesize href when anchor changes and existing href is synthetic (#-prefixed). + // Without this, changing anchor from "old" to "new" would leave href as "#old". + if (typeof patch.anchor === 'string' && patch.href === undefined) { + const currentHref = merged.href; + if (typeof currentHref === 'string' && currentHref.startsWith('#')) { + merged.href = `#${patch.anchor}`; + } + } + + // Handle anchor-to-href transition: clear anchor if href is set and anchor is cleared + if (patch.anchor === null && typeof merged.href === 'string' && !merged.href.startsWith('#')) { + merged.anchor = null; + } + + const tr = editor.state.tr; + tr.removeMark(from, to, existingMark); + tr.addMark(from, to, linkMarkType.create(merged)); + applyDirectMutationMeta(tr); + return dispatchIfChanged(editor, tr); +} + +// --------------------------------------------------------------------------- +// Remove: unwrap (preserve text) or delete text +// --------------------------------------------------------------------------- + +export function unwrapLink(editor: Editor, from: number, to: number): boolean { + const linkMarkType = getLinkMarkType(editor); + const tr = editor.state.tr; + tr.removeMark(from, to, linkMarkType); + applyDirectMutationMeta(tr); + return dispatchIfChanged(editor, tr); +} + +export function deleteLinkedText(editor: Editor, from: number, to: number): boolean { + const tr = editor.state.tr; + tr.delete(from, to); + applyDirectMutationMeta(tr); + dispatchTransaction(editor, tr); + return true; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/hyperlinks-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/hyperlinks-wrappers.test.ts new file mode 100644 index 0000000000..91183ba017 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/hyperlinks-wrappers.test.ts @@ -0,0 +1,698 @@ +import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { PlanReceipt, HyperlinkTarget, InlineAnchor } from '@superdoc/document-api'; +import type { InlineCandidate, InlineIndex } from '../helpers/inline-address-resolver.js'; +import type { BlockIndex } from '../helpers/node-address-resolver.js'; + +// --------------------------------------------------------------------------- +// Module mocks — must come before imports of the module under test +// --------------------------------------------------------------------------- + +vi.mock('./plan-wrappers.js', () => ({ + executeDomainCommand: vi.fn((_editor: Editor, handler: () => boolean): PlanReceipt => { + const applied = handler(); + return { + success: true, + revision: { before: '0', after: '0' }, + steps: [ + { + stepId: 'step-1', + op: 'domain.command', + effect: applied ? 'changed' : 'noop', + matchCount: applied ? 1 : 0, + data: { domain: 'command', commandDispatched: applied }, + }, + ], + timing: { totalMs: 0 }, + }; + }), +})); + +vi.mock('./revision-tracker.js', () => ({ + getRevision: vi.fn(() => '42'), +})); + +vi.mock('../helpers/index-cache.js', () => ({ + getBlockIndex: vi.fn((): BlockIndex => ({ candidates: [] }) as unknown as BlockIndex), + clearIndexCache: vi.fn(), +})); + +vi.mock('../helpers/mutation-helpers.js', () => ({ + rejectTrackedMode: vi.fn((opName: string, options?: { changeMode?: string }) => { + if (options?.changeMode === 'tracked') { + const err = new Error(`${opName} does not support tracked mode`); + (err as unknown as { code: string; details: Record }).code = 'CAPABILITY_UNAVAILABLE'; + (err as unknown as { code: string; details: Record }).details = { + reason: 'tracked_mode_unsupported', + }; + throw err; + } + }), +})); + +vi.mock('../helpers/hyperlink-mutation-helper.js', () => ({ + wrapWithLink: vi.fn(() => true), + insertLinkedText: vi.fn(() => true), + patchLinkMark: vi.fn(() => true), + unwrapLink: vi.fn(() => true), + deleteLinkedText: vi.fn(() => true), + sanitizeHrefOrThrow: vi.fn((href: string) => { + if (href.startsWith('javascript:')) { + throw Object.assign(new Error('Blocked href'), { code: 'INVALID_INPUT' }); + } + return href; + }), +})); + +// Store a reference we can control per-test +let mockCandidates: InlineCandidate[] = []; + +vi.mock('../helpers/inline-address-resolver.js', () => ({ + buildInlineIndex: vi.fn( + (): InlineIndex => ({ + candidates: mockCandidates, + byType: new Map([['hyperlink', mockCandidates]]), + byKey: new Map(), + }), + ), + findInlineByType: vi.fn((_index: InlineIndex, _type: string) => mockCandidates), + findInlineByAnchor: vi.fn((_index: InlineIndex, target: HyperlinkTarget) => { + return ( + mockCandidates.find( + (c) => + c.anchor.start.blockId === target.anchor.start.blockId && + c.anchor.start.offset === target.anchor.start.offset && + c.anchor.end.offset === target.anchor.end.offset, + ) ?? null + ); + }), +})); + +vi.mock('../helpers/adapter-utils.js', () => ({ + paginate: vi.fn((items: unknown[], offset = 0, limit?: number) => { + const total = items.length; + const sliced = items.slice(offset, limit ? offset + limit : undefined); + return { total, items: sliced }; + }), + resolveTextTarget: vi.fn((_editor: Editor, target: { blockId: string; range: { start: number; end: number } }) => { + // Return a mock resolved range that maps offset to absolute positions + return { from: target.range.start + 1, to: target.range.end + 1 }; + }), + resolveDefaultInsertTarget: vi.fn(() => ({ + kind: 'text-block' as const, + target: { kind: 'text' as const, blockId: 'last-p', range: { start: 10, end: 10 } }, + range: { from: 50, to: 50 }, + })), + insertParagraphAtEnd: vi.fn(), + resolveWithinScope: vi.fn(() => ({ ok: true, range: undefined })), + scopeByRange: vi.fn((candidates: InlineCandidate[]) => candidates), +})); + +import { + hyperlinksListWrapper, + hyperlinksGetWrapper, + hyperlinksWrapWrapper, + hyperlinksInsertWrapper, + hyperlinksPatchWrapper, + hyperlinksRemoveWrapper, +} from './hyperlinks-wrappers.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { + wrapWithLink, + insertLinkedText, + patchLinkMark, + unwrapLink, + deleteLinkedText, + sanitizeHrefOrThrow, +} from '../helpers/hyperlink-mutation-helper.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeAnchor(blockId: string, start: number, end: number): InlineAnchor { + return { start: { blockId, offset: start }, end: { blockId, offset: end } }; +} + +function makeLinkMark(attrs: Record): Mark { + return { type: { name: 'link' }, attrs } as unknown as Mark; +} + +function makeCandidate( + blockId: string, + startOffset: number, + endOffset: number, + markAttrs: Record, + posOverride?: { pos: number; end: number }, +): InlineCandidate { + const anchor = makeAnchor(blockId, startOffset, endOffset); + return { + nodeType: 'hyperlink', + anchor, + blockId, + pos: posOverride?.pos ?? startOffset + 1, + end: posOverride?.end ?? endOffset + 1, + mark: makeLinkMark(markAttrs), + attrs: markAttrs, + }; +} + +function makeEditor(): Editor { + return { + state: { + doc: { + textBetween: vi.fn((_from: number, _to: number) => 'link text'), + resolve: vi.fn(() => ({ + depth: 1, + node: () => ({ type: { name: 'paragraph' } }), + })), + }, + }, + schema: { marks: { link: {} } }, + options: { mode: 'html' }, + } as unknown as Editor; +} + +function makeHyperlinkTarget(blockId: string, start: number, end: number): HyperlinkTarget { + return { + kind: 'inline', + nodeType: 'hyperlink', + anchor: makeAnchor(blockId, start, end), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.restoreAllMocks(); + mockCandidates = []; +}); + +// --------------------------------------------------------------------------- +// hyperlinks.list +// --------------------------------------------------------------------------- + +describe('hyperlinksListWrapper', () => { + it('returns empty result for document with no links', () => { + const editor = makeEditor(); + const result = hyperlinksListWrapper(editor); + expect(result.total).toBe(0); + expect(result.items).toHaveLength(0); + }); + + it('returns all hyperlinks in the document', () => { + mockCandidates = [ + makeCandidate('p1', 0, 5, { href: 'https://a.com' }), + makeCandidate('p2', 0, 3, { href: 'https://b.com' }), + ]; + const editor = makeEditor(); + + const result = hyperlinksListWrapper(editor); + expect(result.total).toBe(2); + expect(result.items).toHaveLength(2); + }); + + it('filters by hrefPattern', () => { + mockCandidates = [ + makeCandidate('p1', 0, 5, { href: 'https://example.com/page' }), + makeCandidate('p2', 0, 3, { href: 'https://other.com' }), + ]; + const editor = makeEditor(); + + const result = hyperlinksListWrapper(editor, { hrefPattern: 'example.com' }); + expect(result.total).toBe(1); + }); + + it('filters by anchor', () => { + mockCandidates = [ + makeCandidate('p1', 0, 5, { href: '#bookmark1', anchor: 'bookmark1' }), + makeCandidate('p2', 0, 3, { href: 'https://example.com' }), + ]; + const editor = makeEditor(); + + const result = hyperlinksListWrapper(editor, { anchor: 'bookmark1' }); + expect(result.total).toBe(1); + }); + + it('applies pagination', () => { + mockCandidates = [ + makeCandidate('p1', 0, 5, { href: 'https://a.com' }), + makeCandidate('p2', 0, 3, { href: 'https://b.com' }), + makeCandidate('p3', 0, 4, { href: 'https://c.com' }), + ]; + const editor = makeEditor(); + + const result = hyperlinksListWrapper(editor, { limit: 2 }); + expect(result.total).toBe(3); + expect(result.page.limit).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.get +// --------------------------------------------------------------------------- + +describe('hyperlinksGetWrapper', () => { + it('returns info for a found hyperlink', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com', tooltip: 'Tip' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksGetWrapper(editor, { target: makeHyperlinkTarget('p1', 0, 5) }); + expect(result.address.nodeType).toBe('hyperlink'); + expect(result.properties.href).toBe('https://example.com'); + expect(result.properties.tooltip).toBe('Tip'); + }); + + it('throws TARGET_NOT_FOUND when hyperlink is not found', () => { + mockCandidates = []; + const editor = makeEditor(); + + try { + hyperlinksGetWrapper(editor, { target: makeHyperlinkTarget('p1', 0, 5) }); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiAdapterError); + expect((err as DocumentApiAdapterError).code).toBe('TARGET_NOT_FOUND'); + } + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.wrap +// --------------------------------------------------------------------------- + +describe('hyperlinksWrapWrapper', () => { + it('wraps text range with a link', () => { + mockCandidates = []; + const editor = makeEditor(); + + const result = hyperlinksWrapWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(wrapWithLink).toHaveBeenCalledTimes(1); + }); + + it('returns NO_OP when range is already linked with same destination', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksWrapWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('NO_OP'); + } + }); + + it('validates href before dry-run return', () => { + mockCandidates = []; + const editor = makeEditor(); + + expect(() => { + hyperlinksWrapWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'javascript:alert(1)' } }, + }, + { dryRun: true }, + ); + }).toThrow('Blocked href'); + }); + + it('supports dry-run without calling mutation', () => { + mockCandidates = []; + const editor = makeEditor(); + + const result = hyperlinksWrapWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + link: { destination: { href: 'https://example.com' } }, + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(wrapWithLink).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.insert +// --------------------------------------------------------------------------- + +describe('hyperlinksInsertWrapper', () => { + it('inserts linked text at the target position', () => { + mockCandidates = []; + const editor = makeEditor(); + + const result = hyperlinksInsertWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 3, end: 3 } }, + text: 'Click', + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(insertLinkedText).toHaveBeenCalledTimes(1); + }); + + it('uses resolveDefaultInsertTarget when target is omitted', async () => { + mockCandidates = []; + const editor = makeEditor(); + const { resolveDefaultInsertTarget } = await import('../helpers/adapter-utils.js'); + + const result = hyperlinksInsertWrapper( + editor, + { + text: 'Click', + link: { destination: { href: 'https://example.com' } }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(resolveDefaultInsertTarget).toHaveBeenCalledTimes(1); + }); + + it('validates href before dry-run return', () => { + mockCandidates = []; + const editor = makeEditor(); + + expect(() => { + hyperlinksInsertWrapper( + editor, + { + text: 'Click', + link: { destination: { href: 'javascript:alert(1)' } }, + }, + { dryRun: true }, + ); + }).toThrow('Blocked href'); + }); + + it('supports dry-run without calling mutation', () => { + mockCandidates = []; + const editor = makeEditor(); + + const result = hyperlinksInsertWrapper( + editor, + { + target: { kind: 'text', blockId: 'p1', range: { start: 3, end: 3 } }, + text: 'Click', + link: { destination: { href: 'https://example.com' } }, + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(insertLinkedText).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.patch +// --------------------------------------------------------------------------- + +describe('hyperlinksPatchWrapper', () => { + it('patches mark attrs on an existing link', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://old.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://new.com' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(patchLinkMark).toHaveBeenCalledTimes(1); + }); + + it('uses resolved text range instead of candidate absolute positions', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://old.com' }, { pos: 100, end: 200 }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://new.com' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + const call = (patchLinkMark as ReturnType).mock.calls[0]!; + expect(call[1]).toBe(1); + expect(call[2]).toBe(6); + }); + + it('returns NO_OP when patch matches existing values', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://example.com' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('NO_OP'); + } + }); + + it('rejects patch that would clear both href and anchor', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + try { + hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: null }, + }, + { changeMode: 'direct' }, + ); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiAdapterError); + expect((err as DocumentApiAdapterError).code).toBe('INVALID_INPUT'); + } + }); + + it('allows patch on fragment-style links (href: "#bookmark")', () => { + const candidate = makeCandidate('p1', 0, 5, { href: '#bookmark1' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { tooltip: 'Updated tooltip' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + }); + + it('supports dry-run without calling mutation', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://old.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksPatchWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + patch: { href: 'https://new.com' }, + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(patchLinkMark).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// hyperlinks.remove +// --------------------------------------------------------------------------- + +describe('hyperlinksRemoveWrapper', () => { + it('unwraps a link by default (preserves text)', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksRemoveWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(unwrapLink).toHaveBeenCalledTimes(1); + expect(deleteLinkedText).not.toHaveBeenCalled(); + }); + + it('uses resolved text range instead of candidate absolute positions', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }, { pos: 100, end: 200 }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksRemoveWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + mode: 'unwrap', + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + const call = (unwrapLink as ReturnType).mock.calls[0]!; + expect(call[1]).toBe(1); + expect(call[2]).toBe(6); + }); + + it('deletes text when mode is deleteText', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksRemoveWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + mode: 'deleteText', + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(deleteLinkedText).toHaveBeenCalledTimes(1); + expect(unwrapLink).not.toHaveBeenCalled(); + }); + + it('supports dry-run without calling mutation', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksRemoveWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(unwrapLink).not.toHaveBeenCalled(); + }); + + it('throws TARGET_NOT_FOUND for missing hyperlink', () => { + mockCandidates = []; + const editor = makeEditor(); + + try { + hyperlinksRemoveWrapper( + editor, + { + target: makeHyperlinkTarget('p1', 0, 5), + }, + { changeMode: 'direct' }, + ); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiAdapterError); + expect((err as DocumentApiAdapterError).code).toBe('TARGET_NOT_FOUND'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Read normalization (tested via list/get output) +// --------------------------------------------------------------------------- + +describe('read normalization via hyperlinksGetWrapper', () => { + it('normalizes #-prefixed href to anchor when no anchor attr exists', () => { + const candidate = makeCandidate('p1', 0, 5, { href: '#bookmark1' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksGetWrapper(editor, { target: makeHyperlinkTarget('p1', 0, 5) }); + expect(result.properties.anchor).toBe('bookmark1'); + expect(result.properties.href).toBeUndefined(); + }); + + it('suppresses synthetic href when it matches anchor', () => { + const candidate = makeCandidate('p1', 0, 5, { href: '#bm1', anchor: 'bm1' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksGetWrapper(editor, { target: makeHyperlinkTarget('p1', 0, 5) }); + expect(result.properties.anchor).toBe('bm1'); + expect(result.properties.href).toBeUndefined(); + }); + + it('reports external href as-is', () => { + const candidate = makeCandidate('p1', 0, 5, { href: 'https://example.com' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksGetWrapper(editor, { target: makeHyperlinkTarget('p1', 0, 5) }); + expect(result.properties.href).toBe('https://example.com'); + }); + + it('reports both href and anchor when #-href differs from anchor', () => { + const candidate = makeCandidate('p1', 0, 5, { href: '#other', anchor: 'bookmark1' }); + mockCandidates = [candidate]; + const editor = makeEditor(); + + const result = hyperlinksGetWrapper(editor, { target: makeHyperlinkTarget('p1', 0, 5) }); + expect(result.properties.href).toBe('#other'); + expect(result.properties.anchor).toBe('bookmark1'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/hyperlinks-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/hyperlinks-wrappers.ts new file mode 100644 index 0000000000..0d6465c574 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/hyperlinks-wrappers.ts @@ -0,0 +1,641 @@ +/** + * Hyperlinks plan-engine wrappers — bridge hyperlink operations to the adapter layer. + */ + +import type { Mark } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { + HyperlinksListQuery, + HyperlinksListResult, + HyperlinksGetInput, + HyperlinkInfo, + HyperlinksWrapInput, + HyperlinksInsertInput, + HyperlinksPatchInput, + HyperlinksRemoveInput, + HyperlinkMutationResult, + HyperlinkTarget, + HyperlinkReadProperties, + HyperlinkDomain, + MutationOptions, + ReceiptFailureCode, + InlineAnchor, +} from '@superdoc/document-api'; +import { buildDiscoveryResult, buildDiscoveryItem, buildResolvedHandle } from '@superdoc/document-api'; +import { getBlockIndex, clearIndexCache } from '../helpers/index-cache.js'; +import { + buildInlineIndex, + findInlineByAnchor, + findInlineByType, + type InlineCandidate, +} from '../helpers/inline-address-resolver.js'; +import { + paginate, + resolveTextTarget, + resolveDefaultInsertTarget, + insertParagraphAtEnd, + resolveWithinScope, + scopeByRange, +} from '../helpers/adapter-utils.js'; +import { getRevision } from './revision-tracker.js'; +import { executeDomainCommand } from './plan-wrappers.js'; +import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { + wrapWithLink, + insertLinkedText, + patchLinkMark, + unwrapLink, + deleteLinkedText, + sanitizeHrefOrThrow, + type HyperlinkWriteSpec, +} from '../helpers/hyperlink-mutation-helper.js'; + +// --------------------------------------------------------------------------- +// Read normalization +// --------------------------------------------------------------------------- + +/** + * Normalizes PM mark attrs to HyperlinkReadProperties. + * + * Applies the read normalization rules from the plan: + * - #-prefixed href with no anchor → extract as anchor, suppress href + * - #-prefixed href matching anchor → suppress href (synthetic) + * - Real external href → report as-is + */ +function normalizeReadProperties(attrs: Record): HyperlinkReadProperties { + const rawHref = typeof attrs.href === 'string' ? attrs.href : undefined; + const rawAnchor = typeof attrs.anchor === 'string' ? attrs.anchor : undefined; + const rawDocLocation = typeof attrs.docLocation === 'string' ? attrs.docLocation : undefined; + const rawTooltip = typeof attrs.tooltip === 'string' ? attrs.tooltip : undefined; + const rawTarget = typeof attrs.target === 'string' ? attrs.target : undefined; + const rawRel = typeof attrs.rel === 'string' ? attrs.rel : undefined; + + const props: HyperlinkReadProperties = {}; + + // Determine effective anchor + let effectiveAnchor = rawAnchor; + let effectiveHref = rawHref; + + if (rawHref && rawHref.startsWith('#')) { + const fragment = rawHref.slice(1); + if (!rawAnchor) { + // #-href with no anchor attr → normalize to anchor + effectiveAnchor = fragment; + effectiveHref = undefined; + } else if (rawAnchor === fragment) { + // #-href matches anchor → suppress synthetic href + effectiveHref = undefined; + } + // else: #-href differs from anchor — keep both (unusual but possible) + } + + if (effectiveHref) props.href = effectiveHref; + if (effectiveAnchor) props.anchor = effectiveAnchor; + if (rawDocLocation) props.docLocation = rawDocLocation; + if (rawTooltip) props.tooltip = rawTooltip; + if (rawTarget) props.target = rawTarget; + if (rawRel) props.rel = rawRel; + + return props; +} + +// --------------------------------------------------------------------------- +// Candidate → domain projection +// --------------------------------------------------------------------------- + +function candidateToTarget(candidate: InlineCandidate): HyperlinkTarget { + return { + kind: 'inline', + nodeType: 'hyperlink', + anchor: candidate.anchor, + }; +} + +function candidateToReadProperties(candidate: InlineCandidate): HyperlinkReadProperties { + const attrs = (candidate.mark?.attrs ?? candidate.attrs ?? {}) as Record; + return normalizeReadProperties(attrs); +} + +function extractDisplayText(editor: Editor, candidate: InlineCandidate): string | undefined { + const doc = editor.state.doc; + try { + return doc.textBetween(candidate.pos, candidate.end, ''); + } catch { + return undefined; + } +} + +function candidateToDomain(editor: Editor, candidate: InlineCandidate): HyperlinkDomain { + return { + address: candidateToTarget(candidate), + properties: candidateToReadProperties(candidate), + text: extractDisplayText(editor, candidate), + }; +} + +function encodeInlineRef(anchor: InlineAnchor): string { + return `${anchor.start.blockId}:${anchor.start.offset}:${anchor.end.offset}`; +} + +// --------------------------------------------------------------------------- +// TOC guard +// --------------------------------------------------------------------------- + +function isInsideTocBlock(editor: Editor, pos: number): boolean { + const resolved = editor.state.doc.resolve(pos); + for (let depth = resolved.depth; depth > 0; depth--) { + const node = resolved.node(depth); + if (node.type.name === 'tableOfContents') return true; + } + return false; +} + +function rejectIfInsideToc(editor: Editor, pos: number, operationName: string): void { + if (isInsideTocBlock(editor, pos)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `${operationName}: target is inside a TOC block. TOC content is managed by toc.* operations.`, + ); + } +} + +// --------------------------------------------------------------------------- +// Inline candidate resolution +// --------------------------------------------------------------------------- + +function findHyperlinkCandidates(editor: Editor): InlineCandidate[] { + const blockIndex = getBlockIndex(editor); + const inlineIndex = buildInlineIndex(editor, blockIndex); + return findInlineByType(inlineIndex, 'hyperlink'); +} + +function resolveHyperlinkCandidate(editor: Editor, target: HyperlinkTarget): InlineCandidate { + const blockIndex = getBlockIndex(editor); + const inlineIndex = buildInlineIndex(editor, blockIndex); + const candidate = findInlineByAnchor(inlineIndex, target); + if (!candidate) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Hyperlink target not found in document.', { target }); + } + return candidate; +} + +function resolveCandidateTextRange( + editor: Editor, + candidate: InlineCandidate, + operationName: string, +): { from: number; to: number } { + const start = candidate.anchor.start; + const end = candidate.anchor.end; + if (start.blockId !== end.blockId) { + throw new DocumentApiAdapterError('INVALID_TARGET', `${operationName}: hyperlink anchor spans multiple blocks.`, { + anchor: candidate.anchor, + }); + } + + const resolved = resolveTextTarget(editor, { + kind: 'text', + blockId: start.blockId, + range: { start: start.offset, end: end.offset }, + }); + if (!resolved) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `${operationName}: hyperlink text range could not be resolved.`, + { + anchor: candidate.anchor, + }, + ); + } + return resolved; +} + +// --------------------------------------------------------------------------- +// Result helpers +// --------------------------------------------------------------------------- + +function hyperlinkSuccess(target: HyperlinkTarget): HyperlinkMutationResult { + return { success: true, hyperlink: target }; +} + +function hyperlinkFailure(code: ReceiptFailureCode, message: string): HyperlinkMutationResult { + return { success: false, failure: { code, message } }; +} + +function receiptApplied(receipt: ReturnType): boolean { + return receipt.steps[0]?.effect === 'changed'; +} + +// --------------------------------------------------------------------------- +// Filtering helpers for hyperlinks.list +// --------------------------------------------------------------------------- + +function matchesListQuery(candidate: InlineCandidate, query: HyperlinksListQuery | undefined, editor: Editor): boolean { + if (!query) return true; + + const props = candidateToReadProperties(candidate); + + if (query.hrefPattern && (!props.href || !props.href.includes(query.hrefPattern))) { + return false; + } + if (query.anchor && props.anchor !== query.anchor) { + return false; + } + if (query.textPattern) { + const text = extractDisplayText(editor, candidate); + if (!text || !text.includes(query.textPattern)) return false; + } + + return true; +} + +// --------------------------------------------------------------------------- +// Read operations +// --------------------------------------------------------------------------- + +export function hyperlinksListWrapper(editor: Editor, query?: HyperlinksListQuery): HyperlinksListResult { + const revision = getRevision(editor); + let candidates = findHyperlinkCandidates(editor); + + // Apply within scope filtering when provided + if (query?.within) { + const blockIndex = getBlockIndex(editor); + const diagnostics: { message: string }[] = []; + const withinResult = resolveWithinScope(blockIndex, { within: query.within }, diagnostics); + if (!withinResult.ok) { + // Scope block not found — return empty result rather than throwing + candidates = []; + } else { + candidates = scopeByRange(candidates, withinResult.range); + } + } + + const filtered = candidates.filter((c) => matchesListQuery(c, query, editor)); + const allItems = filtered.map((candidate) => { + const domain = candidateToDomain(editor, candidate); + const ref = encodeInlineRef(candidate.anchor); + const handle = buildResolvedHandle(ref, 'ephemeral', 'node'); + return buildDiscoveryItem(ref, handle, domain); + }); + + const { total, items: paged } = paginate(allItems, query?.offset, query?.limit); + const effectiveLimit = query?.limit ?? total; + + return buildDiscoveryResult({ + evaluatedRevision: revision, + total, + items: paged, + page: { limit: effectiveLimit, offset: query?.offset ?? 0, returned: paged.length }, + }); +} + +export function hyperlinksGetWrapper(editor: Editor, input: HyperlinksGetInput): HyperlinkInfo { + const candidate = resolveHyperlinkCandidate(editor, input.target); + return { + address: candidateToTarget(candidate), + properties: candidateToReadProperties(candidate), + text: extractDisplayText(editor, candidate), + }; +} + +// --------------------------------------------------------------------------- +// Mutation operations +// --------------------------------------------------------------------------- + +function specFromInput(link: { + destination: { href?: string; anchor?: string; docLocation?: string }; + tooltip?: string; + target?: string; + rel?: string; +}): HyperlinkWriteSpec { + return { + href: link.destination.href, + anchor: link.destination.anchor, + docLocation: link.destination.docLocation, + tooltip: link.tooltip, + target: link.target, + rel: link.rel, + }; +} + +export function hyperlinksWrapWrapper( + editor: Editor, + input: HyperlinksWrapInput, + options?: MutationOptions, +): HyperlinkMutationResult { + rejectTrackedMode('hyperlinks.wrap', options); + + const resolved = resolveTextTarget(editor, input.target); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'hyperlinks.wrap: text target block not found.', { + target: input.target, + }); + } + + rejectIfInsideToc(editor, resolved.from, 'hyperlinks.wrap'); + + // Check for existing links in range — detect overlaps + const blockIndex = getBlockIndex(editor); + const inlineIndex = buildInlineIndex(editor, blockIndex); + const hyperlinks = findInlineByType(inlineIndex, 'hyperlink'); + const overlapping = hyperlinks.filter((c) => c.pos < resolved.to && c.end > resolved.from); + + if (overlapping.length > 1) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'hyperlinks.wrap: target range spans multiple existing links.'); + } + if (overlapping.length === 1) { + const existing = overlapping[0]; + // Full overlap with same range — check if same destination (NO_OP) + if (existing.pos === resolved.from && existing.end === resolved.to) { + // Compare destinations + const existingProps = candidateToReadProperties(existing); + const spec = specFromInput(input.link); + if (existingProps.href === spec.href && existingProps.anchor === spec.anchor) { + return hyperlinkFailure('NO_OP', 'Text range is already linked with the same destination.'); + } + // Different destination — will replace the link (fall through) + } else { + // Partial overlap + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + 'hyperlinks.wrap: target range partially overlaps an existing link. Remove the existing link first.', + ); + } + } + + // Validate href eagerly so dry-run and real execution have parity + if (input.link.destination.href) { + sanitizeHrefOrThrow(input.link.destination.href); + } + + if (options?.dryRun) { + // Build a projected target address for dry-run response + const dryTarget: HyperlinkTarget = { + kind: 'inline', + nodeType: 'hyperlink', + anchor: { + start: { blockId: input.target.blockId, offset: input.target.range.start }, + end: { blockId: input.target.blockId, offset: input.target.range.end }, + }, + }; + return hyperlinkSuccess(dryTarget); + } + + const spec = specFromInput(input.link); + const receipt = executeDomainCommand( + editor, + () => { + const result = wrapWithLink(editor, resolved.from, resolved.to, spec); + if (result) clearIndexCache(editor); + return result; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) { + return hyperlinkFailure('NO_OP', 'Wrap operation produced no change.'); + } + + // Re-resolve to get the actual address post-mutation + const postCandidate = findHyperlinkAtRange(editor, resolved.from, resolved.to); + return hyperlinkSuccess( + postCandidate + ? candidateToTarget(postCandidate) + : { + kind: 'inline', + nodeType: 'hyperlink', + anchor: { + start: { blockId: input.target.blockId, offset: input.target.range.start }, + end: { blockId: input.target.blockId, offset: input.target.range.end }, + }, + }, + ); +} + +export function hyperlinksInsertWrapper( + editor: Editor, + input: HyperlinksInsertInput, + options?: MutationOptions, +): HyperlinkMutationResult { + rejectTrackedMode('hyperlinks.insert', options); + + let insertPos: number; + let blockId: string; + let offset: number; + let structuralEnd = false; + + if (input.target) { + const resolved = resolveTextTarget(editor, input.target); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'hyperlinks.insert: text target block not found.', { + target: input.target, + }); + } + rejectIfInsideToc(editor, resolved.from, 'hyperlinks.insert'); + insertPos = resolved.from; + blockId = input.target.blockId; + offset = input.target.range.start; + } else { + // Insert at document end using the shared fallback resolver + const fallback = resolveDefaultInsertTarget(editor); + if (!fallback) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + 'hyperlinks.insert: document has no content to insert into.', + ); + } + if (fallback.kind === 'text-block') { + insertPos = fallback.range.from; + blockId = fallback.target.blockId; + offset = fallback.target.range.start; + } else { + // structural-end: must create a paragraph host during mutation + insertPos = fallback.insertPos; + blockId = ''; + offset = 0; + structuralEnd = true; + } + } + + // Validate href eagerly so dry-run and real execution have parity + if (input.link.destination.href) { + sanitizeHrefOrThrow(input.link.destination.href); + } + + if (options?.dryRun) { + const dryTarget: HyperlinkTarget = { + kind: 'inline', + nodeType: 'hyperlink', + anchor: { + start: { blockId, offset }, + end: { blockId, offset: offset + input.text.length }, + }, + }; + return hyperlinkSuccess(dryTarget); + } + + const spec = specFromInput(input.link); + const receipt = executeDomainCommand( + editor, + () => { + if (structuralEnd) { + // Create a new paragraph host, then apply the link mark over the inserted text + insertParagraphAtEnd(editor, insertPos, input.text); + clearIndexCache(editor); + // The paragraph was inserted at insertPos; its text starts at insertPos + 1 + const textStart = insertPos + 1; + const result = wrapWithLink(editor, textStart, textStart + input.text.length, spec); + if (result) clearIndexCache(editor); + return result; + } + const result = insertLinkedText(editor, insertPos, input.text, spec); + if (result) clearIndexCache(editor); + return result; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) { + return hyperlinkFailure('NO_OP', 'Insert operation produced no change.'); + } + + // Re-resolve to get the actual address post-mutation + const searchFrom = structuralEnd ? insertPos + 1 : insertPos; + const searchTo = searchFrom + input.text.length; + const postCandidate = findHyperlinkAtRange(editor, searchFrom, searchTo); + return hyperlinkSuccess( + postCandidate + ? candidateToTarget(postCandidate) + : { + kind: 'inline', + nodeType: 'hyperlink', + anchor: { + start: { blockId, offset }, + end: { blockId, offset: offset + input.text.length }, + }, + }, + ); +} + +export function hyperlinksPatchWrapper( + editor: Editor, + input: HyperlinksPatchInput, + options?: MutationOptions, +): HyperlinkMutationResult { + rejectTrackedMode('hyperlinks.patch', options); + + const candidate = resolveHyperlinkCandidate(editor, input.target); + const resolvedRange = resolveCandidateTextRange(editor, candidate, 'hyperlinks.patch'); + rejectIfInsideToc(editor, resolvedRange.from, 'hyperlinks.patch'); + + const existingMark = candidate.mark; + if (!existingMark) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'hyperlinks.patch: resolved candidate has no mark.'); + } + + // Validate destination safety: patch must not clear both href and anchor. + // A fragment-style href (#bookmark) counts as a valid destination even + // without a separate anchor attr — it's how anchor-only links are stored. + const oldAttrs = existingMark.attrs as Record; + const mergedHref = input.patch.href === undefined ? oldAttrs.href : input.patch.href; + const mergedAnchor = input.patch.anchor === undefined ? oldAttrs.anchor : input.patch.anchor; + const hasHref = typeof mergedHref === 'string' && mergedHref.length > 0; + const hasAnchor = typeof mergedAnchor === 'string' && mergedAnchor.length > 0; + if (!hasHref && !hasAnchor) { + throw new DocumentApiAdapterError( + 'INVALID_INPUT', + 'hyperlinks.patch: resulting destination must have at least one of href or anchor.', + ); + } + + // Sanitize href if provided + if (typeof input.patch.href === 'string') { + sanitizeHrefOrThrow(input.patch.href); + } + + // Check NO_OP: if all patch fields match existing values + const currentProps = candidateToReadProperties(candidate); + const isNoop = Object.entries(input.patch).every(([key, value]) => { + if (value === undefined) return true; + if (value === null) return currentProps[key as keyof typeof currentProps] === undefined; + return currentProps[key as keyof typeof currentProps] === value; + }); + if (isNoop) { + return hyperlinkFailure('NO_OP', 'Patch produces no change — all values already match.'); + } + + if (options?.dryRun) { + return hyperlinkSuccess(candidateToTarget(candidate)); + } + + const receipt = executeDomainCommand( + editor, + () => { + const result = patchLinkMark(editor, resolvedRange.from, resolvedRange.to, existingMark, input.patch); + if (result) clearIndexCache(editor); + return result; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) { + return hyperlinkFailure('NO_OP', 'Patch operation produced no change.'); + } + + // Re-resolve to get updated address + const postCandidate = findHyperlinkAtRange(editor, resolvedRange.from, resolvedRange.to); + return hyperlinkSuccess(postCandidate ? candidateToTarget(postCandidate) : candidateToTarget(candidate)); +} + +export function hyperlinksRemoveWrapper( + editor: Editor, + input: HyperlinksRemoveInput, + options?: MutationOptions, +): HyperlinkMutationResult { + rejectTrackedMode('hyperlinks.remove', options); + + const candidate = resolveHyperlinkCandidate(editor, input.target); + const resolvedRange = resolveCandidateTextRange(editor, candidate, 'hyperlinks.remove'); + rejectIfInsideToc(editor, resolvedRange.from, 'hyperlinks.remove'); + + const mode = input.mode ?? 'unwrap'; + const targetAddress = candidateToTarget(candidate); + + if (options?.dryRun) { + return hyperlinkSuccess(targetAddress); + } + + const receipt = executeDomainCommand( + editor, + () => { + const result = + mode === 'unwrap' + ? unwrapLink(editor, resolvedRange.from, resolvedRange.to) + : deleteLinkedText(editor, resolvedRange.from, resolvedRange.to); + if (result) clearIndexCache(editor); + return result; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) { + return hyperlinkFailure('NO_OP', `Remove (${mode}) operation produced no change.`); + } + + return hyperlinkSuccess(targetAddress); +} + +// --------------------------------------------------------------------------- +// Post-mutation resolution helper +// --------------------------------------------------------------------------- + +function findHyperlinkAtRange(editor: Editor, from: number, to: number): InlineCandidate | undefined { + const blockIndex = getBlockIndex(editor); + const inlineIndex = buildInlineIndex(editor, blockIndex); + const hyperlinks = findInlineByType(inlineIndex, 'hyperlink'); + // Find the candidate closest to the expected range + return ( + hyperlinks.find((c) => c.pos >= from - 1 && c.pos <= from + 1 && c.end >= to - 1 && c.end <= to + 1) ?? + hyperlinks.find((c) => c.pos <= from && c.end >= to) + ); +} diff --git a/tests/doc-api-stories/tests/hyperlinks/all-commands.ts b/tests/doc-api-stories/tests/hyperlinks/all-commands.ts new file mode 100644 index 0000000000..0077986fc9 --- /dev/null +++ b/tests/doc-api-stories/tests/hyperlinks/all-commands.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from 'vitest'; +import { writeFile } from 'node:fs/promises'; +import { corpusDoc, unwrap, useStoryHarness } from '../harness'; + +const ALL_HYPERLINK_COMMAND_IDS = [ + 'hyperlinks.list', + 'hyperlinks.get', + 'hyperlinks.wrap', + 'hyperlinks.insert', + 'hyperlinks.patch', + 'hyperlinks.remove', +] as const; + +type HyperlinksCommandId = (typeof ALL_HYPERLINK_COMMAND_IDS)[number]; + +type HyperlinkTarget = { + kind: 'inline'; + nodeType: 'hyperlink'; + anchor: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + }; +}; + +type TextTarget = { + kind: 'text'; + blockId: string; + range: { start: number; end: number }; +}; + +type HyperlinksFixture = { + target?: HyperlinkTarget; + textTarget?: TextTarget; + removedText?: string; + beforeTotal?: number; +}; + +type Scenario = { + operationId: HyperlinksCommandId; + setup: 'corpus'; + prepare?: (sessionId: string) => Promise; + run: (sessionId: string, fixture: HyperlinksFixture | null) => Promise; +}; + +const CORPUS_HYPERLINK_FIXTURE = corpusDoc('basic/hyperlink-font-size.docx'); +const WRAP_PARAGRAPH_TEXT = 'This sentence has a wrap target phrase for hyperlink wrapping.'; +const WRAP_PHRASE = 'wrap target phrase'; + +describe('document-api story: all hyperlinks commands', () => { + const { outPath, runCli } = useStoryHarness('hyperlinks/all-commands', { + preserveResults: true, + }); + const readOperationIds = new Set(['hyperlinks.list', 'hyperlinks.get']); + + function makeSessionId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + + function slug(operationId: HyperlinksCommandId): string { + return operationId.replace(/\./g, '-'); + } + + function sourceDocNameFor(operationId: HyperlinksCommandId): string { + return `${slug(operationId)}-source.docx`; + } + + function resultDocNameFor(operationId: HyperlinksCommandId): string { + return `${slug(operationId)}.docx`; + } + + function readOutputNameFor(operationId: HyperlinksCommandId): string { + return `${slug(operationId)}-read-output.json`; + } + + async function saveReadOutput(operationId: HyperlinksCommandId, result: any): Promise { + await writeFile( + outPath(readOutputNameFor(operationId)), + `${JSON.stringify({ operationId, output: result }, null, 2)}\n`, + 'utf8', + ); + } + + async function saveSource(sessionId: string, operationId: HyperlinksCommandId): Promise { + await callDocOperation('save', { + sessionId, + out: outPath(sourceDocNameFor(operationId)), + force: true, + }); + } + + async function saveResult(sessionId: string, operationId: HyperlinksCommandId): Promise { + await callDocOperation('save', { + sessionId, + out: outPath(resultDocNameFor(operationId)), + force: true, + }); + } + + function assertMutationSuccess(operationId: string, result: any): void { + if (result?.success === true || result?.receipt?.success === true) return; + const code = result?.failure?.code ?? result?.receipt?.failure?.code ?? 'UNKNOWN'; + throw new Error(`${operationId} did not report success (code: ${code}).`); + } + + function assertReadOutput(operationId: HyperlinksCommandId, result: any): void { + if (operationId === 'hyperlinks.list') { + expect(Array.isArray(result?.items)).toBe(true); + expect(typeof result?.total).toBe('number'); + expect(result?.page).toBeDefined(); + return; + } + + if (operationId === 'hyperlinks.get') { + expect(result?.address?.kind).toBe('inline'); + expect(result?.address?.nodeType).toBe('hyperlink'); + const href = result?.properties?.href; + const anchor = result?.properties?.anchor; + expect(typeof href === 'string' || typeof anchor === 'string').toBe(true); + return; + } + + throw new Error(`Unexpected read assertion branch for ${operationId}.`); + } + + function requireFixture(operationId: HyperlinksCommandId, fixture: HyperlinksFixture | null): HyperlinksFixture { + if (!fixture) throw new Error(`${operationId} requires a fixture.`); + return fixture; + } + + async function callDocOperation(operationId: string, input: Record): Promise { + const envelope = await runCli(['call', `doc.${operationId}`, '--input-json', JSON.stringify(input)]); + return unwrap(unwrap(envelope?.data)); + } + + async function listHyperlinks(sessionId: string): Promise { + return callDocOperation('hyperlinks.list', { sessionId }); + } + + async function resolveFirstHyperlinkTarget(sessionId: string): Promise { + const listResult = await listHyperlinks(sessionId); + const target = listResult?.items?.[0]?.address; + if (!target?.anchor?.start?.blockId || !target?.anchor?.end?.blockId) { + throw new Error('Unable to resolve hyperlink target from hyperlinks.list.'); + } + return target as HyperlinkTarget; + } + + async function seedWrapSource(sessionId: string): Promise { + const insertResult = await callDocOperation('insert', { sessionId, value: WRAP_PARAGRAPH_TEXT }); + expect(insertResult?.receipt?.success).toBe(true); + + const blockId = insertResult?.target?.blockId; + if (typeof blockId !== 'string' || blockId.length === 0) { + throw new Error('Wrap setup failed: insert did not return a blockId.'); + } + + const start = WRAP_PARAGRAPH_TEXT.indexOf(WRAP_PHRASE); + if (start < 0) throw new Error('Wrap setup failed: phrase was not found in seed text.'); + const end = start + WRAP_PHRASE.length; + + return { + kind: 'text', + blockId, + range: { start, end }, + }; + } + + const scenarios: Scenario[] = [ + { + operationId: 'hyperlinks.list', + setup: 'corpus', + run: async (sessionId) => { + const listResult = await listHyperlinks(sessionId); + expect(listResult?.total).toBeGreaterThanOrEqual(1); + expect(listResult?.items?.[0]?.address?.nodeType).toBe('hyperlink'); + return listResult; + }, + }, + { + operationId: 'hyperlinks.get', + setup: 'corpus', + prepare: async (sessionId) => ({ + target: await resolveFirstHyperlinkTarget(sessionId), + }), + run: async (sessionId, fixture) => { + const f = requireFixture('hyperlinks.get', fixture); + if (!f.target) throw new Error('hyperlinks.get requires a hyperlink target fixture.'); + return callDocOperation('hyperlinks.get', { sessionId, target: f.target }); + }, + }, + { + operationId: 'hyperlinks.wrap', + setup: 'corpus', + prepare: async (sessionId) => ({ + textTarget: await seedWrapSource(sessionId), + }), + run: async (sessionId, fixture) => { + const f = requireFixture('hyperlinks.wrap', fixture); + if (!f.textTarget) throw new Error('hyperlinks.wrap requires a text target fixture.'); + + const wrapResult = await callDocOperation('hyperlinks.wrap', { + sessionId, + target: f.textTarget, + link: { + destination: { href: 'https://example.com/wrapped-by-story' }, + tooltip: 'wrapped-by-story', + }, + }); + + const listResult = await callDocOperation('hyperlinks.list', { + sessionId, + textPattern: WRAP_PHRASE, + }); + expect(listResult?.total).toBeGreaterThanOrEqual(1); + + return wrapResult; + }, + }, + { + operationId: 'hyperlinks.insert', + setup: 'corpus', + prepare: async (sessionId) => { + const insertResult = await callDocOperation('insert', { + sessionId, + value: 'Insertion host paragraph.', + }); + expect(insertResult?.receipt?.success).toBe(true); + return null; + }, + run: async (sessionId) => { + const insertedText = 'Inserted hyperlink text'; + const insertResult = await callDocOperation('hyperlinks.insert', { + sessionId, + text: insertedText, + link: { + destination: { href: 'https://example.com/inserted-by-story' }, + tooltip: 'inserted-by-story', + }, + }); + + const listResult = await callDocOperation('hyperlinks.list', { + sessionId, + textPattern: insertedText, + }); + expect(listResult?.total).toBeGreaterThanOrEqual(1); + + return insertResult; + }, + }, + { + operationId: 'hyperlinks.patch', + setup: 'corpus', + prepare: async (sessionId) => { + const listResult = await listHyperlinks(sessionId); + const target = + listResult?.items?.find((item: any) => typeof item?.properties?.href === 'string')?.address ?? + listResult?.items?.[0]?.address; + if (!target) throw new Error('hyperlinks.patch setup failed: no hyperlink target found.'); + return { target }; + }, + run: async (sessionId, fixture) => { + const f = requireFixture('hyperlinks.patch', fixture); + if (!f.target) throw new Error('hyperlinks.patch requires a hyperlink target fixture.'); + + const patchResult = await callDocOperation('hyperlinks.patch', { + sessionId, + target: f.target, + patch: { + href: 'https://example.com/patched-by-story', + tooltip: 'patched-by-story', + target: '_blank', + }, + }); + + const patchedTarget = patchResult?.hyperlink ?? f.target; + const info = await callDocOperation('hyperlinks.get', { + sessionId, + target: patchedTarget, + }); + + expect(info?.properties?.href).toBe('https://example.com/patched-by-story'); + expect(info?.properties?.tooltip).toBe('patched-by-story'); + expect(info?.properties?.target).toBe('_blank'); + + return patchResult; + }, + }, + { + operationId: 'hyperlinks.remove', + setup: 'corpus', + prepare: async (sessionId) => { + const listResult = await listHyperlinks(sessionId); + const targetItem = listResult?.items?.[0]; + if (!targetItem?.address) { + throw new Error('hyperlinks.remove setup failed: no hyperlink target found.'); + } + return { + target: targetItem.address, + removedText: typeof targetItem.text === 'string' ? targetItem.text : undefined, + beforeTotal: typeof listResult?.total === 'number' ? listResult.total : undefined, + }; + }, + run: async (sessionId, fixture) => { + const f = requireFixture('hyperlinks.remove', fixture); + if (!f.target) throw new Error('hyperlinks.remove requires a hyperlink target fixture.'); + + const removeResult = await callDocOperation('hyperlinks.remove', { + sessionId, + target: f.target, + mode: 'unwrap', + }); + + const afterList = await listHyperlinks(sessionId); + if (typeof f.beforeTotal === 'number') { + expect(afterList?.total).toBe(f.beforeTotal - 1); + } + + if (typeof f.removedText === 'string' && f.removedText.length > 0) { + const textResult = await callDocOperation('find', { + sessionId, + type: 'text', + pattern: f.removedText, + }); + expect(textResult?.total).toBeGreaterThanOrEqual(1); + } + + return removeResult; + }, + }, + ]; + + it('covers every hyperlinks command currently defined on this branch', () => { + const scenarioIds = scenarios.map((scenario) => scenario.operationId); + expect(new Set(scenarioIds).size).toBe(scenarioIds.length); + expect(new Set(scenarioIds)).toEqual(new Set(ALL_HYPERLINK_COMMAND_IDS)); + }); + + for (const scenario of scenarios) { + it(`${scenario.operationId}: executes and saves source/result docs`, async () => { + const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-')); + try { + await callDocOperation('open', { sessionId, doc: CORPUS_HYPERLINK_FIXTURE }); + + const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null; + + await saveSource(sessionId, scenario.operationId); + + const result = await scenario.run(sessionId, fixture); + + if (readOperationIds.has(scenario.operationId)) { + assertReadOutput(scenario.operationId, result); + await saveReadOutput(scenario.operationId, result); + } else { + assertMutationSuccess(scenario.operationId, result); + } + + await saveResult(sessionId, scenario.operationId); + } finally { + await callDocOperation('close', { sessionId, discard: true }).catch(() => {}); + } + }); + } +});