Skip to content

Commit 64cab87

Browse files
committed
feat(document-api): allow placement and BlockNodeAddress target for markdown inserts
1 parent 145693d commit 64cab87

3 files changed

Lines changed: 53 additions & 17 deletions

File tree

examples/collaboration/ai-node-sdk/Makefile

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,9 @@ restore-npm-sdk: ## Restore SDK from npm if currently symlinked
111111

112112
link-local-sdk: ## Build and link local SuperDoc SDK + CLI
113113
@echo "Linking local SuperDoc SDK ($(CLI_TARGET))..."
114-
@# 1. Build CLI native binary if not already built
115-
@if [ ! -f $(CLI_ARTIFACT) ]; then \
116-
echo " Building CLI binary..."; \
117-
cd $(ROOT) && $(PNPM) --filter @superdoc-dev/cli run build:native:host; \
118-
else \
119-
echo " CLI binary found"; \
120-
fi
114+
@# 1. Always rebuild CLI binary to pick up latest source changes
115+
@echo " Building CLI binary..."
116+
@cd $(ROOT) && $(PNPM) --filter @superdoc-dev/cli run build:native:host
121117
@# 2. Ensure server node_modules exists
122118
@if [ ! -d server/node_modules ]; then \
123119
echo " Installing server deps first..."; \

packages/document-api/src/index.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,10 +1454,23 @@ describe('createDocumentApi', () => {
14541454
);
14551455
});
14561456

1457-
it('rejects legacy insert with structural "placement" field', () => {
1457+
it('rejects plain text insert with structural "placement" field', () => {
14581458
const api = makeApi();
14591459
expect(() => api.insert({ value: 'hi', placement: 'before' } as any)).toThrow(
1460-
/"placement" is only valid with structural/,
1460+
/"placement" is only valid with structural content input or markdown\/html/,
1461+
);
1462+
});
1463+
1464+
it('accepts placement for markdown insert', () => {
1465+
const api = makeApi();
1466+
// Should not throw — markdown inserts route through the structural path
1467+
expect(() => api.insert({ value: '# Hello', type: 'markdown', placement: 'insideEnd' } as any)).not.toThrow();
1468+
});
1469+
1470+
it('rejects invalid placement value for markdown insert', () => {
1471+
const api = makeApi();
1472+
expect(() => api.insert({ value: '# Hello', type: 'markdown', placement: 'end' } as any)).toThrow(
1473+
/placement must be one of/,
14611474
);
14621475
});
14631476

packages/document-api/src/insert/insert.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export type InsertInput = TextInsertInput | SDInsertInput;
5959
// Allowlists for strict field validation
6060
// ---------------------------------------------------------------------------
6161

62-
const TEXT_INSERT_ALLOWED_KEYS = new Set(['value', 'type', 'target', 'ref', 'in']);
62+
const TEXT_INSERT_ALLOWED_KEYS = new Set(['value', 'type', 'target', 'ref', 'in', 'placement']);
6363
const STRUCTURAL_INSERT_ALLOWED_KEYS = new Set(['content', 'target', 'placement', 'nestingPolicy', 'in']);
6464
const VALID_INSERT_TYPES: ReadonlySet<string> = new Set(['text', 'markdown', 'html']);
6565

@@ -121,11 +121,15 @@ function validateInsertInput(input: unknown): asserts input is InsertInput {
121121

122122
/** Validates the text-based insert input shape. */
123123
function validateTextInsertInput(input: Record<string, unknown>): void {
124+
const contentType = typeof input.type === 'string' ? input.type : 'text';
125+
const isRichContent = contentType === 'markdown' || contentType === 'html';
126+
124127
// Union conflict rule 4: structural-only fields with text shape
125-
if ('placement' in input && input.placement !== undefined) {
128+
// placement is allowed for markdown/html since they route through the structural path
129+
if ('placement' in input && input.placement !== undefined && !isRichContent) {
126130
throw new DocumentApiValidationError(
127131
'INVALID_INPUT',
128-
'"placement" is only valid with structural content input, not with "value".',
132+
'"placement" is only valid with structural content input or markdown/html inserts, not with plain "value".',
129133
{ field: 'placement' },
130134
);
131135
}
@@ -139,6 +143,17 @@ function validateTextInsertInput(input: Record<string, unknown>): void {
139143

140144
assertNoUnknownFields(input, TEXT_INSERT_ALLOWED_KEYS, 'insert');
141145

146+
// Validate placement value when provided for markdown/html
147+
if (isRichContent && 'placement' in input && input.placement !== undefined) {
148+
if (typeof input.placement !== 'string' || !PLACEMENT_VALUES.has(input.placement)) {
149+
throw new DocumentApiValidationError(
150+
'INVALID_INPUT',
151+
`placement must be one of: before, after, insideStart, insideEnd. Got "${String(input.placement)}".`,
152+
{ field: 'placement', value: input.placement },
153+
);
154+
}
155+
}
156+
142157
const { target, ref, value, type } = input;
143158

144159
// Mutual exclusivity: target and ref
@@ -150,11 +165,23 @@ function validateTextInsertInput(input: Record<string, unknown>): void {
150165
);
151166
}
152167

153-
if (target !== undefined && !isSelectionTarget(target)) {
154-
throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a SelectionTarget object.', {
155-
field: 'target',
156-
value: target,
157-
});
168+
if (target !== undefined) {
169+
// Markdown/html inserts accept BlockNodeAddress targets (with optional placement)
170+
// since they route through the structural insert path and produce block-level content.
171+
if (isRichContent) {
172+
if (!isSelectionTarget(target) && !isBlockNodeAddress(target)) {
173+
throw new DocumentApiValidationError(
174+
'INVALID_TARGET',
175+
'target must be a SelectionTarget or BlockNodeAddress for markdown/html inserts.',
176+
{ field: 'target', value: target },
177+
);
178+
}
179+
} else if (!isSelectionTarget(target)) {
180+
throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a SelectionTarget object.', {
181+
field: 'target',
182+
value: target,
183+
});
184+
}
158185
}
159186

160187
if (ref !== undefined && (typeof ref !== 'string' || ref === '')) {

0 commit comments

Comments
 (0)