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
20 changes: 19 additions & 1 deletion apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,25 @@ const INTENT_NAMES = {
`format_${entry.key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`)}`,
]),
),
'doc.format.align': 'format_align',
'doc.styles.paragraph.setStyle': 'set_paragraph_style',
'doc.styles.paragraph.clearStyle': 'clear_paragraph_style',
'doc.format.paragraph.resetDirectFormatting': 'reset_paragraph_direct_formatting',
'doc.format.paragraph.setAlignment': 'set_paragraph_alignment',
'doc.format.paragraph.clearAlignment': 'clear_paragraph_alignment',
'doc.format.paragraph.setIndentation': 'set_paragraph_indentation',
'doc.format.paragraph.clearIndentation': 'clear_paragraph_indentation',
'doc.format.paragraph.setSpacing': 'set_paragraph_spacing',
'doc.format.paragraph.clearSpacing': 'clear_paragraph_spacing',
'doc.format.paragraph.setKeepOptions': 'set_paragraph_keep_options',
'doc.format.paragraph.setOutlineLevel': 'set_paragraph_outline_level',
'doc.format.paragraph.setFlowOptions': 'set_paragraph_flow_options',
'doc.format.paragraph.setTabStop': 'set_paragraph_tab_stop',
'doc.format.paragraph.clearTabStop': 'clear_paragraph_tab_stop',
'doc.format.paragraph.clearAllTabStops': 'clear_all_paragraph_tab_stops',
'doc.format.paragraph.setBorder': 'set_paragraph_border',
'doc.format.paragraph.clearBorder': 'clear_paragraph_border',
'doc.format.paragraph.setShading': 'set_paragraph_shading',
'doc.format.paragraph.clearShading': 'clear_paragraph_shading',
'doc.styles.apply': 'styles_apply',
'doc.create.paragraph': 'create_paragraph',
'doc.create.heading': 'create_heading',
Expand Down
176 changes: 157 additions & 19 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,56 @@ const FORMAT_INLINE_ALIAS_SUCCESS_SCENARIOS: Record<
return [operationId, formatInlineAliasSuccessScenario(operationId)];
}),
) as Record<FormatInlineAliasCliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;

function paragraphMutationScenario(
operationId: CliOperationId,
label: string,
extraArgs: string[],
prepare: Array<{ operationId: CliOperationId; extraArgs: string[] }> = [],
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const stateDir = await harness.createStateDir(`${label}-success`);
let docPath = await harness.copyFixtureDoc(`${label}-source`);
let block = await harness.firstBlockMatch(docPath, stateDir);

for (let index = 0; index < prepare.length; index += 1) {
const step = prepare[index];
const preparedOut = harness.createOutputPath(`${label}-prepare-${index + 1}`);
const prepared = await harness.runCli(
[
...commandTokens(step.operationId),
docPath,
'--target-json',
JSON.stringify({ kind: 'block', nodeType: 'paragraph', nodeId: block.nodeId }),
...step.extraArgs,
'--out',
preparedOut,
],
stateDir,
);

if (prepared.result.code !== 0 || prepared.envelope.ok !== true) {
throw new Error(`Failed to prepare paragraph scenario ${label} with ${step.operationId}.`);
}

docPath = preparedOut;
block = await harness.firstBlockMatch(docPath, stateDir);
}

return {
stateDir,
args: [
...commandTokens(operationId),
docPath,
'--target-json',
JSON.stringify({ kind: 'block', nodeType: 'paragraph', nodeId: block.nodeId }),
...extraArgs,
'--out',
harness.createOutputPath(`${label}-output`),
],
};
};
}
// ---------------------------------------------------------------------------
// Table scenario helpers (DRY builders for the 40 table operations)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1143,25 +1193,113 @@ export const SUCCESS_SCENARIOS = {
};
},
...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');
const target = await harness.firstTextRange(docPath, stateDir);
return {
stateDir,
args: [
'format',
'align',
docPath,
'--target-json',
JSON.stringify(target),
'--alignment-json',
JSON.stringify('center'),
'--out',
harness.createOutputPath('doc-format-align-output'),
],
};
},
'doc.styles.paragraph.setStyle': paragraphMutationScenario('doc.styles.paragraph.setStyle', 'styles-paragraph-set', [
'--style-id',
'Normal',
]),
'doc.styles.paragraph.clearStyle': paragraphMutationScenario(
'doc.styles.paragraph.clearStyle',
'styles-paragraph-clear',
[],
[{ operationId: 'doc.styles.paragraph.setStyle', extraArgs: ['--style-id', '__ConformanceTmpStyle__'] }],
),
'doc.format.paragraph.resetDirectFormatting': paragraphMutationScenario(
'doc.format.paragraph.resetDirectFormatting',
'format-paragraph-reset',
[],
),
'doc.format.paragraph.setAlignment': paragraphMutationScenario(
'doc.format.paragraph.setAlignment',
'format-paragraph-set-alignment',
['--alignment', 'center'],
[{ operationId: 'doc.format.paragraph.setAlignment', extraArgs: ['--alignment', 'left'] }],
),
'doc.format.paragraph.clearAlignment': paragraphMutationScenario(
'doc.format.paragraph.clearAlignment',
'format-paragraph-clear-alignment',
[],
),
'doc.format.paragraph.setIndentation': paragraphMutationScenario(
'doc.format.paragraph.setIndentation',
'format-paragraph-set-indentation',
['--left', '720'],
),
'doc.format.paragraph.clearIndentation': paragraphMutationScenario(
'doc.format.paragraph.clearIndentation',
'format-paragraph-clear-indentation',
[],
[{ operationId: 'doc.format.paragraph.setIndentation', extraArgs: ['--left', '720'] }],
),
'doc.format.paragraph.setSpacing': paragraphMutationScenario(
'doc.format.paragraph.setSpacing',
'format-paragraph-set-spacing',
['--before', '120', '--after', '120'],
),
'doc.format.paragraph.clearSpacing': paragraphMutationScenario(
'doc.format.paragraph.clearSpacing',
'format-paragraph-clear-spacing',
[],
[{ operationId: 'doc.format.paragraph.setSpacing', extraArgs: ['--before', '120', '--after', '120'] }],
),
'doc.format.paragraph.setKeepOptions': paragraphMutationScenario(
'doc.format.paragraph.setKeepOptions',
'format-paragraph-set-keep-options',
['--keep-next', 'true'],
),
'doc.format.paragraph.setOutlineLevel': paragraphMutationScenario(
'doc.format.paragraph.setOutlineLevel',
'format-paragraph-set-outline',
['--outline-level-json', '1'],
),
'doc.format.paragraph.setFlowOptions': paragraphMutationScenario(
'doc.format.paragraph.setFlowOptions',
'format-paragraph-set-flow',
['--contextual-spacing', 'true'],
),
'doc.format.paragraph.setTabStop': paragraphMutationScenario(
'doc.format.paragraph.setTabStop',
'format-paragraph-set-tab-stop',
['--position', '720', '--alignment', 'left'],
),
'doc.format.paragraph.clearTabStop': paragraphMutationScenario(
'doc.format.paragraph.clearTabStop',
'format-paragraph-clear-tab-stop',
['--position', '720'],
[{ operationId: 'doc.format.paragraph.setTabStop', extraArgs: ['--position', '720', '--alignment', 'left'] }],
),
'doc.format.paragraph.clearAllTabStops': paragraphMutationScenario(
'doc.format.paragraph.clearAllTabStops',
'format-paragraph-clear-all-tab-stops',
[],
[{ operationId: 'doc.format.paragraph.setTabStop', extraArgs: ['--position', '720', '--alignment', 'left'] }],
),
'doc.format.paragraph.setBorder': paragraphMutationScenario(
'doc.format.paragraph.setBorder',
'format-paragraph-set-border',
['--side', 'top', '--style', 'single', '--color', '000000'],
),
'doc.format.paragraph.clearBorder': paragraphMutationScenario(
'doc.format.paragraph.clearBorder',
'format-paragraph-clear-border',
['--side', 'top'],
[
{
operationId: 'doc.format.paragraph.setBorder',
extraArgs: ['--side', 'top', '--style', 'single', '--color', '000000'],
},
],
),
'doc.format.paragraph.setShading': paragraphMutationScenario(
'doc.format.paragraph.setShading',
'format-paragraph-set-shading',
['--fill', 'FFFF00'],
),
'doc.format.paragraph.clearShading': paragraphMutationScenario(
'doc.format.paragraph.clearShading',
'format-paragraph-clear-shading',
[],
[{ operationId: 'doc.format.paragraph.setShading', extraArgs: ['--fill', 'FFFF00'] }],
),
'doc.styles.apply': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-styles-apply-success');
const docPath = await harness.copyFixtureDoc('doc-styles-apply');
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/src/__tests__/lib/error-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,15 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => {
expect(result).toBeInstanceOf(CliError);
expect(result!.code).not.toBe('NO_OP');
});

test('paragraph mutation receipt maps INVALID_TARGET to INVALID_ARGUMENT', () => {
const receipt = {
success: false,
failure: { code: 'INVALID_TARGET', message: 'Paragraph target is invalid.' },
};

const result = mapFailedReceipt('format.paragraph.setAlignment' as any, receipt);
expect(result).toBeInstanceOf(CliError);
expect(result!.code).toBe('INVALID_ARGUMENT');
});
});
50 changes: 39 additions & 11 deletions apps/cli/src/cli/operation-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@
* OPERATION_DEFINITIONS, the CLI requires only a one-line entry in each table.
*/

import { COMMAND_CATALOG } from '@superdoc/document-api';
import { COMMAND_CATALOG, INLINE_PROPERTY_REGISTRY, type InlineRunPatchKey } 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'>;
type FormatInlineAliasOperationId = `format.${InlineRunPatchKey}`;

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',
const FORMAT_INLINE_ALIAS_OPERATION_IDS = INLINE_PROPERTY_REGISTRY.map(
(entry) => `format.${entry.key}` as FormatInlineAliasOperationId,
);

function buildFormatInlineAliasRecord<T>(value: T): Record<FormatInlineAliasOperationId, T> {
Expand All @@ -27,6 +25,37 @@ function buildFormatInlineAliasRecord<T>(value: T): Record<FormatInlineAliasOper
>;
}

const PARAGRAPH_OPERATION_IDS = [
'styles.paragraph.setStyle',
'styles.paragraph.clearStyle',
'format.paragraph.resetDirectFormatting',
'format.paragraph.setAlignment',
'format.paragraph.clearAlignment',
'format.paragraph.setIndentation',
'format.paragraph.clearIndentation',
'format.paragraph.setSpacing',
'format.paragraph.clearSpacing',
'format.paragraph.setKeepOptions',
'format.paragraph.setOutlineLevel',
'format.paragraph.setFlowOptions',
'format.paragraph.setTabStop',
'format.paragraph.clearTabStop',
'format.paragraph.clearAllTabStops',
'format.paragraph.setBorder',
'format.paragraph.clearBorder',
'format.paragraph.setShading',
'format.paragraph.clearShading',
] as const satisfies readonly CliExposedOperationId[];

type ParagraphOperationId = (typeof PARAGRAPH_OPERATION_IDS)[number];

function buildParagraphRecord<T>(value: T): Record<ParagraphOperationId, T> {
return Object.fromEntries(PARAGRAPH_OPERATION_IDS.map((operationId) => [operationId, value])) as Record<
ParagraphOperationId,
T
>;
}

// ---------------------------------------------------------------------------
// Orchestration kind (derived from COMMAND_CATALOG)
// ---------------------------------------------------------------------------
Expand All @@ -52,8 +81,8 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
delete: 'deleted text',
'blocks.delete': 'deleted block',
'format.apply': 'applied style',
'format.align': 'set alignment',
...buildFormatInlineAliasRecord('applied style'),
...buildParagraphRecord('updated paragraph formatting'),
'styles.apply': 'applied stylesheet defaults',
'create.paragraph': 'created paragraph',
'create.heading': 'created heading',
Expand Down Expand Up @@ -165,8 +194,8 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
delete: 'mutationReceipt',
'blocks.delete': 'plain',
'format.apply': 'mutationReceipt',
'format.align': 'mutationReceipt',
...buildFormatInlineAliasRecord('mutationReceipt'),
...buildParagraphRecord('plain'),
'styles.apply': 'receipt',
'create.paragraph': 'createResult',
'create.heading': 'createResult',
Expand Down Expand Up @@ -262,8 +291,8 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
delete: null,
'blocks.delete': 'result',
'format.apply': null,
'format.align': null,
...buildFormatInlineAliasRecord(null),
...buildParagraphRecord('result'),
'styles.apply': 'receipt',
'create.paragraph': 'result',
'create.heading': 'result',
Expand Down Expand Up @@ -353,7 +382,6 @@ export const RESPONSE_VALIDATION_KEY: Partial<Record<CliExposedOperationId, stri
replace: 'receipt',
delete: 'receipt',
'format.apply': 'receipt',
'format.align': 'receipt',
...buildFormatInlineAliasRecord('receipt'),
};

Expand Down Expand Up @@ -388,8 +416,8 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
delete: 'textMutation',
'blocks.delete': 'blocks',
'format.apply': 'textMutation',
'format.align': 'textMutation',
...buildFormatInlineAliasRecord('textMutation'),
...buildParagraphRecord('textMutation'),
'styles.apply': 'general',
'create.paragraph': 'create',
'create.heading': 'create',
Expand Down
37 changes: 35 additions & 2 deletions apps/cli/src/lib/document.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { Editor } from 'superdoc/super-editor';
import type { Editor } from 'superdoc/super-editor';
import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx';
import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters';
import { markdownToPmDoc } from '@superdoc/super-editor/markdown';
Expand Down Expand Up @@ -37,6 +37,38 @@ export interface FileOutputMeta {
byteLength: number;
}

type EditorModule = {
Editor: {
open(source: Buffer, options: Record<string, unknown>): Promise<Editor>;
};
};

const EDITOR_IMPORT_CANDIDATES = ['@superdoc/super-editor', 'superdoc/super-editor'] as const;
let cachedEditorModule: EditorModule | null = null;

async function loadEditorModule(): Promise<EditorModule> {
if (cachedEditorModule) return cachedEditorModule;

const errors: string[] = [];
for (const specifier of EDITOR_IMPORT_CANDIDATES) {
try {
const module = (await import(specifier)) as Partial<EditorModule>;
if (module.Editor && typeof module.Editor.open === 'function') {
cachedEditorModule = module as EditorModule;
return cachedEditorModule;
}
errors.push(`${specifier}: module loaded but Editor.open is unavailable`);
} catch (error) {
errors.push(`${specifier}: ${error instanceof Error ? error.message : String(error)}`);
}
}

throw new CliError('DOCUMENT_OPEN_FAILED', 'Failed to load editor runtime module.', {
candidates: [...EDITOR_IMPORT_CANDIDATES],
errors,
});
}

function toUint8Array(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
Expand Down Expand Up @@ -122,9 +154,10 @@ export async function openDocument(
}

let editor: Editor;
const { Editor: EditorRuntime } = await loadEditorModule();
try {
const isTest = process.env.NODE_ENV === 'test';
editor = await Editor.open(Buffer.from(source), {
editor = await EditorRuntime.open(Buffer.from(source), {
documentId: options.documentId ?? meta.path ?? 'blank.docx',
user: { id: 'cli', name: 'CLI' },
...(isTest ? { telemetry: { enabled: false } } : {}),
Expand Down
Loading
Loading