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
3 changes: 2 additions & 1 deletion packages/super-editor/src/core/types/MarkAttributesMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export interface MarkAttributesMap {}

/**
* Get all registered mark names.
* Uses mapped type to force TypeScript to expand the union in hover tooltips.
*/
export type MarkName = keyof MarkAttributesMap;
export type MarkName = { [K in keyof MarkAttributesMap]: K }[keyof MarkAttributesMap];

/**
* Get the attribute type for a mark by name.
Expand Down
3 changes: 2 additions & 1 deletion packages/super-editor/src/core/types/NodeAttributesMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export interface NodeAttributesMap {}

/**
* Get all registered node names.
* Uses mapped type to force TypeScript to expand the union in hover tooltips.
*/
export type NodeName = keyof NodeAttributesMap;
export type NodeName = { [K in keyof NodeAttributesMap]: K }[keyof NodeAttributesMap];

/**
* Get the attribute type for a node by name.
Expand Down
10 changes: 8 additions & 2 deletions packages/superdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
"import": "./dist/superdoc.es.js",
"require": "./dist/superdoc.cjs"
},
"./types": {
"types": "./dist/types.d.ts",
"source": "./src/types.ts",
"import": "./dist/types.es.js",
"require": "./dist/types.cjs"
},
"./converter": {
"import": "./dist/super-editor/converter.es.js"
},
Expand All @@ -44,9 +50,9 @@
"module": "./dist/superdoc.es.js",
"scripts": {
"dev": "vite",
"build": "vite build && pnpm run build:umd",
"build": "pnpm --prefix ../super-editor run types:build && vite build && pnpm run build:umd",
"postbuild": "node ./scripts/ensure-types.cjs",
"build:es": "vite build",
"build:es": "pnpm --prefix ../super-editor run types:build && vite build && node ./scripts/ensure-types.cjs",
"watch:es": "vite build --watch",
"build:umd": "vite build --config vite.config.umd.js",
"clean": "rm -rf dist",
Expand Down
50 changes: 50 additions & 0 deletions packages/superdoc/scripts/ensure-types.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,54 @@ if (!hasSuperDocExport) {
process.exit(1);
}

const distRoot = path.resolve(__dirname, '..', 'dist');
const typeTargets = ['types.d.ts', 'types.es.d.ts', 'types.cjs.d.ts'];
const superEditorDist = path.resolve(__dirname, '..', '..', 'super-editor', 'dist');
const superDocSuperEditorDist = path.resolve(distRoot, 'super-editor');
const superEditorTypesPath = path.join(superEditorDist, 'types.d.ts');

const copyDir = (src, dest) => {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
entries.forEach((entry) => {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else if (entry.isFile()) {
fs.copyFileSync(srcPath, destPath);
}
});
};

try {
fs.mkdirSync(distRoot, { recursive: true });

if (!fs.existsSync(superEditorTypesPath)) {
console.error(`[ensure-types] Missing super-editor types at ${superEditorTypesPath}`);
process.exit(1);
}

let superEditorTypes = fs.readFileSync(superEditorTypesPath, 'utf8');

// Rewrite relative paths to point into the super-editor subdirectory
// e.g. './core/types/...' -> './super-editor/core/types/...'
// './extensions/types/...' -> './super-editor/extensions/types/...'
superEditorTypes = superEditorTypes
.replace(/from\s+['"]\.\/(core|extensions)\//g, "from './super-editor/$1/")
.replace(/import\s+['"]\.\/(core|extensions)\//g, "import './super-editor/$1/");

typeTargets.forEach((target) => {
const targetPath = path.join(distRoot, target);
fs.writeFileSync(targetPath, superEditorTypes, 'utf8');
});

copyDir(superEditorDist, superDocSuperEditorDist);
} catch (error) {
console.error(`[ensure-types] Failed to prepare types entrypoints: ${error?.message || error}`);
process.exit(1);
}

console.log(`[ensure-types] ✓ Verified SuperDoc export in ${basePath}`);
console.log(`[ensure-types] ✓ Copied super-editor dist into dist/super-editor`);
console.log(`[ensure-types] ✓ Copied super-editor types into dist/ (${typeTargets.join(', ')})`);
62 changes: 62 additions & 0 deletions packages/superdoc/src/helpers/schema-introspection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Editor, getRichTextExtensions, getStarterExtensions } from '@superdoc/super-editor';

/**
* @typedef {Object} SchemaIntrospectionOptions
* @property {import('@superdoc/super-editor').Editor} [editor] - Existing Editor instance to introspect.
* @property {Array} [extensions] - Extension list to build a schema from.
* @property {'docx' | 'text' | 'html'} [mode] - Editor mode when building a schema. Defaults to 'docx'.
*/

/**
* Build a schema summary with nodes, marks, and their attribute definitions.
*
* Returns a JSON object describing all nodes and marks in the schema, including
* attribute names, default values, and whether each attribute is required.
* Useful for AI agents, documentation generation, or schema validation.
*
* @param {SchemaIntrospectionOptions} [options] - Configuration options.
* @returns {Promise<import('@superdoc/super-editor').SchemaSummaryJSON>} Schema summary with nodes and marks.
* @throws {Error} If the editor schema is not initialized.
*
* @example
* // Get schema for DOCX mode (default)
* const schema = await getSchemaIntrospection();
* console.log(schema.nodes); // Array of node definitions
*
* @example
* // Get schema for HTML mode
* const schema = await getSchemaIntrospection({ mode: 'html' });
*
* @example
* // Use existing editor instance
* const schema = await getSchemaIntrospection({ editor: myEditor });
*/
export const getSchemaIntrospection = async (options = {}) => {
const { editor, extensions, mode = 'docx' } = options;

if (editor) {
return editor.getSchemaSummaryJSON();
}

const resolvedExtensions =
Array.isArray(extensions) && extensions.length
? extensions
: mode === 'docx'
? getStarterExtensions()
: getRichTextExtensions();

const tempEditor = new Editor({
extensions: resolvedExtensions,
mode,
isHeadless: true,
deferDocumentLoad: true,
});

try {
return await tempEditor.getSchemaSummaryJSON();
} finally {
if (typeof tempEditor.destroy === 'function') {
tempEditor.destroy();
}
}
};
136 changes: 136 additions & 0 deletions packages/superdoc/src/helpers/schema-introspection.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getSchemaIntrospection } from './schema-introspection.js';

describe('getSchemaIntrospection', () => {
describe('with existing editor', () => {
it('should use the provided editor instance', async () => {
const mockSchemaSummary = {
version: '1.0.0',
schemaVersion: 'current',
topNode: 'doc',
nodes: [{ name: 'paragraph', attrs: {} }],
marks: [{ name: 'bold', attrs: {} }],
};

const mockEditor = {
getSchemaSummaryJSON: vi.fn().mockResolvedValue(mockSchemaSummary),
};

const result = await getSchemaIntrospection({ editor: mockEditor });

expect(mockEditor.getSchemaSummaryJSON).toHaveBeenCalledOnce();
expect(result).toBe(mockSchemaSummary);
});

it('should not create a temporary editor when editor is provided', async () => {
const mockEditor = {
getSchemaSummaryJSON: vi.fn().mockResolvedValue({ nodes: [], marks: [] }),
};

await getSchemaIntrospection({ editor: mockEditor });

// If no temporary editor was created, destroy should not be called
expect(mockEditor.destroy).toBeUndefined();
});
});

describe('without existing editor', () => {
it('should create and destroy a temporary editor', async () => {
const result = await getSchemaIntrospection({ mode: 'docx' });

// Verify schema structure is returned
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('version');
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.marks)).toBe(true);
});

it('should default to docx mode when no mode is specified', async () => {
const result = await getSchemaIntrospection();

// DOCX mode includes specific nodes like paragraph, table, etc.
expect(result.nodes).toBeDefined();
const nodeNames = result.nodes.map((n) => n.name);
expect(nodeNames).toContain('paragraph');
});

it('should use html mode when specified', async () => {
const result = await getSchemaIntrospection({ mode: 'html' });

expect(result.nodes).toBeDefined();
expect(Array.isArray(result.nodes)).toBe(true);
});

it('should use text mode when specified', async () => {
const result = await getSchemaIntrospection({ mode: 'text' });

expect(result.nodes).toBeDefined();
expect(Array.isArray(result.nodes)).toBe(true);
});

it('should use provided extensions when given', async () => {
// Using minimal extensions
const customExtensions = [];

// This should not throw even with empty extensions
// (the Editor will use minimal defaults)
const result = await getSchemaIntrospection({ extensions: customExtensions });

expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('marks');
});
});

describe('schema summary structure', () => {
it('should return nodes with name and attrs properties', async () => {
const result = await getSchemaIntrospection();

expect(result.nodes.length).toBeGreaterThan(0);

for (const node of result.nodes) {
expect(node).toHaveProperty('name');
expect(typeof node.name).toBe('string');
expect(node).toHaveProperty('attrs');
expect(typeof node.attrs).toBe('object');
}
});

it('should return marks with name and attrs properties', async () => {
const result = await getSchemaIntrospection();

expect(result.marks.length).toBeGreaterThan(0);

for (const mark of result.marks) {
expect(mark).toHaveProperty('name');
expect(typeof mark.name).toBe('string');
expect(mark).toHaveProperty('attrs');
expect(typeof mark.attrs).toBe('object');
}
});

it('should include attribute metadata with default and required flags', async () => {
const result = await getSchemaIntrospection();

// Find a node that has attributes (paragraph has paragraphProperties)
const paragraphNode = result.nodes.find((n) => n.name === 'paragraph');

if (paragraphNode && Object.keys(paragraphNode.attrs).length > 0) {
const firstAttr = Object.values(paragraphNode.attrs)[0];
expect(firstAttr).toHaveProperty('default');
expect(firstAttr).toHaveProperty('required');
expect(typeof firstAttr.required).toBe('boolean');
}
});
});

describe('error handling', () => {
it('should propagate errors from getSchemaSummaryJSON', async () => {
const mockEditor = {
getSchemaSummaryJSON: vi.fn().mockRejectedValue(new Error('Schema not initialized')),
};

await expect(getSchemaIntrospection({ editor: mockEditor })).rejects.toThrow('Schema not initialized');
});
});
});
2 changes: 2 additions & 0 deletions packages/superdoc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@superdoc/super-editor';
import { DOCX, PDF, HTML, getFileObject, compareVersions } from '@superdoc/common';
import BlankDOCX from '@superdoc/common/data/blank.docx?url';
import { getSchemaIntrospection } from './helpers/schema-introspection.js';

// Beta channel note: keep this file touched so CI picks up prerelease runs
export { SuperDoc } from './core/SuperDoc.js';
Expand All @@ -22,6 +23,7 @@ export {
compareVersions,
Editor,
getRichTextExtensions,
getSchemaIntrospection,

// Allowed types
DOCX,
Expand Down
1 change: 1 addition & 0 deletions packages/superdoc/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type * from '@superdoc/super-editor/src/types.js';

Check failure on line 1 in packages/superdoc/src/types.ts

View workflow job for this annotation

GitHub Actions / run-e2e-tests

Cannot find module '@superdoc/super-editor/src/types.js' or its corresponding type declarations.

Check failure on line 1 in packages/superdoc/src/types.ts

View workflow job for this annotation

GitHub Actions / run-unit-tests

Cannot find module '@superdoc/super-editor/src/types.js' or its corresponding type declarations.
1 change: 1 addition & 0 deletions packages/superdoc/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export default defineConfig(({ mode, command}) => {
input: {
'superdoc': 'src/index.js',
'super-editor': 'src/super-editor.js',
'types': 'src/types.ts',
'super-editor/docx-zipper': '@core/DocxZipper',
'super-editor/converter': '@core/super-converter/SuperConverter',
'super-editor/file-zipper': '@core/super-converter/zipper.js',
Expand Down
Loading