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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { resolve, dirname } from 'node:path';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';

import { COMMAND_CATALOG } from '@superdoc/document-api';
import { COMMAND_CATALOG, INLINE_PROPERTY_REGISTRY } from '@superdoc/document-api';

import { CLI_OPERATION_METADATA } from '../src/cli/operation-params';
import {
Expand Down Expand Up @@ -60,9 +60,12 @@ const INTENT_NAMES = {
'doc.delete': 'delete_content',
'doc.blocks.delete': 'delete_block',
'doc.format.apply': 'format_apply',
'doc.format.fontSize': 'format_font_size',
'doc.format.fontFamily': 'format_font_family',
'doc.format.color': 'format_color',
...Object.fromEntries(
INLINE_PROPERTY_REGISTRY.map((entry) => [
`doc.format.${entry.key}`,
`format_${entry.key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`)}`,
]),
),
'doc.format.align': 'format_align',
'doc.styles.apply': 'styles_apply',
'doc.create.paragraph': 'create_paragraph',
Expand Down
154 changes: 96 additions & 58 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CliOperationId } from '../../cli';
import { CLI_OPERATION_COMMAND_KEYS } from '../../cli';
import type { ConformanceHarness } from './harness';
import { INLINE_PROPERTY_REGISTRY } from '@superdoc/document-api';

export type ScenarioInvocation = {
stateDir: string;
Expand Down Expand Up @@ -138,6 +139,99 @@ function sectionMutationScenario(
};
}

type InlineAliasKey = (typeof INLINE_PROPERTY_REGISTRY)[number]['key'];
type FormatInlineAliasCliOperationId = `doc.format.${InlineAliasKey}`;

function sampleInlineAliasValue(key: InlineAliasKey): unknown {
switch (key) {
case 'underline':
return true;
case 'vertAlign':
return 'superscript';
case 'shading':
return { fill: 'FFFF00' };
case 'border':
return { val: 'single' };
case 'fitText':
return { val: 12 };
case 'lang':
return { val: 'en-US' };
case 'rFonts':
return { ascii: 'Calibri', hAnsi: 'Calibri' };
case 'eastAsianLayout':
return { vert: true };
case 'stylisticSets':
return [{ id: 1, val: true }];
case 'rStyle':
return 'DefaultParagraphFont';
case 'color':
return '#FF0000';
case 'highlight':
return 'yellow';
case 'em':
return 'dot';
case 'ligatures':
return 'standard';
case 'numForm':
return 'lining';
case 'numSpacing':
return 'proportional';
case 'fontSize':
case 'fontSizeCs':
return 14;
case 'letterSpacing':
return 0.5;
case 'position':
return 1;
case 'charScale':
return 100;
case 'kerning':
return 8;
default: {
const entry = INLINE_PROPERTY_REGISTRY.find((candidate) => candidate.key === key);
if (!entry) throw new Error(`Unknown inline alias key: ${key}`);
if (entry.type === 'boolean') return true;
if (entry.type === 'number') return 1;
if (entry.type === 'string') return 'on';
if (entry.type === 'array') return [{ id: 1, val: true }];
return { val: 'on' };
}
}
}

function formatInlineAliasSuccessScenario(
operationId: FormatInlineAliasCliOperationId,
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const key = operationId.slice('doc.format.'.length) as InlineAliasKey;
const stateDir = await harness.createStateDir(`${operationId.replace(/\./g, '-')}-success`);
const docPath = await harness.copyFixtureDoc(`${operationId.replace(/\./g, '-')}`);
const target = await harness.firstTextRange(docPath, stateDir);
return {
stateDir,
args: [
...commandTokens(operationId),
docPath,
'--target-json',
JSON.stringify(target),
'--value-json',
JSON.stringify(sampleInlineAliasValue(key)),
'--out',
harness.createOutputPath(`${operationId.replace(/\./g, '-')}-output`),
],
};
};
}

const FORMAT_INLINE_ALIAS_SUCCESS_SCENARIOS: Record<
FormatInlineAliasCliOperationId,
(harness: ConformanceHarness) => Promise<ScenarioInvocation>
> = Object.fromEntries(
INLINE_PROPERTY_REGISTRY.map((entry) => {
const operationId = `doc.format.${entry.key}` as FormatInlineAliasCliOperationId;
return [operationId, formatInlineAliasSuccessScenario(operationId)];
}),
) as Record<FormatInlineAliasCliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;
// ---------------------------------------------------------------------------
// Table scenario helpers (DRY builders for the 40 table operations)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -988,69 +1082,13 @@ export const SUCCESS_SCENARIOS = {
'--target-json',
JSON.stringify(target),
'--inline-json',
JSON.stringify({ bold: 'on' }),
JSON.stringify({ bold: true }),
'--out',
harness.createOutputPath('doc-style-apply-output'),
],
};
},
'doc.format.fontSize': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-format-font-size-success');
const docPath = await harness.copyFixtureDoc('doc-format-font-size');
const target = await harness.firstTextRange(docPath, stateDir);
return {
stateDir,
args: [
'format',
'font-size',
docPath,
'--target-json',
JSON.stringify(target),
'--value-json',
JSON.stringify('14pt'),
'--out',
harness.createOutputPath('doc-format-font-size-output'),
],
};
},
'doc.format.fontFamily': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-format-font-family-success');
const docPath = await harness.copyFixtureDoc('doc-format-font-family');
const target = await harness.firstTextRange(docPath, stateDir);
return {
stateDir,
args: [
'format',
'font-family',
docPath,
'--target-json',
JSON.stringify(target),
'--value-json',
JSON.stringify('Arial'),
'--out',
harness.createOutputPath('doc-format-font-family-output'),
],
};
},
'doc.format.color': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-format-color-success');
const docPath = await harness.copyFixtureDoc('doc-format-color');
const target = await harness.firstTextRange(docPath, stateDir);
return {
stateDir,
args: [
'format',
'color',
docPath,
'--target-json',
JSON.stringify(target),
'--value-json',
JSON.stringify('#ff0000'),
'--out',
harness.createOutputPath('doc-format-color-output'),
],
};
},
...FORMAT_INLINE_ALIAS_SUCCESS_SCENARIOS,
'doc.format.align': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-format-align-success');
const docPath = await harness.copyFixtureDoc('doc-format-align');
Expand Down
40 changes: 4 additions & 36 deletions apps/cli/src/cli/helper-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,46 +44,14 @@ function mapIdToTarget(input: Record<string, unknown>): Record<string, unknown>
}

/**
* Format helper commands — map `format <mark>` to `format.apply` with pre-filled inline styles.
* These keep `superdoc format bold|italic|underline|strikethrough` as ergonomic
* shortcuts over the canonical `format.apply` contract operation.
* Helper commands for compatibility/ergonomics where no direct canonical key exists.
*/
export const CLI_HELPER_COMMANDS: readonly CliHelperCommand[] = [
// --- Format helpers (route to format.apply) ---
{
tokens: ['format', 'bold'],
canonicalOperationId: 'format.apply',
defaultInput: { inline: { bold: 'on' } },
description: 'Apply bold formatting to a text range.',
category: 'format',
mutates: true,
examples: [
'superdoc format bold --blockId p1 --start 0 --end 5',
'superdoc format bold --target \'{"kind":"text","blockId":"p1","range":{"start":0,"end":5}}\'',
],
},
{
tokens: ['format', 'italic'],
canonicalOperationId: 'format.apply',
defaultInput: { inline: { italic: 'on' } },
description: 'Apply italic formatting to a text range.',
category: 'format',
mutates: true,
examples: ['superdoc format italic --blockId p1 --start 0 --end 5'],
},
{
tokens: ['format', 'underline'],
canonicalOperationId: 'format.apply',
defaultInput: { inline: { underline: 'on' } },
description: 'Apply underline formatting to a text range.',
category: 'format',
mutates: true,
examples: ['superdoc format underline --blockId p1 --start 0 --end 5'],
},
// --- Format helper ---
{
tokens: ['format', 'strikethrough'],
canonicalOperationId: 'format.apply',
defaultInput: { inline: { strike: 'on' } },
canonicalOperationId: 'format.strike',
defaultInput: { value: true },
description: 'Apply strikethrough formatting to a text range.',
category: 'format',
mutates: true,
Expand Down
35 changes: 20 additions & 15 deletions apps/cli/src/cli/operation-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
import { COMMAND_CATALOG } from '@superdoc/document-api';
import type { CliExposedOperationId } from './operation-set.js';

type FormatOperationId = Extract<CliExposedOperationId, `format.${string}`>;
type FormatInlineAliasOperationId = Exclude<FormatOperationId, 'format.apply' | 'format.align'>;

const FORMAT_INLINE_ALIAS_OPERATION_IDS = (Object.keys(COMMAND_CATALOG) as CliExposedOperationId[]).filter(
(operationId): operationId is FormatInlineAliasOperationId =>
operationId.startsWith('format.') && operationId !== 'format.apply' && operationId !== 'format.align',
);

function buildFormatInlineAliasRecord<T>(value: T): Record<FormatInlineAliasOperationId, T> {
return Object.fromEntries(FORMAT_INLINE_ALIAS_OPERATION_IDS.map((operationId) => [operationId, value])) as Record<
FormatInlineAliasOperationId,
T
>;
}

// ---------------------------------------------------------------------------
// Orchestration kind (derived from COMMAND_CATALOG)
// ---------------------------------------------------------------------------
Expand All @@ -37,10 +52,8 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
delete: 'deleted text',
'blocks.delete': 'deleted block',
'format.apply': 'applied style',
'format.fontSize': 'set font size',
'format.fontFamily': 'set font family',
'format.color': 'set text color',
'format.align': 'set alignment',
...buildFormatInlineAliasRecord('applied style'),
'styles.apply': 'applied stylesheet defaults',
'create.paragraph': 'created paragraph',
'create.heading': 'created heading',
Expand Down Expand Up @@ -146,10 +159,8 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
delete: 'mutationReceipt',
'blocks.delete': 'plain',
'format.apply': 'mutationReceipt',
'format.fontSize': 'mutationReceipt',
'format.fontFamily': 'mutationReceipt',
'format.color': 'mutationReceipt',
'format.align': 'mutationReceipt',
...buildFormatInlineAliasRecord('mutationReceipt'),
'styles.apply': 'receipt',
'create.paragraph': 'createResult',
'create.heading': 'createResult',
Expand Down Expand Up @@ -239,10 +250,8 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
delete: null,
'blocks.delete': 'result',
'format.apply': null,
'format.fontSize': null,
'format.fontFamily': null,
'format.color': null,
'format.align': null,
...buildFormatInlineAliasRecord(null),
'styles.apply': 'receipt',
'create.paragraph': 'result',
'create.heading': 'result',
Expand Down Expand Up @@ -326,10 +335,8 @@ export const RESPONSE_VALIDATION_KEY: Partial<Record<CliExposedOperationId, stri
replace: 'receipt',
delete: 'receipt',
'format.apply': 'receipt',
'format.fontSize': 'receipt',
'format.fontFamily': 'receipt',
'format.color': 'receipt',
'format.align': 'receipt',
...buildFormatInlineAliasRecord('receipt'),
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -362,10 +369,8 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
delete: 'textMutation',
'blocks.delete': 'blocks',
'format.apply': 'textMutation',
'format.fontSize': 'textMutation',
'format.fontFamily': 'textMutation',
'format.color': 'textMutation',
'format.align': 'textMutation',
...buildFormatInlineAliasRecord('textMutation'),
'styles.apply': 'general',
'create.paragraph': 'create',
'create.heading': 'create',
Expand Down
13 changes: 8 additions & 5 deletions apps/cli/src/cli/operation-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ const LIST_TARGET_FLAT_PARAMS: CliOperationParamSpec[] = [
{ name: 'nodeId', kind: 'flag', flag: 'node-id', type: 'string' },
];

const FORMAT_OPERATION_IDS = CLI_DOC_OPERATIONS.filter((operationId): operationId is OperationId =>
operationId.startsWith('format.'),
);

const EXTRA_CLI_PARAMS: Partial<Record<string, CliOperationParamSpec[]>> = {
'doc.find': [
{ name: 'type', kind: 'flag', type: 'string' },
Expand All @@ -346,11 +350,6 @@ const EXTRA_CLI_PARAMS: Partial<Record<string, CliOperationParamSpec[]>> = {
'doc.insert': [...INSERT_FLAT_PARAMS],
'doc.replace': [...TEXT_TARGET_FLAT_PARAMS],
'doc.delete': [...TEXT_TARGET_FLAT_PARAMS],
'doc.format.apply': [...TEXT_TARGET_FLAT_PARAMS],
'doc.format.fontSize': [...TEXT_TARGET_FLAT_PARAMS],
'doc.format.fontFamily': [...TEXT_TARGET_FLAT_PARAMS],
'doc.format.color': [...TEXT_TARGET_FLAT_PARAMS],
'doc.format.align': [...TEXT_TARGET_FLAT_PARAMS],
'doc.styles.apply': [
{ name: 'target', kind: 'jsonFlag', flag: 'target-json', type: 'json' },
{ name: 'patch', kind: 'jsonFlag', flag: 'patch-json', type: 'json' },
Expand Down Expand Up @@ -387,6 +386,10 @@ const EXTRA_CLI_PARAMS: Partial<Record<string, CliOperationParamSpec[]>> = {
'doc.create.heading': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }],
};

for (const operationId of FORMAT_OPERATION_IDS) {
EXTRA_CLI_PARAMS[`doc.${operationId}`] = [...TEXT_TARGET_FLAT_PARAMS];
}

// ---------------------------------------------------------------------------
// Doc requirement derivation
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading