diff --git a/.github/workflows/build-calm-widgets.yml b/.github/workflows/build-calm-widgets.yml new file mode 100644 index 000000000..92273e93d --- /dev/null +++ b/.github/workflows/build-calm-widgets.yml @@ -0,0 +1,38 @@ +name: Build Calm Widgets + +permissions: + contents: read + +on: + pull_request: + branches: + - 'main' + push: + branches: + - 'main' + +jobs: + shared: + name: Build, Test, and Lint Calm Widgets Module + runs-on: ubuntu-latest + + steps: + - name: Checkout PR Branch + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: v22 + + - name: Install workspace + run: npm ci + + - name: Lint Shared Module + run: npm run lint --workspace=calm-widgets + + - name: Build workspace + run: npm run build --workspace=calm-widgets + + - name: Run tests with coverage for Calm Widgets + run: npm run test --workspace=calm-widgets diff --git a/.github/workflows/build-shared.yml b/.github/workflows/build-shared.yml index 68bd6893b..1d319830f 100644 --- a/.github/workflows/build-shared.yml +++ b/.github/workflows/build-shared.yml @@ -32,7 +32,7 @@ jobs: run: npm run lint --workspace=shared - name: Build workspace - run: npm run build --workspace=shared + run: npm run build:shared - name: Run tests with coverage for Shared run: npm run test --workspace=shared diff --git a/calm-widgets/README.md b/calm-widgets/README.md new file mode 100644 index 000000000..8c1aee3b2 --- /dev/null +++ b/calm-widgets/README.md @@ -0,0 +1,350 @@ +# CALM Widgets Framework + +A TypeScript widget system built on Handlebars that provides reusable components for generating Markdown documentation. The framework allows you to create custom widgets that can transform data into formatted output using Handlebars templates. + +## ๐Ÿ”ง Built-in Widgets + +### Table Widget + +Renders data as Markdown tables with support for nested objects and column filtering. + +```handlebars +{{!-- Basic table with headers --}} +{{table services}} + +{{!-- Table without headers --}} +{{table services headers=false}} + +{{!-- Filter specific columns --}} +{{table services columns="name,port,version" key="id"}} +``` + +**Options:** +- `headers` (boolean): Show/hide table headers (default: true) +- `columns` (string): Comma-separated list of columns to include +- `key` (string): Property to use as unique identifier (default: "unique-id") + +### List Widget + +Renders arrays as Markdown lists (ordered or unordered). + +```handlebars +{{!-- Unordered list --}} +{{list features}} + +{{!-- Ordered list --}} +{{list steps ordered=true}} + +{{!-- Extract specific property from objects --}} +{{list services property="name"}} +``` + +**Options:** +- `ordered` (boolean): Create numbered list (default: false) +- `property` (string): Extract specific property from objects + +### JSON Viewer Widget + +Renders data as formatted JSON blocks. + +```handlebars +{{!-- Simple JSON output --}} +{{json-viewer config}} +``` + +## ๐Ÿ› ๏ธ Creating Custom Widgets + +### 1. Widget Definition + +Create a widget by implementing the `CalmWidget` interface: + +```typescript +// src/widgets/my-widget/index.ts +import { CalmWidget } from '@finos/calm-widgets'; + +export interface MyWidgetContext { + title: string; + items: string[]; +} + +export interface MyWidgetOptions { + showCount?: boolean; + prefix?: string; +} + +export interface MyWidgetViewModel { + title: string; + items: string[]; + count?: number; + prefix: string; +} + +export const MyWidget: CalmWidget< + MyWidgetContext, + MyWidgetOptions, + MyWidgetViewModel +> = { + id: 'my-widget', + templatePartial: 'my-widget-template.html', + + // Optional: additional template partials + partials: ['item-template.html'], + + // Transform input data to view model + transformToViewModel: (context, options) => { + const showCount = options?.hash?.showCount ?? false; + const prefix = options?.hash?.prefix ?? 'โ€ข'; + + return { + title: context.title, + items: context.items, + count: showCount ? context.items.length : undefined, + prefix + }; + }, + + // Validate input context + validateContext: (context): context is MyWidgetContext => { + return ( + typeof context === 'object' && + context !== null && + typeof (context as any).title === 'string' && + Array.isArray((context as any).items) && + (context as any).items.every((item: any) => typeof item === 'string') + ); + }, + + // Optional: register custom helpers + registerHelpers: () => ({ + upperCase: (str: string) => str.toUpperCase(), + repeat: (str: string, count: number) => str.repeat(count) + }) +}; +``` + +### 2. Template Files + +Create Handlebars templates for your widget: + +```handlebars + +## {{title}} +{{#if count}} +*Total items: {{count}}* +{{/if}} + +{{#each items}} +{{../prefix}} {{upperCase this}} +{{/each}} +``` + +```handlebars + +{{prefix}} **{{upperCase this}}** +``` + +### 3. Widget Tests + +Create comprehensive tests for your widget: + +```typescript +// src/widgets/my-widget/index.spec.ts +import { describe, it, expect } from 'vitest'; +import { MyWidget } from './index'; + +describe('MyWidget', () => { + describe('validateContext', () => { + it('accepts valid context', () => { + const context = { + title: 'Test Title', + items: ['item1', 'item2'] + }; + expect(MyWidget.validateContext(context)).toBe(true); + }); + + it('rejects invalid context', () => { + expect(MyWidget.validateContext(null)).toBe(false); + expect(MyWidget.validateContext({ title: 123 })).toBe(false); + }); + }); + + describe('transformToViewModel', () => { + it('transforms context correctly', () => { + const context = { title: 'Test', items: ['a', 'b'] }; + const options = { hash: { showCount: true, prefix: '-' } }; + + const result = MyWidget.transformToViewModel!(context, options); + + expect(result).toEqual({ + title: 'Test', + items: ['a', 'b'], + count: 2, + prefix: '-' + }); + }); + }); +}); +``` + +### 4. Test Fixtures + +Create test fixtures to verify widget output: + +```json +// test-fixtures/my-widget/basic-example/context.json +{ + "title": "My Items", + "items": ["First Item", "Second Item", "Third Item"] +} +``` + +```handlebars +{{!-- test-fixtures/my-widget/basic-example/template.hbs --}} +{{my-widget . showCount=true prefix="โ†’"}} +``` + +```markdown + +## My Items +*Total items: 3* + +โ†’ FIRST ITEM +โ†’ SECOND ITEM +โ†’ THIRD ITEM +``` + +### 5. Register Your Widget + +Add your widget to the engine: + +```typescript +import { MyWidget } from './widgets/my-widget'; + +// Register individual widget +engine.setupWidgets([{ + widget: MyWidget, + folder: __dirname + '/widgets/my-widget' +}]); + +// Or extend registerDefaultWidgets +class MyWidgetEngine extends WidgetEngine { + registerDefaultWidgets() { + super.registerDefaultWidgets(); + + this.setupWidgets([{ + widget: MyWidget, + folder: __dirname + '/widgets/my-widget' + }]); + } +} +``` + +## ๐Ÿงช Testing + +The framework includes comprehensive testing utilities: + +### Running Tests + +```bash +# Run all tests +npm test + +# Run specific widget tests +npm test -- my-widget + +# Run with coverage +npm run test:coverage +``` + +### Test Fixtures + +Use the fixture system for consistent testing: + +```typescript +import { FixtureLoader } from './test-utils/fixture-loader'; + +const fixtures = new FixtureLoader(); +const { context, template, expected } = fixtures.loadFixture('my-widget', 'basic-example'); + +const compiledTemplate = handlebars.compile(template); +const result = compiledTemplate(context); + +expect(result.trim()).toBe(expected); +``` + +### Updating Fixtures + +Use the fixture update script to regenerate expected outputs: + +```bash +npx tsx src/scripts/update-fixtures.ts +``` + +## ๐Ÿ” Architecture + +### Core Components + +- **WidgetEngine**: Orchestrates widget registration and setup +- **WidgetRegistry**: Manages widget storage and Handlebars partial registration +- **WidgetRenderer**: Handles widget rendering with context validation +- **Widget Helpers**: Global Handlebars helpers available to all widgets + +### Helper Functions + +The framework provides built-in helpers: + +- `eq`, `ne`: Equality comparisons +- `lookup`: Property access +- `json`: JSON stringification +- `kebabToTitleCase`: Convert "api-service" โ†’ "Api Service" +- `kebabCase`: Convert "API Service" โ†’ "api-service" +- `isObject`, `isArray`: Type checking +- `notEmpty`: Check for non-empty values +- `or`: Logical OR operations +- `currentTimestamp`, `currentDate`: Date utilities +- `instanceOf`: Constructor name checking +- `eachInMap`: Object iteration + +### Type Safety + +The framework uses TypeScript generics for type-safe widgets: + +```typescript +CalmWidget +``` + +- `TContext`: Input data type +- `TOptions`: Handlebars options/parameters +- `TViewModel`: Transformed data for template + +## ๐Ÿ“ Best Practices + +### Widget Design + +1. **Keep widgets focused**: Each widget should have a single responsibility +2. **Validate inputs**: Always implement robust `validateContext` +3. **Transform data**: Use `transformToViewModel` to prepare data for templates +4. **Handle errors gracefully**: Provide meaningful error messages +5. **Test thoroughly**: Include unit tests and integration fixtures + +### Template Guidelines + +1. **Use semantic markup**: Generate clean, readable Markdown +2. **Handle empty data**: Gracefully handle missing or empty inputs +3. **Be consistent**: Follow established patterns from built-in widgets +4. **Optimize performance**: Avoid complex logic in templates + +### Testing Strategy + +1. **Unit test widget logic**: Test `validateContext` and `transformToViewModel` +2. **Integration test output**: Use fixtures to verify rendered output +3. **Test edge cases**: Handle null, undefined, and malformed data +4. **Maintain fixtures**: Keep expected outputs up to date + +## ๐Ÿค Contributing + +1. **Create your widget** following the structure above +2. **Add comprehensive tests** including fixtures +3. **Update documentation** if adding new concepts +4. **Follow code style** using the project's ESLint configuration +5. **Test thoroughly** with `npm test` diff --git a/calm-widgets/eslint.config.mjs b/calm-widgets/eslint.config.mjs new file mode 100644 index 000000000..02739d6e5 --- /dev/null +++ b/calm-widgets/eslint.config.mjs @@ -0,0 +1,50 @@ +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default [ + ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), + { + plugins: { + "@typescript-eslint": typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.browser, + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + }, + + rules: { + indent: ["error", 4], + "linebreak-style": ["error", "unix"], + quotes: ["error", "single"], + semi: ["error", "always"], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] + } + }, +]; \ No newline at end of file diff --git a/calm-widgets/package.json b/calm-widgets/package.json new file mode 100644 index 000000000..ac5f5115f --- /dev/null +++ b/calm-widgets/package.json @@ -0,0 +1,59 @@ +{ + "name": "@finos/calm-widgets", + "version": "1.0.0", + "description": "Reusable Handlebars widgets for generating human-readable CALM architecture documentation", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsup && tsc", + "watch": "tsup --watch", + "test": "vitest run", + "test:coverage": "vitest --coverage", + "lint": "eslint src", + "lint-fix": "eslint src --fix" + }, + "keywords": [ + "calm", + "architecture", + "documentation", + "templates", + "handlebars", + "widgets" + ], + "author": "FINOS", + "license": "Apache-2.0", + "dependencies": { + "handlebars": "^4.7.8", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@types/handlebars": "^4.1.0", + "@stoplight/types": "^14.1.1", + "@types/js-yaml": "^4.0.9", + "@types/json-pointer": "^1.0.34", + "@types/junit-report-builder": "^3.0.2", + "@types/lodash": "^4.17.16", + "@types/node": "^22.15.0", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "axios-mock-adapter": "^2.1.0", + "eslint": "^9.24.0", + "fetch-mock": "^12.5.2", + "globals": "^16.0.0", + "memfs": "^4.17.0", + "msw": "^2.7.3", + "typescript": "^5.8.3" + }, + "files": [ + "dist/**/*", + "widgets/**/*" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./widgets": "./widgets" + } +} \ No newline at end of file diff --git a/calm-widgets/scripts/copy-widgets.mjs b/calm-widgets/scripts/copy-widgets.mjs new file mode 100644 index 000000000..760116ba9 --- /dev/null +++ b/calm-widgets/scripts/copy-widgets.mjs @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const projectRoot = path.resolve(__dirname, '..'); +const srcDir = path.join(projectRoot, 'src/widgets'); +const outDirs = [ + path.join(projectRoot, 'dist/cli/widgets'), // For cli bundling + path.join(projectRoot, 'dist/widgets') // For shared tests +]; + +function copyTemplatesRecursively(currentDir, baseDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(currentDir, entry.name); + const relPath = path.relative(baseDir, srcPath); + + if (entry.isDirectory()) { + copyTemplatesRecursively(srcPath, baseDir); + } else if (entry.isFile()) { + if (path.extname(entry.name) === '.ts') continue; // skip .ts files + + for (const outDir of outDirs) { + const destPath = path.join(outDir, relPath); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.copyFileSync(srcPath, destPath); + } + } + } +} + +for (const outDir of outDirs) { + fs.mkdirSync(outDir, { recursive: true }); +} +copyTemplatesRecursively(srcDir, srcDir); diff --git a/calm-widgets/src/index.ts b/calm-widgets/src/index.ts new file mode 100644 index 000000000..a19060ee2 --- /dev/null +++ b/calm-widgets/src/index.ts @@ -0,0 +1,2 @@ +export { WidgetEngine } from './widget-engine'; +export { WidgetRegistry } from './widget-registry'; \ No newline at end of file diff --git a/calm-widgets/src/test-utils/fixture-loader.ts b/calm-widgets/src/test-utils/fixture-loader.ts new file mode 100644 index 000000000..1dc405127 --- /dev/null +++ b/calm-widgets/src/test-utils/fixture-loader.ts @@ -0,0 +1,59 @@ +import fs from 'fs'; +import path from 'path'; + +export interface TestFixture { + context: unknown; + template: string; + expected: string; +} + +export class FixtureLoader { + private readonly fixturesPath: string; + + constructor(fixturesPath: string = path.join(__dirname, '../../test-fixtures')) { + this.fixturesPath = fixturesPath; + } + + loadFixture(widget: string, scenario: string): TestFixture { + const scenarioPath = path.join(this.fixturesPath, widget, scenario); + + const contextPath = path.join(scenarioPath, 'context.json'); + const templatePath = path.join(scenarioPath, 'template.hbs'); + const expectedPath = path.join(scenarioPath, 'expected.md'); + + if (!fs.existsSync(contextPath)) { + throw new Error(`Context file not found: ${contextPath}`); + } + if (!fs.existsSync(templatePath)) { + throw new Error(`Template file not found: ${templatePath}`); + } + if (!fs.existsSync(expectedPath)) { + throw new Error(`Expected file not found: ${expectedPath}`); + } + + const context = JSON.parse(fs.readFileSync(contextPath, 'utf-8')); + const template = fs.readFileSync(templatePath, 'utf-8'); + const expected = fs.readFileSync(expectedPath, 'utf-8').trim(); + + return { context, template, expected }; + } + + listScenarios(widget: string): string[] { + const widgetPath = path.join(this.fixturesPath, widget); + if (!fs.existsSync(widgetPath)) { + return []; + } + return fs.readdirSync(widgetPath, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + } + + listWidgets(): string[] { + if (!fs.existsSync(this.fixturesPath)) { + return []; + } + return fs.readdirSync(this.fixturesPath, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + } +} diff --git a/calm-widgets/src/types.ts b/calm-widgets/src/types.ts new file mode 100644 index 000000000..fc3911a92 --- /dev/null +++ b/calm-widgets/src/types.ts @@ -0,0 +1,18 @@ +export interface CalmWidget< + TContext = unknown, + TOptions = Record, + TViewModel = unknown +> { + id: string; + templatePartial: string; + partials?: string[]; + + registerHelpers?: () => Record unknown>; + + transformToViewModel?: ( + context: TContext, + options?: { hash?: TOptions } + ) => TViewModel; + + validateContext: (context: unknown) => context is TContext; +} diff --git a/calm-widgets/src/widget-engine.spec.ts b/calm-widgets/src/widget-engine.spec.ts new file mode 100644 index 000000000..c6f3a6fa8 --- /dev/null +++ b/calm-widgets/src/widget-engine.spec.ts @@ -0,0 +1,134 @@ +import {describe, it, expect, vi, beforeEach, Mock} from 'vitest'; +import handlebars from 'handlebars'; +import { WidgetEngine } from './widget-engine'; +import { WidgetRegistry } from './widget-registry'; +import { CalmWidget } from './types'; +import { WidgetRenderer } from './widget-renderer'; + +const globalHelpers = { + kebabToTitleCase: (s: string) => s.replace(/-/g, ' '), + notEmpty: (obj: object) => !!obj && Object.keys(obj).length > 0, +}; + +vi.mock('./widget-helpers', () => ({ + registerGlobalTemplateHelpers: () => globalHelpers, +})); + +vi.mock('./widget-renderer', () => ({ + WidgetRenderer: vi.fn().mockImplementation(() => ({ + render: vi.fn().mockReturnValue('rendered-content'), + })), +})); + +vi.mock('./widgets/json-viewer', () => ({ + JsonViewerWidget: { + id: 'json-viewer', + templatePartial: 'json.hbs', + validateContext: () => true, + }, +})); + +vi.mock('./widgets/list', () => ({ + ListWidget: { + id: 'list', + templatePartial: 'list.hbs', + validateContext: () => true, + }, +})); + +vi.mock('./widgets/table', () => ({ + TableWidget: { + id: 'table', + templatePartial: 'table.hbs', + validateContext: () => true, + }, +})); + +describe('WidgetEngine', () => { + let localHandlebars: typeof handlebars; + const registerMock = vi.fn(); + let registry: WidgetRegistry; + let engine: WidgetEngine; + + beforeEach(() => { + vi.clearAllMocks(); + localHandlebars = handlebars.create(); // โœ… fresh handlebars with empty helpers + vi.spyOn(localHandlebars, 'registerHelper'); + registry = { register: registerMock } as unknown as WidgetRegistry; + engine = new WidgetEngine(localHandlebars, registry); + }); + + describe('setupWidgets', () => { + it('registers global helpers and widgets', () => { + const mockWidget: CalmWidget, unknown> = { + id: 'mock-widget', + templatePartial: 'main.hbs', + validateContext: (_context): _context is unknown => true, + }; + + engine.setupWidgets([{ widget: mockWidget, folder: '/mock' }]); + + expect(localHandlebars.registerHelper).toHaveBeenCalledWith('kebabToTitleCase', expect.any(Function)); + expect(localHandlebars.registerHelper).toHaveBeenCalledWith('notEmpty', expect.any(Function)); + expect(registerMock).toHaveBeenCalledWith(mockWidget, '/mock'); + expect(localHandlebars.registerHelper).toHaveBeenCalledWith('mock-widget', expect.any(Function)); + }); + + it('throws if widget id collides with global helper', () => { + const badWidget: CalmWidget, unknown> = { + id: 'notEmpty', + templatePartial: 'oops.hbs', + validateContext: (_): _ is unknown => true, + }; + + expect(() => { + engine.setupWidgets([{ widget: badWidget, folder: '/bad' }]); + }).toThrowError('[WidgetEngine] โŒ Conflict: widget id \'notEmpty\' collides with a global helper name.'); + }); + + it('throws if widget id already registered as helper', () => { + localHandlebars.registerHelper('table', () => true); + + const conflictingWidget: CalmWidget, unknown> = { + id: 'table', + templatePartial: 'conflict.hbs', + validateContext: (_): _ is unknown => true, + }; + + expect(() => { + engine.setupWidgets([{ widget: conflictingWidget, folder: '/conflict' }]); + }).toThrowError('[WidgetEngine] โŒ Conflict: Handlebars already has a helper registered as \'table\'.'); + }); + + it('handles empty widget array', () => { + engine.setupWidgets([]); + expect(registerMock).not.toHaveBeenCalled(); + }); + }); + + describe('registerWidgetHelper', () => { + it('calls WidgetRenderer and returns SafeString', () => { + engine.registerWidgetHelper('test-widget'); + + const calls = (localHandlebars.registerHelper as Mock).mock.calls; + const [helperName, helperFn] = calls.find(([name]) => name === 'test-widget')!; + + const output = helperFn({ some: 'context' }, { hash: {} }); + + expect(helperName).toBe('test-widget'); + expect(output.toString()).toBe('rendered-content'); + expect(WidgetRenderer).toHaveBeenCalledWith(localHandlebars, registry); + }); + }); + + describe('registerDefaultWidgets', () => { + it('registers the default widgets (list, table, json-viewer)', () => { + engine.registerDefaultWidgets(); + + expect(registerMock).toHaveBeenCalledTimes(3); + expect(localHandlebars.registerHelper).toHaveBeenCalledWith('list', expect.any(Function)); + expect(localHandlebars.registerHelper).toHaveBeenCalledWith('table', expect.any(Function)); + expect(localHandlebars.registerHelper).toHaveBeenCalledWith('json-viewer', expect.any(Function)); + }); + }); +}); diff --git a/calm-widgets/src/widget-engine.ts b/calm-widgets/src/widget-engine.ts new file mode 100644 index 000000000..23ee83338 --- /dev/null +++ b/calm-widgets/src/widget-engine.ts @@ -0,0 +1,66 @@ +import Handlebars from 'handlebars'; +import { WidgetRegistry } from './widget-registry'; +import { WidgetRenderer } from './widget-renderer'; +import { CalmWidget } from './types'; +import {registerGlobalTemplateHelpers} from './widget-helpers'; + +import { TableWidget } from './widgets/table'; +import { ListWidget } from './widgets/list'; +import { JsonViewerWidget } from './widgets/json-viewer'; + +export class WidgetEngine { + constructor( + private readonly handlebars: typeof Handlebars, + private readonly registry: WidgetRegistry + ) {} + + setupWidgets(widgets: { widget: CalmWidget, unknown>, folder: string }[]) { + const helpers = registerGlobalTemplateHelpers(); + + for (const [name, fn] of Object.entries(helpers)) { + this.handlebars.registerHelper(name, fn); + } + + for (const { widget, folder } of widgets) { + const widgetId = widget.id; + + if (helpers[widgetId]) { + throw new Error(`[WidgetEngine] โŒ Conflict: widget id '${widgetId}' collides with a global helper name.`); + } + + if (this.handlebars.helpers[widgetId]) { + throw new Error(`[WidgetEngine] โŒ Conflict: Handlebars already has a helper registered as '${widgetId}'.`); + } + + this.registry.register(widget, folder); + this.registerWidgetHelper(widgetId); + } + } + + + registerWidgetHelper(widgetId: string) { + this.handlebars.registerHelper(widgetId, (context: unknown, options: Record) => { + const renderer = new WidgetRenderer(this.handlebars, this.registry); + const rendered = renderer.render(widgetId, context, options); + return new this.handlebars.SafeString(rendered); + }); + } + + registerDefaultWidgets() { + const widgets: { widget: CalmWidget, folder: string }[] = [ + { + widget: TableWidget as CalmWidget, + folder: __dirname + '/widgets/table', + }, + { + widget: ListWidget as CalmWidget, + folder: __dirname + '/widgets/list', + }, + { + widget: JsonViewerWidget, + folder: __dirname + '/widgets/json-viewer', + }, + ]; + this.setupWidgets(widgets); + } +} diff --git a/calm-widgets/src/widget-helpers.spec.ts b/calm-widgets/src/widget-helpers.spec.ts new file mode 100644 index 000000000..04fd57b65 --- /dev/null +++ b/calm-widgets/src/widget-helpers.spec.ts @@ -0,0 +1,311 @@ +import { describe, it, expect } from 'vitest'; +import { registerGlobalTemplateHelpers } from './widget-helpers'; + +describe('Widget Helpers', () => { + const helpers = registerGlobalTemplateHelpers(); + + describe('eq helper', () => { + it('returns true for equal values', () => { + expect(helpers.eq(5, 5)).toBe(true); + expect(helpers.eq('hello', 'hello')).toBe(true); + expect(helpers.eq(null, null)).toBe(true); + }); + + it('returns false for unequal values', () => { + expect(helpers.eq(5, 10)).toBe(false); + expect(helpers.eq('hello', 'world')).toBe(false); + expect(helpers.eq(null, undefined)).toBe(false); + }); + }); + + describe('ne helper', () => { + it('returns false for equal values', () => { + expect(helpers.ne(5, 5)).toBe(false); + expect(helpers.ne('hello', 'hello')).toBe(false); + }); + + it('returns true for unequal values', () => { + expect(helpers.ne(5, 10)).toBe(true); + expect(helpers.ne('hello', 'world')).toBe(true); + }); + }); + + describe('currentTimestamp helper', () => { + it('returns ISO timestamp string', () => { + const timestamp = helpers.currentTimestamp(); + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + }); + + describe('currentDate helper', () => { + it('returns date in YYYY-MM-DD format', () => { + const date = helpers.currentDate(); + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe('lookup helper', () => { + it('returns property value for string keys', () => { + const obj = { name: 'John', age: 30 }; + expect(helpers.lookup(obj, 'name')).toBe('John'); + expect(helpers.lookup(obj, 'age')).toBe(30); + }); + + it('returns property value for numeric keys', () => { + const arr = ['a', 'b', 'c']; + expect(helpers.lookup(arr, 0)).toBe('a'); + expect(helpers.lookup(arr, 2)).toBe('c'); + }); + + it('returns undefined for non-existent keys', () => { + const obj = { name: 'John' }; + expect(helpers.lookup(obj, 'missing')).toBeUndefined(); + }); + + it('returns undefined for invalid inputs', () => { + expect(helpers.lookup(null, 'key')).toBeUndefined(); + expect(helpers.lookup({}, null)).toBeUndefined(); + expect(helpers.lookup({}, {})).toBeUndefined(); + }); + }); + + describe('json helper', () => { + it('stringifies objects with proper formatting', () => { + const obj = { name: 'John', age: 30 }; + const result = helpers.json(obj); + expect(result).toBe('{\n "name": "John",\n "age": 30\n}'); + }); + + it('handles arrays', () => { + const arr = [1, 2, 3]; + const result = helpers.json(arr); + expect(result).toBe('[\n 1,\n 2,\n 3\n]'); + }); + + it('handles primitive values', () => { + expect(helpers.json('hello')).toBe('"hello"'); + expect(helpers.json(42)).toBe('42'); + expect(helpers.json(true)).toBe('true'); + }); + }); + + describe('instanceOf helper', () => { + it('returns true for matching constructor names', () => { + const obj = new Date(); + expect(helpers.instanceOf(obj, 'Date')).toBe(true); + }); + + it('returns false for non-matching constructor names', () => { + const obj = new Date(); + expect(helpers.instanceOf(obj, 'Array')).toBe(false); + }); + + it('returns false for invalid inputs', () => { + expect(helpers.instanceOf(null, 'Date')).toBe(false); + expect(helpers.instanceOf({}, null)).toBe(false); + expect(helpers.instanceOf('string', 'String')).toBe(false); + }); + }); + + describe('kebabToTitleCase helper', () => { + it('converts kebab-case to Title Case', () => { + expect(helpers.kebabToTitleCase('hello-world')).toBe('Hello World'); + expect(helpers.kebabToTitleCase('api-gateway-service')).toBe('Api Gateway Service'); + expect(helpers.kebabToTitleCase('single')).toBe('Single'); + }); + + it('handles empty and invalid inputs', () => { + expect(helpers.kebabToTitleCase('')).toBe(''); + expect(helpers.kebabToTitleCase(null)).toBe(''); + expect(helpers.kebabToTitleCase(undefined)).toBe(''); + expect(helpers.kebabToTitleCase(123)).toBe(''); + }); + + it('handles already capitalized words', () => { + expect(helpers.kebabToTitleCase('API-Gateway')).toBe('API Gateway'); + }); + }); + + describe('kebabCase helper', () => { + it('converts strings to kebab-case', () => { + expect(helpers.kebabCase('Hello World')).toBe('hello-world'); + expect(helpers.kebabCase('API Gateway Service')).toBe('api-gateway-service'); + expect(helpers.kebabCase('camelCaseString')).toBe('camelcasestring'); + }); + + it('handles special characters', () => { + expect(helpers.kebabCase('Hello & World!')).toBe('hello-world'); + expect(helpers.kebabCase('Test@123#456')).toBe('test-123-456'); + }); + + it('handles empty and invalid inputs', () => { + expect(helpers.kebabCase('')).toBe(''); + expect(helpers.kebabCase(null)).toBe(''); + expect(helpers.kebabCase(undefined)).toBe(''); + expect(helpers.kebabCase(123)).toBe(''); + }); + + it('removes leading and trailing dashes', () => { + expect(helpers.kebabCase(' hello world ')).toBe('hello-world'); + expect(helpers.kebabCase('---test---')).toBe('test'); + }); + }); + + describe('isObject helper', () => { + it('returns true for plain objects', () => { + expect(helpers.isObject({})).toBe(true); + expect(helpers.isObject({ key: 'value' })).toBe(true); + }); + + it('returns false for arrays', () => { + expect(helpers.isObject([])).toBe(false); + expect(helpers.isObject([1, 2, 3])).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(helpers.isObject(null)).toBe(false); + expect(helpers.isObject(undefined)).toBe(false); + }); + + it('returns false for primitive values', () => { + expect(helpers.isObject('string')).toBe(false); + expect(helpers.isObject(123)).toBe(false); + expect(helpers.isObject(true)).toBe(false); + }); + }); + + describe('isArray helper', () => { + it('returns true for arrays', () => { + expect(helpers.isArray([])).toBe(true); + expect(helpers.isArray([1, 2, 3])).toBe(true); + }); + + it('returns false for non-arrays', () => { + expect(helpers.isArray({})).toBe(false); + expect(helpers.isArray('string')).toBe(false); + expect(helpers.isArray(null)).toBe(false); + }); + }); + + describe('notEmpty helper', () => { + it('returns true for non-empty arrays', () => { + expect(helpers.notEmpty([1, 2, 3])).toBe(true); + expect(helpers.notEmpty(['a'])).toBe(true); + }); + + it('returns false for empty arrays', () => { + expect(helpers.notEmpty([])).toBe(false); + }); + + it('returns true for non-empty objects', () => { + expect(helpers.notEmpty({ key: 'value' })).toBe(true); + }); + + it('returns false for empty objects', () => { + expect(helpers.notEmpty({})).toBe(false); + }); + + it('handles Maps and Sets', () => { + expect(helpers.notEmpty(new Map([['key', 'value']]))).toBe(true); + expect(helpers.notEmpty(new Map())).toBe(false); + expect(helpers.notEmpty(new Set([1, 2]))).toBe(true); + expect(helpers.notEmpty(new Set())).toBe(false); + }); + + it('returns true for non-empty strings', () => { + expect(helpers.notEmpty('hello')).toBe(true); + }); + + it('returns false for empty or whitespace-only strings', () => { + expect(helpers.notEmpty('')).toBe(false); + expect(helpers.notEmpty(' ')).toBe(false); + expect(helpers.notEmpty(' ')).toBe(false); // whitespace-only strings are treated as empty + }); + + it('returns false for null and undefined', () => { + expect(helpers.notEmpty(null)).toBe(false); + expect(helpers.notEmpty(undefined)).toBe(false); + }); + + it('returns boolean value for other types', () => { + expect(helpers.notEmpty(0)).toBe(false); + expect(helpers.notEmpty(1)).toBe(true); + expect(helpers.notEmpty(false)).toBe(false); + expect(helpers.notEmpty(true)).toBe(true); + }); + }); + + describe('or helper', () => { + it('returns true if any argument is truthy', () => { + expect(helpers.or(false, true, false)).toBe(true); + expect(helpers.or(0, '', 'hello')).toBe(true); + expect(helpers.or(null, undefined, 42)).toBe(true); + }); + + it('returns false if all arguments are falsy', () => { + expect(helpers.or(false, false, false)).toBe(false); + expect(helpers.or(0, '', null)).toBe(false); + expect(helpers.or(undefined, false)).toBe(false); + }); + + it('handles handlebars options parameter', () => { + const options = { fn: () => 'template' }; + expect(helpers.or(false, false, options)).toBe(false); + expect(helpers.or(true, false, options)).toBe(true); + }); + + it('handles empty arguments', () => { + expect(helpers.or()).toBe(false); + }); + }); + + describe('eachInMap helper', () => { + it('iterates over object properties with primitive values', () => { + const map = { a: 'value1', b: 'value2' }; + const mockOptions = { + fn: (context: unknown) => { + const ctx = context as Record; + return `${ctx.key}:${ctx.key === 'a' ? 'value1' : 'value2'};`; + } + }; + + const result = helpers.eachInMap(map, mockOptions); + expect(result).toContain('a:value1;'); + expect(result).toContain('b:value2;'); + }); + + it('passes key with object values', () => { + const map = { user1: { name: 'John' }, user2: { name: 'Jane' } }; + const mockOptions = { + fn: (context: unknown) => { + const ctx = context as Record; + return `${ctx.key}:${ctx.name};`; + } + }; + + const result = helpers.eachInMap(map, mockOptions); + expect(result).toContain('user1:John;'); + expect(result).toContain('user2:Jane;'); + }); + + it('handles non-object values', () => { + const map = { count: 5, flag: true }; + const mockOptions = { + fn: (context: unknown) => { + const ctx = context as Record; + return `${ctx.key};`; + } + }; + + const result = helpers.eachInMap(map, mockOptions); + expect(result).toContain('count;'); + expect(result).toContain('flag;'); + }); + + it('returns empty string for invalid inputs', () => { + expect(helpers.eachInMap(null, {})).toBe(''); + expect(helpers.eachInMap({}, null)).toBe(''); + expect(helpers.eachInMap({}, { notAFunction: true })).toBe(''); + }); + }); +}); diff --git a/calm-widgets/src/widget-helpers.ts b/calm-widgets/src/widget-helpers.ts new file mode 100644 index 000000000..eaf077de6 --- /dev/null +++ b/calm-widgets/src/widget-helpers.ts @@ -0,0 +1,93 @@ +export function registerGlobalTemplateHelpers(): Record unknown> { + return { + eq: (a: unknown, b: unknown): boolean => a === b, + ne: (a: unknown, b: unknown): boolean => a !== b, + currentTimestamp: (): string => new Date().toISOString(), + currentDate: (): string => new Date().toISOString().split('T')[0], + lookup: (obj: unknown, key: unknown): unknown => { + if (obj && (typeof key === 'string' || typeof key === 'number')) { + return (obj as Record)[key]; + } + return undefined; + }, + json: (obj: unknown): string => JSON.stringify(obj, null, 2), + instanceOf: (value: unknown, className: unknown): boolean => + typeof className === 'string' && + typeof value === 'object' && + value !== null && + 'constructor' in value && + (value as { constructor: { name: string } }).constructor.name === className, + + kebabToTitleCase: (str: unknown): string => { + if (typeof str !== 'string') return ''; + return str + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + }, + + kebabCase: (str: unknown): string => { + if (typeof str !== 'string') return ''; + return str + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + }, + + isObject: (value: unknown): boolean => + typeof value === 'object' && value !== undefined && value !== null && !Array.isArray(value), + + isArray: (value: unknown): boolean => Array.isArray(value), + + notEmpty: (value: unknown): boolean => { + if (value == null) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') { + if (value instanceof Map || value instanceof Set) return value.size > 0; + return Object.keys(value).length > 0; + } + if (typeof value === 'string') return value.trim().length > 0; + return Boolean(value); + }, + + or: (...args: unknown[]): boolean => { + const maybeOptions = args[args.length - 1]; + + const isHandlebarsOptions = (obj: unknown): obj is { fn: (...args: unknown[]) => unknown } => + typeof obj === 'object' && + obj !== null && + 'fn' in obj && + typeof (obj as { fn: unknown }).fn === 'function'; + + const actualArgs = isHandlebarsOptions(maybeOptions) ? args.slice(0, -1) : args; + + return actualArgs.some(Boolean); + }, + + eachInMap: (map: unknown, options: unknown): string => { + let result = ''; + if ( + typeof map === 'object' && + map !== null && + typeof options === 'object' && + options !== null && + 'fn' in options && + typeof (options as Record).fn === 'function' + ) { + const fn = (options as { fn: (context: unknown) => string }).fn; + for (const key in map as Record) { + if (Object.prototype.hasOwnProperty.call(map, key)) { + const value = (map as Record)[key]; + const context: Record = + typeof value === 'object' && value !== null + ? { ...value, key } + : { key }; + result += fn(context); + } + } + } + return result; + }, + }; +} diff --git a/calm-widgets/src/widget-registry.spec.ts b/calm-widgets/src/widget-registry.spec.ts new file mode 100644 index 000000000..133715dee --- /dev/null +++ b/calm-widgets/src/widget-registry.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Handlebars from 'handlebars'; +import { WidgetRegistry } from './widget-registry'; +import { CalmWidget } from './types'; +import { vol } from 'memfs'; +import { createFsFromVolume } from 'memfs'; +import * as fs from 'fs'; + +const memFs = createFsFromVolume(vol); + +describe('WidgetRegistry (with memfs)', () => { + const registerPartial = vi.fn(); + const registerHelper = vi.fn(); + const mockHandlebars = { + registerPartial, + registerHelper, + } as unknown as typeof Handlebars; + + let registry: WidgetRegistry; + + beforeEach(() => { + vol.reset(); // Reset in-memory filesystem + vi.clearAllMocks(); + + const readFileSyncCompat = memFs.readFileSync.bind(memFs) as unknown as typeof fs.readFileSync; + registry = new WidgetRegistry(mockHandlebars, readFileSyncCompat); + }); + + it('registers main and partial templates using memfs', () => { + vol.fromJSON({ + '/widget/main.hbs': 'Main Template Content', + '/widget/sub1.hbs': 'Subtemplate 1', + '/widget/sub2.hbs': 'Subtemplate 2', + }); + + const widget: CalmWidget = { + id: 'my-widget', + templatePartial: 'main.hbs', + partials: ['sub1.hbs', 'sub2.hbs'], + validateContext: function (context: unknown): context is unknown { + return false; + } + }; + + registry.register(widget, '/widget'); + + expect(registerPartial).toHaveBeenCalledWith('main.hbs', 'Main Template Content'); + expect(registerPartial).toHaveBeenCalledWith('my-widget', 'Main Template Content'); + expect(registerPartial).toHaveBeenCalledWith('sub1.hbs', 'Subtemplate 1'); + expect(registerPartial).toHaveBeenCalledWith('sub2.hbs', 'Subtemplate 2'); + }); + + it('registers helpers if provided', () => { + vol.fromJSON({ + '/widget/main.hbs': 'Main Template', + }); + + const widget: CalmWidget = { + id: 'helper-widget', + templatePartial: 'main.hbs', + registerHelpers: () => ({ + shout: (...args: unknown[]) => { + const s = args[0] as string; + return s.toUpperCase(); + }, + count: (...args: unknown[]) => { + const arr = args[0] as unknown[]; + return arr.length; + }, + }), + validateContext: function (context: unknown): context is unknown { + return true; + } + }; + + registry.register(widget, '/widget'); + + expect(registerHelper).toHaveBeenCalledWith('shout', expect.any(Function)); + expect(registerHelper).toHaveBeenCalledWith('count', expect.any(Function)); + }); + + it('can retrieve registered widget', () => { + vol.fromJSON({ + '/widget/main.hbs': 'Main Template', + }); + + const widget: CalmWidget = { + id: 'retrievable', + templatePartial: 'main.hbs', + validateContext: function (context: unknown): context is unknown { + return true; + } + }; + + registry.register(widget, '/widget'); + expect(registry.get('retrievable')?.id).toBe('retrievable'); + }); + + it('clears widgets', () => { + vol.fromJSON({ + '/widget/main.hbs': 'Main Template', + }); + + const widget: CalmWidget = { + id: 'clear-me', + templatePartial: 'main.hbs', + validateContext: function (context: unknown): context is unknown { + return true; + } + }; + + registry.register(widget, '/widget'); + expect(registry.get('clear-me')).toBeDefined(); + + registry.clear(); + expect(registry.get('clear-me')).toBeUndefined(); + }); +}); diff --git a/calm-widgets/src/widget-registry.ts b/calm-widgets/src/widget-registry.ts new file mode 100644 index 000000000..5ce2a3530 --- /dev/null +++ b/calm-widgets/src/widget-registry.ts @@ -0,0 +1,48 @@ +import fs from 'fs'; +import path from 'path'; +import Handlebars from 'handlebars'; +import { CalmWidget } from './types'; + +/** + * Registers a widget and its associated Handlebars partial(s). + * - Main template is registered under `widget.id` + * - Supporting partials (if any) are registered under their filenames + * + * @param widget - the CalmWidget definition + * @param widgetFolder - the directory where the widget and its templates live (usually __dirname) + */ +export class WidgetRegistry { + private registry: Record = {}; + + constructor(private handlebars = Handlebars, private readFileSync = fs.readFileSync) {} + + register(widget: CalmWidget, widgetFolder: string): void { + const mainPath = path.join(widgetFolder, widget.templatePartial); + const mainSource = this.readFileSync(mainPath, 'utf-8'); + this.handlebars.registerPartial(widget.templatePartial, mainSource); + this.handlebars.registerPartial(widget.id, mainSource); + + widget.partials?.forEach((partialFile) => { + const partialPath = path.join(widgetFolder, partialFile); + const partialSource = this.readFileSync(partialPath, 'utf-8'); + this.handlebars.registerPartial(partialFile, partialSource); + }); + + if (widget.registerHelpers) { + const helpers = widget.registerHelpers(); + for (const [name, fn] of Object.entries(helpers)) { + this.handlebars.registerHelper(name, fn); + } + } + + this.registry[widget.id] = widget as CalmWidget; + } + + get(id: string): CalmWidget | undefined { + return this.registry[id]; + } + + clear(): void { + this.registry = {}; + } +} diff --git a/calm-widgets/src/widget-renderer.spec.ts b/calm-widgets/src/widget-renderer.spec.ts new file mode 100644 index 000000000..e691c0fb3 --- /dev/null +++ b/calm-widgets/src/widget-renderer.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import Handlebars from 'handlebars'; +import { WidgetRenderer } from './widget-renderer'; +import { WidgetRegistry } from './widget-registry'; +import { CalmWidget } from './types'; + +describe('WidgetRenderer', () => { + const compileMock = vi.fn(); + const templateFnMock = vi.fn(); + + const mockHandlebars = { + compile: compileMock, + } as unknown as typeof Handlebars; + + let registry: WidgetRegistry; + let renderer: WidgetRenderer; + + const widgetBase: Partial = { + id: 'test-widget', + templatePartial: 'main.hbs', + }; + + beforeEach(() => { + vi.clearAllMocks(); + compileMock.mockReset(); + templateFnMock.mockReset(); + + registry = { + get: vi.fn(), + } as unknown as WidgetRegistry; + + renderer = new WidgetRenderer(mockHandlebars, registry); + }); + + it('throws if widget is not found', () => { + (registry.get as Mock).mockReturnValue(undefined); + + expect(() => + renderer.render('missing-widget', {}) + ).toThrow('Widget \'missing-widget\' not found.'); + }); + + it('throws if context is invalid', () => { + (registry.get as Mock).mockReturnValue({ + ...widgetBase, + validateContext: () => false, + }); + + expect(() => + renderer.render('test-widget', {}) + ).toThrow('Invalid context for widget \'test-widget\''); + }); + + it('uses transformToViewModel if present', () => { + const widget: CalmWidget = { + id: 'test-widget', + templatePartial: 'main.hbs', + validateContext: (context: unknown): context is unknown => true, + transformToViewModel: vi.fn().mockImplementation((ctx) => ({ + wrapped: ctx, + })), + }; + + (registry.get as Mock).mockReturnValue(widget); + compileMock.mockReturnValue(templateFnMock); + templateFnMock.mockReturnValue('rendered output'); + + const result = renderer.render('test-widget', { foo: 'bar' }); + + expect(widget.transformToViewModel).toHaveBeenCalledWith({ foo: 'bar' }, undefined); + expect(templateFnMock).toHaveBeenCalledWith({ wrapped: { foo: 'bar' } }); + expect(result).toBe('rendered output'); + }); + + it('uses raw context if no transformToViewModel present', () => { + const widget: CalmWidget = { + id: 'plain-widget', + templatePartial: 'main.hbs', + validateContext: (context: unknown): context is unknown => true, + }; + + (registry.get as Mock).mockReturnValue(widget); + compileMock.mockReturnValue(templateFnMock); + templateFnMock.mockReturnValue('plain output'); + + const result = renderer.render('plain-widget', { a: 1 }); + + expect(templateFnMock).toHaveBeenCalledWith({ a: 1 }); + expect(result).toBe('plain output'); + }); +}); diff --git a/calm-widgets/src/widget-renderer.ts b/calm-widgets/src/widget-renderer.ts new file mode 100644 index 000000000..9d028c9a5 --- /dev/null +++ b/calm-widgets/src/widget-renderer.ts @@ -0,0 +1,30 @@ +import Handlebars from 'handlebars'; +import { WidgetRegistry } from './widget-registry'; +import { CalmWidget } from './types'; + +export class WidgetRenderer { + constructor( + private handlebars: typeof Handlebars, + private registry: WidgetRegistry + ) {} + + render( + widgetId: string, + context: unknown, + options?: Record + ): string { + const widget: CalmWidget | undefined = this.registry.get(widgetId); + if (!widget) throw new Error(`Widget '${widgetId}' not found.`); + if (!widget.validateContext?.(context)) { + // TODO: Give more context on why the widget is invalid. + throw new Error(`Invalid context for widget '${widgetId}'`); + } + + const transformed = widget.transformToViewModel + ? widget.transformToViewModel(context, options) + : context; + + const template = this.handlebars.compile(`{{> ${widget.id} }}`); + return template(transformed); + } +} diff --git a/calm-widgets/src/widgets.e2e.spec.ts b/calm-widgets/src/widgets.e2e.spec.ts new file mode 100644 index 000000000..8632411df --- /dev/null +++ b/calm-widgets/src/widgets.e2e.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import Handlebars from 'handlebars'; +import { WidgetEngine } from './widget-engine'; +import { WidgetRegistry } from './widget-registry'; +import { FixtureLoader } from './test-utils/fixture-loader'; + +describe('Widgets E2E - Handlebars Integration', () => { + let handlebars: typeof Handlebars; + let registry: WidgetRegistry; + let engine: WidgetEngine; + let fixtures: FixtureLoader; + + beforeEach(() => { + handlebars = Handlebars.create(); + registry = new WidgetRegistry(handlebars); + engine = new WidgetEngine(handlebars, registry); + engine.registerDefaultWidgets(); + fixtures = new FixtureLoader(); + }); + + describe('Table Widget', () => { + it('renders a simple object as a table with headers', () => { + const { context, template, expected } = fixtures.loadFixture('table-widget', 'simple-object'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + + it('renders an array of objects as a table without headers', () => { + const { context, template, expected } = fixtures.loadFixture('table-widget', 'array-no-headers'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + + it('renders nested objects recursively', () => { + const { context, template, expected } = fixtures.loadFixture('table-widget', 'nested-objects'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + + it('renders with specific columns only (column filtering)', () => { + const { context, template, expected } = fixtures.loadFixture('table-widget', 'column-filtering'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + }); + + describe('List Widget', () => { + it('renders an unordered list of strings', () => { + const { context, template, expected } = fixtures.loadFixture('list-widget', 'unordered-strings'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + + it('renders an ordered list when specified', () => { + const { context, template, expected } = fixtures.loadFixture('list-widget', 'ordered-strings'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + + it('renders objects as key-value pairs', () => { + const { context, template, expected } = fixtures.loadFixture('list-widget', 'objects-as-key-value'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + }); + + describe('JSON Viewer Widget', () => { + it('renders simple objects as formatted JSON', () => { + const { context, template, expected } = fixtures.loadFixture('json-viewer-widget', 'simple-object'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + + it('renders complex nested structures', () => { + const { context, template, expected } = fixtures.loadFixture('json-viewer-widget', 'nested-structure'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + }); + + describe('Combined Widgets', () => { + it('demonstrates comprehensive documentation using all widgets together', () => { + const { context, template, expected } = fixtures.loadFixture('combined-widgets', 'comprehensive-documentation'); + + const compiledTemplate = handlebars.compile(template); + const result = compiledTemplate(context); + + expect(result.trim()).toBe(expected); + }); + }); + + describe('Fixture System', () => { + it('can load all available widget fixtures', () => { + const availableWidgets = fixtures.listWidgets(); + expect(availableWidgets).toContain('table-widget'); + expect(availableWidgets).toContain('list-widget'); + expect(availableWidgets).toContain('json-viewer-widget'); + expect(availableWidgets).toContain('combined-widgets'); + }); + + it('can list scenarios for each widget', () => { + const tableScenarios = fixtures.listScenarios('table-widget'); + expect(tableScenarios).toContain('simple-object'); + expect(tableScenarios).toContain('array-no-headers'); + expect(tableScenarios).toContain('nested-objects'); + + const listScenarios = fixtures.listScenarios('list-widget'); + expect(listScenarios).toContain('unordered-strings'); + expect(listScenarios).toContain('ordered-strings'); + expect(listScenarios).toContain('objects-as-key-value'); + }); + }); +}); diff --git a/calm-widgets/src/widgets/json-viewer/index.spec.ts b/calm-widgets/src/widgets/json-viewer/index.spec.ts new file mode 100644 index 000000000..80b693ace --- /dev/null +++ b/calm-widgets/src/widgets/json-viewer/index.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { JsonViewerWidget } from './index'; + +describe('JsonViewerWidget', () => { + describe('validateContext', () => { + it('always returns true', () => { + expect(JsonViewerWidget.validateContext(null)).toBe(true); + expect(JsonViewerWidget.validateContext({})).toBe(true); + expect(JsonViewerWidget.validateContext([])).toBe(true); + expect(JsonViewerWidget.validateContext('anything')).toBe(true); + }); + }); + + describe('transformToViewModel', () => { + it('wraps context in { context } object', () => { + const input = { foo: 'bar' }; + const vm = JsonViewerWidget.transformToViewModel!(input, {}); + expect(vm).toEqual({ context: input }); + }); + + it('works with primitive context values', () => { + expect(JsonViewerWidget.transformToViewModel!(42, {})).toEqual({ context: 42 }); + expect(JsonViewerWidget.transformToViewModel!('hello', {})).toEqual({ context: 'hello' }); + }); + + it('works with undefined context', () => { + expect(JsonViewerWidget.transformToViewModel!(undefined, {})).toEqual({ context: undefined }); + }); + }); +}); diff --git a/calm-widgets/src/widgets/json-viewer/index.ts b/calm-widgets/src/widgets/json-viewer/index.ts new file mode 100644 index 000000000..f6b93069b --- /dev/null +++ b/calm-widgets/src/widgets/json-viewer/index.ts @@ -0,0 +1,9 @@ +import { CalmWidget } from '../../types'; + +export const JsonViewerWidget: CalmWidget = { + id: 'json-viewer', + templatePartial: 'json-viewer-template.html', + transformToViewModel: (context) => ({ context }), + validateContext: (_context): _context is unknown => true +}; + diff --git a/calm-widgets/src/widgets/json-viewer/json-viewer-template.html b/calm-widgets/src/widgets/json-viewer/json-viewer-template.html new file mode 100644 index 000000000..649cf8a3e --- /dev/null +++ b/calm-widgets/src/widgets/json-viewer/json-viewer-template.html @@ -0,0 +1,4 @@ +```json +{{{json context}}} +``` + diff --git a/calm-widgets/src/widgets/list/index.spec.ts b/calm-widgets/src/widgets/list/index.spec.ts new file mode 100644 index 000000000..6c78c56d7 --- /dev/null +++ b/calm-widgets/src/widgets/list/index.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { ListWidget } from './index'; + +describe('ListWidget', () => { + it('transforms array of strings', () => { + const input = ['alpha', 'beta', 'gamma']; + const vm = ListWidget.transformToViewModel!(input, { hash: { ordered: false } }); + expect(vm).toEqual({ items: input, ordered: false }); + }); + + it('transforms array of objects without property filter', () => { + const input = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob', extra: undefined } + ]; + const vm = ListWidget.transformToViewModel!(input, {}); + expect(vm.items).toEqual([ + 'id: 1, name: Alice', + 'id: 2, name: Bob' + ]); + }); + + it('transforms array with mixed types and property filter', () => { + const input = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob', extra: undefined }, + { id: '3', name: undefined }, + { id: '4' }, // no name + { id: '5', name: 123 }, + { id: '6', name: { nested: true } }, + 'unstructured' + ]; + + const vm = ListWidget.transformToViewModel!(input, { + hash: { property: 'name' } + }); + + expect(vm.items).toEqual([ + 'Alice', + 'Bob', + '123', + 'unstructured' + ]); + }); + + it('filters out undefined and empty items', () => { + const input = [ + undefined, + null, + {}, + { name: undefined }, + 'valid', + ] as never[]; + + const vm = ListWidget.transformToViewModel!(input, { + hash: { property: 'name' } + }); + + expect(vm.items).toEqual(['valid']); + }); + + it('marks ordered correctly', () => { + const vm1 = ListWidget.transformToViewModel!(['a', 'b'], { + hash: { ordered: true } + }); + const vm2 = ListWidget.transformToViewModel!(['a', 'b'], { + hash: { ordered: false } + }); + + expect(vm1.ordered).toBe(true); + expect(vm2.ordered).toBe(false); + }); + + it('validates proper context', () => { + expect(ListWidget.validateContext(['a', { b: 1 }])).toBe(true); + expect(ListWidget.validateContext([{ b: 1 }, null, 'x'])).toBe(false); + expect(ListWidget.validateContext([1, 2, 3])).toBe(false); + }); +}); diff --git a/calm-widgets/src/widgets/list/index.ts b/calm-widgets/src/widgets/list/index.ts new file mode 100644 index 000000000..fb7dd0581 --- /dev/null +++ b/calm-widgets/src/widgets/list/index.ts @@ -0,0 +1,59 @@ +import { CalmWidget } from '../../types'; + +export const ListWidget: CalmWidget< + Array>, + { ordered?: boolean; property?: string }, + { items: Array; ordered: boolean } +> = { + id: 'list', + templatePartial: 'list-template.html', + + transformToViewModel: (context, options) => { + const hash = options?.hash ?? {}; + const ordered = Boolean(hash.ordered); + const property = typeof hash.property === 'string' ? hash.property : undefined; + + const cleanItems = context + .map(item => { + if (typeof item === 'object' && item !== null && !Array.isArray(item)) { + const cleaned = Object.fromEntries( + Object.entries(item).filter(([_, v]) => v !== undefined) + ); + + if (property) { + if (!(property in cleaned)) return undefined; + const val = cleaned[property]; + if (val === undefined || val === null) return undefined; + if (typeof val === 'string') return val; + if (typeof val === 'number' || typeof val === 'boolean') return val.toString(); + if (typeof val === 'object') return undefined; + return String(val); + } + + return Object.entries(cleaned) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + } + + if (typeof item === 'string') return item; + + return undefined; + }) + .filter((item): item is string => typeof item === 'string' && item.length > 0); + + return { + items: cleanItems, + ordered, + }; + }, + validateContext: (context): context is Array> => { + return ( + Array.isArray(context) && + context.every( + item => + typeof item === 'string' || + (typeof item === 'object' && item !== null && !Array.isArray(item)) + ) + ); + }, +}; diff --git a/calm-widgets/src/widgets/list/list-template.html b/calm-widgets/src/widgets/list/list-template.html new file mode 100644 index 000000000..ce548325c --- /dev/null +++ b/calm-widgets/src/widgets/list/list-template.html @@ -0,0 +1,29 @@ +{{#if ordered}} +
    + {{#each items}} +
  1. + {{#if (isObject this)}} + {{#each this}} + {{@key}}: {{this}} {{#unless @last}}, {{/unless}} + {{/each}} + {{else}} + {{this}} + {{/if}} +
  2. + {{/each}} +
+{{else}} +
    + {{#each items}} +
  • + {{#if (isObject this)}} + {{#each this}} + {{@key}}: {{this}} {{#unless @last}}, {{/unless}} + {{/each}} + {{else}} + {{this}} + {{/if}} +
  • + {{/each}} +
+{{/if}} diff --git a/calm-widgets/src/widgets/table/index.spec.ts b/calm-widgets/src/widgets/table/index.spec.ts new file mode 100644 index 000000000..787a972a5 --- /dev/null +++ b/calm-widgets/src/widgets/table/index.spec.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { TableWidget } from './index'; + +describe('TableWidget', () => { + describe('validateContext', () => { + it('accepts array of objects', () => { + expect(TableWidget.validateContext([{ a: 1 }, { b: 2 }])).toBe(true); + }); + + it('accepts single object', () => { + expect(TableWidget.validateContext({ foo: { bar: 1 }, baz: { qux: 2 } })).toBe(true); + }); + + it('rejects null and invalid types', () => { + expect(TableWidget.validateContext(null)).toBe(false); + expect(TableWidget.validateContext(123)).toBe(false); + expect(TableWidget.validateContext('string')).toBe(false); + }); + + it('rejects arrays of non-objects', () => { + expect(TableWidget.validateContext([1, 2, 3])).toBe(false); + }); + + it('rejects array with null elements', () => { + expect(TableWidget.validateContext([{ a: 1 }, null])).toBe(false); + }); + }); + + describe('transformToViewModel', () => { + const data = [ + { 'unique-id': '1', name: 'Alice' }, + { 'unique-id': '2', name: 'Bob', extra: undefined }, + { 'unique-id': '', name: 'Empty ID' }, + { name: 'No ID' }, + { 'unique-id': null } + ]; + + it('transforms array with default options', () => { + const vm = TableWidget.transformToViewModel!(data, {}); + expect(vm.rows.length).toBe(5); // Now processes all valid objects + expect(vm.headers).toBe(true); + expect(vm.rows[0]).toEqual({ + id: '1', // Uses the unique-id value + data: { 'unique-id': '1', name: 'Alice' } + }); + expect(vm.rows[1]).toEqual({ + id: '2', + data: { 'unique-id': '2', name: 'Bob' } + }); + expect(vm.rows[2]).toEqual({ + id: '2', // Uses array index as fallback for empty unique-id + data: { 'unique-id': '', name: 'Empty ID' } + }); + expect(vm.rows[3]).toEqual({ + id: '3', // Uses array index as fallback for missing unique-id + data: { name: 'No ID' } + }); + expect(vm.rows[4]).toEqual({ + id: '4', // Uses array index as fallback for null unique-id + data: { 'unique-id': null } + }); + }); + + it('transforms object into entries', () => { + const input = { + foo: { name: 'Foo' }, + bar: { name: 'Bar' } + }; + const vm = TableWidget.transformToViewModel!(input, {}); + expect(vm.rows.length).toBe(2); + expect(vm.rows[0].id).toBe('foo'); + expect(vm.rows[0].data).toEqual({ name: 'Foo', 'unique-id': 'foo' }); + }); + + it('uses custom key', () => { + const custom = [{ key: 'abc', name: 'Test' }]; + const vm = TableWidget.transformToViewModel!(custom, { + hash: { key: 'key' } + }); + expect(vm.rows[0].id).toBe('abc'); + }); + + it('skips records with missing or non-string key', () => { + const invalid = [{ id: 123 }, { id: null }, {}]; + const vm = TableWidget.transformToViewModel!(invalid, { + hash: { key: 'id' } + }); + expect(vm.rows.length).toBe(3); // All objects processed with fallback indices + expect(vm.rows[0].id).toBe('0'); // Uses array index + expect(vm.rows[1].id).toBe('1'); + expect(vm.rows[2].id).toBe('2'); + }); + + it('respects headers option = false', () => { + const vm = TableWidget.transformToViewModel!(data, { + hash: { headers: false } + }); + expect(vm.headers).toBe(false); + }); + + it('filters columns correctly', () => { + const vm = TableWidget.transformToViewModel!(data, { + hash: { columns: 'name' } + }); + expect(vm.rows[0].data).toEqual({ name: 'Alice' }); + }); + + it('filters columns and keeps key out of data', () => { + const vm = TableWidget.transformToViewModel!(data, { + hash: { columns: 'name', key: 'unique-id' } + }); + expect(vm.rows[0]).toEqual({ + id: '1', + data: { name: 'Alice' } + }); + }); + + it('works with object and columns', () => { + const input = { + foo: { a: 1, b: 2 }, + bar: { a: 3, b: 4 } + }; + const vm = TableWidget.transformToViewModel!(input, { + hash: { columns: 'a' } + }); + expect(vm.rows[0].data).toEqual({ a: 1 }); + }); + }); + + describe('registerHelpers', () => { + const helpers = TableWidget.registerHelpers?.(); + const fn = helpers?.objectEntries; + + it('objectEntries returns id/data pairs', () => { + const result = fn?.({ a: 1, b: 2 }); + expect(result).toEqual([ + { id: 'a', data: 1 }, + { id: 'b', data: 2 } + ]); + }); + + it('objectEntries returns empty for non-object or array', () => { + expect(fn?.(null)).toEqual([]); + expect(fn?.([1, 2, 3])).toEqual([]); + }); + }); +}); diff --git a/calm-widgets/src/widgets/table/index.ts b/calm-widgets/src/widgets/table/index.ts new file mode 100644 index 000000000..1b66dde8f --- /dev/null +++ b/calm-widgets/src/widgets/table/index.ts @@ -0,0 +1,103 @@ +import { CalmWidget } from '../../types'; + +export const TableWidget: CalmWidget< + Array> | Record, + { key?: string; headers?: boolean; columns?: string }, + { + headers: boolean; + rows: Array<{ id: string; data: Record }>; + flatTable?: boolean; + columnNames?: string[]; + } +> = { + id: 'table', + templatePartial: 'table-template.html', + partials: ['row-template.html'], + + transformToViewModel: (context, options) => { + const hash = options?.hash ?? {}; + const key = typeof hash.key === 'string' ? hash.key : 'unique-id'; + const columnList = typeof hash.columns === 'string' + ? hash.columns.split(',').map(col => col.trim()).filter(Boolean) + : undefined; + + // Determine if we should render as flat table or nested + const flatTable = columnList !== undefined; + + let entries: Array>; + + if (Array.isArray(context)) { + entries = context; + } else if (typeof context === 'object' && context !== null && !Array.isArray(context)) { + // For objects, convert to array of entries with the key as the specified key field + entries = Object.entries(context).map(([id, value]) => { + const val = typeof value === 'object' && value !== null && !Array.isArray(value) + ? { ...value, [key]: id } + : { value, [key]: id }; + return val; + }); + } else { + throw new Error('Unsupported context format for table widget'); + } + + const rows = entries + .filter((item): item is Record => { + // For arrays, don't filter by key - just ensure it's a valid object + if (Array.isArray(context)) { + return typeof item === 'object' && item !== null && !Array.isArray(item); + } + // For objects/records, filter by key as before + const id = item?.[key]; + return typeof id === 'string' && id.trim() !== ''; + }) + .map((item, index) => { + // For arrays, use index as fallback ID if no key field exists + let id: string; + if (Array.isArray(context)) { + const keyValue = item?.[key]; + id = typeof keyValue === 'string' && keyValue.trim() !== '' + ? keyValue + : index.toString(); + } else { + id = item[key] as string; + } + + const cleaned = Object.fromEntries( + Object.entries(item).filter(([_, value]) => value !== undefined) + ); + + const selectedData = columnList + ? Object.fromEntries( + columnList.map(col => [col, cleaned[col]]) + ) + : cleaned; + + return { + id, + data: selectedData + }; + }); + + return { + headers: hash.headers !== false, + rows, + flatTable, + columnNames: columnList + }; + }, + + validateContext: (context): context is Array> | Record => { + return ( + (Array.isArray(context) && + context.every(item => typeof item === 'object' && item !== null && !Array.isArray(item))) || + (typeof context === 'object' && context !== null && !Array.isArray(context)) + ); + }, + + registerHelpers: () => ({ + objectEntries: (obj: unknown) => { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return []; + return Object.entries(obj).map(([id, data]) => ({ id, data })); + } + }) +}; diff --git a/calm-widgets/src/widgets/table/row-template.html b/calm-widgets/src/widgets/table/row-template.html new file mode 100644 index 000000000..cffd2f7fe --- /dev/null +++ b/calm-widgets/src/widgets/table/row-template.html @@ -0,0 +1,28 @@ +{{#if ../flatTable}} + + {{id}} + {{#each ../columnNames}} + {{lookup ../data this}} + {{/each}} + +{{else}} + + {{kebabToTitleCase id}} + + {{#if (isObject data)}} + {{> table-template.html rows=(objectEntries data) headers=false }} + + {{else if (isArray data)}} + {{#each data}} + {{#if (isObject this)}} + {{> table-template.html rows=(objectEntries this) headers=false }} + {{else}} + {{this}} + {{/if}} + {{/each}} + {{else}} + {{data}} + {{/if}} + + +{{/if}} \ No newline at end of file diff --git a/calm-widgets/src/widgets/table/table-template.html b/calm-widgets/src/widgets/table/table-template.html new file mode 100644 index 000000000..705534736 --- /dev/null +++ b/calm-widgets/src/widgets/table/table-template.html @@ -0,0 +1,33 @@ +
+ + {{#if headers}} + + + {{#if flatTable}} + {{#each columnNames}} + + {{/each}} + {{else}} + + + {{/if}} + + + {{/if}} + + {{#if flatTable}} + {{#each rows}} + + {{#each ../columnNames}} + + {{/each}} + + {{/each}} + {{else}} + {{#each rows}} + {{> row-template.html this}} + {{/each}} + {{/if}} + +
{{kebabToTitleCase this}}KeyValue
{{lookup ../data this}}
+
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/context.json b/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/context.json new file mode 100644 index 000000000..0b702677e --- /dev/null +++ b/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/context.json @@ -0,0 +1,31 @@ +{ + "system": { + "project-name": "E-Commerce Platform", + "version": "3.0.0", + "architecture": "Microservices", + "deployment": "Kubernetes" + }, + "services": [ + { + "name": "Product Service", + "port": 8080, + "database": "products-db", + "features": ["Product Catalog", "Inventory Management", "Price Calculation"], + "config": { + "cacheEnabled": true, + "maxConnections": 50 + } + }, + { + "name": "Order Service", + "port": 8081, + "database": "orders-db", + "features": ["Order Processing", "Payment Integration", "Order Tracking"], + "config": { + "paymentTimeout": 30000, + "retryAttempts": 3 + } + } + ] +} + diff --git a/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/expected.md b/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/expected.md new file mode 100644 index 000000000..3cac31488 --- /dev/null +++ b/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/expected.md @@ -0,0 +1,454 @@ +# System Architecture + +## System Overview + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Project Name +
+ + + + + + + + + + + +
Value + E-Commerce Platform +
Unique Id + project-name +
+
+
Version +
+ + + + + + + + + + + +
Value + 3.0.0 +
Unique Id + version +
+
+
Architecture +
+ + + + + + + + + + + +
Value + Microservices +
Unique Id + architecture +
+
+
Deployment +
+ + + + + + + + + + + +
Value + Kubernetes +
Unique Id + deployment +
+
+
+
+ +## Microservices + +### Product Service + +#### Configuration +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Name +
+ + + + + + + + + + + +
Value + Product Service +
Unique Id + name +
+
+
Port +
+ + + + + + + + + + + +
Value + 8080 +
Unique Id + port +
+
+
Database +
+ + + + + + + + + + + +
Value + products-db +
Unique Id + database +
+
+
Features +
+ + + + + + + + + + + +
Value + Product Catalog + Inventory Management + Price Calculation +
Unique Id + features +
+
+
Config +
+ + + + + + + + + + + + + + + +
CacheEnabled + true +
MaxConnections + 50 +
Unique Id + config +
+
+
+
+ +#### Features +
    +
  • + Product Catalog +
  • +
  • + Inventory Management +
  • +
  • + Price Calculation +
  • +
+ + +#### Full Configuration (JSON) +```json +{ + "name": "Product Service", + "port": 8080, + "database": "products-db", + "features": [ + "Product Catalog", + "Inventory Management", + "Price Calculation" + ], + "config": { + "cacheEnabled": true, + "maxConnections": 50 + } +} +``` + + + +### Order Service + +#### Configuration +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Name +
+ + + + + + + + + + + +
Value + Order Service +
Unique Id + name +
+
+
Port +
+ + + + + + + + + + + +
Value + 8081 +
Unique Id + port +
+
+
Database +
+ + + + + + + + + + + +
Value + orders-db +
Unique Id + database +
+
+
Features +
+ + + + + + + + + + + +
Value + Order Processing + Payment Integration + Order Tracking +
Unique Id + features +
+
+
Config +
+ + + + + + + + + + + + + + + +
PaymentTimeout + 30000 +
RetryAttempts + 3 +
Unique Id + config +
+
+
+
+ +#### Features +
    +
  • + Order Processing +
  • +
  • + Payment Integration +
  • +
  • + Order Tracking +
  • +
+ + +#### Full Configuration (JSON) +```json +{ + "name": "Order Service", + "port": 8081, + "database": "orders-db", + "features": [ + "Order Processing", + "Payment Integration", + "Order Tracking" + ], + "config": { + "paymentTimeout": 30000, + "retryAttempts": 3 + } +} +``` \ No newline at end of file diff --git a/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/template.hbs b/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/template.hbs new file mode 100644 index 000000000..c1b03a8c7 --- /dev/null +++ b/calm-widgets/test-fixtures/combined-widgets/comprehensive-documentation/template.hbs @@ -0,0 +1,22 @@ +# System Architecture + +## System Overview + +{{table system}} + +## Microservices + +{{#each services}} +### {{name}} + +#### Configuration +{{table this}} + +#### Features +{{list features}} + +#### Full Configuration (JSON) +{{json-viewer this}} + +{{/each}} + diff --git a/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/context.json b/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/context.json new file mode 100644 index 000000000..dfb0b1094 --- /dev/null +++ b/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/context.json @@ -0,0 +1,29 @@ +{ + "architecture": { + "services": { + "frontend": { + "type": "web-app", + "framework": "React", + "dependencies": ["auth-service", "api-gateway"] + }, + "backend": { + "type": "api", + "framework": "Spring Boot", + "database": { + "type": "PostgreSQL", + "version": "13.0", + "replicas": 2 + } + } + }, + "deployment": { + "platform": "Kubernetes", + "replicas": 3, + "resources": { + "cpu": "500m", + "memory": "1Gi" + } + } + } +} + diff --git a/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/expected.md b/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/expected.md new file mode 100644 index 000000000..75d49c131 --- /dev/null +++ b/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/expected.md @@ -0,0 +1,31 @@ +```json +{ + "services": { + "frontend": { + "type": "web-app", + "framework": "React", + "dependencies": [ + "auth-service", + "api-gateway" + ] + }, + "backend": { + "type": "api", + "framework": "Spring Boot", + "database": { + "type": "PostgreSQL", + "version": "13.0", + "replicas": 2 + } + } + }, + "deployment": { + "platform": "Kubernetes", + "replicas": 3, + "resources": { + "cpu": "500m", + "memory": "1Gi" + } + } +} +``` \ No newline at end of file diff --git a/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/template.hbs b/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/template.hbs new file mode 100644 index 000000000..1d2edc54a --- /dev/null +++ b/calm-widgets/test-fixtures/json-viewer-widget/nested-structure/template.hbs @@ -0,0 +1,2 @@ +{{json-viewer architecture}} + diff --git a/calm-widgets/test-fixtures/json-viewer-widget/simple-object/context.json b/calm-widgets/test-fixtures/json-viewer-widget/simple-object/context.json new file mode 100644 index 000000000..7f8ade9e4 --- /dev/null +++ b/calm-widgets/test-fixtures/json-viewer-widget/simple-object/context.json @@ -0,0 +1,8 @@ +{ + "config": { + "environment": "production", + "debug": false, + "maxConnections": 100 + } +} + diff --git a/calm-widgets/test-fixtures/json-viewer-widget/simple-object/expected.md b/calm-widgets/test-fixtures/json-viewer-widget/simple-object/expected.md new file mode 100644 index 000000000..a86bb9d06 --- /dev/null +++ b/calm-widgets/test-fixtures/json-viewer-widget/simple-object/expected.md @@ -0,0 +1,7 @@ +```json +{ + "environment": "production", + "debug": false, + "maxConnections": 100 +} +``` \ No newline at end of file diff --git a/calm-widgets/test-fixtures/json-viewer-widget/simple-object/template.hbs b/calm-widgets/test-fixtures/json-viewer-widget/simple-object/template.hbs new file mode 100644 index 000000000..4a78fc6a6 --- /dev/null +++ b/calm-widgets/test-fixtures/json-viewer-widget/simple-object/template.hbs @@ -0,0 +1,3 @@ +{{json-viewer config}} + + diff --git a/calm-widgets/test-fixtures/list-widget/objects-as-key-value/context.json b/calm-widgets/test-fixtures/list-widget/objects-as-key-value/context.json new file mode 100644 index 000000000..7e08b2364 --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/objects-as-key-value/context.json @@ -0,0 +1,15 @@ +{ + "services": [ + { + "name": "auth-service", + "version": "1.0", + "status": "running" + }, + { + "name": "user-service", + "version": "2.1", + "status": "stopped" + } + ] +} + diff --git a/calm-widgets/test-fixtures/list-widget/objects-as-key-value/expected.md b/calm-widgets/test-fixtures/list-widget/objects-as-key-value/expected.md new file mode 100644 index 000000000..d176e2577 --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/objects-as-key-value/expected.md @@ -0,0 +1,8 @@ +
    +
  • + name: auth-service, version: 1.0, status: running +
  • +
  • + name: user-service, version: 2.1, status: stopped +
  • +
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/list-widget/objects-as-key-value/template.hbs b/calm-widgets/test-fixtures/list-widget/objects-as-key-value/template.hbs new file mode 100644 index 000000000..33216e3d0 --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/objects-as-key-value/template.hbs @@ -0,0 +1,2 @@ +{{list services}} + diff --git a/calm-widgets/test-fixtures/list-widget/ordered-strings/context.json b/calm-widgets/test-fixtures/list-widget/ordered-strings/context.json new file mode 100644 index 000000000..ddfd407ee --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/ordered-strings/context.json @@ -0,0 +1,9 @@ +{ + "steps": [ + "Initialize the application", + "Load configuration", + "Connect to database", + "Start web server" + ] +} + diff --git a/calm-widgets/test-fixtures/list-widget/ordered-strings/expected.md b/calm-widgets/test-fixtures/list-widget/ordered-strings/expected.md new file mode 100644 index 000000000..d402cc867 --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/ordered-strings/expected.md @@ -0,0 +1,14 @@ +
    +
  1. + Initialize the application +
  2. +
  3. + Load configuration +
  4. +
  5. + Connect to database +
  6. +
  7. + Start web server +
  8. +
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/list-widget/ordered-strings/template.hbs b/calm-widgets/test-fixtures/list-widget/ordered-strings/template.hbs new file mode 100644 index 000000000..8d2d1abd9 --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/ordered-strings/template.hbs @@ -0,0 +1,2 @@ +{{list steps ordered=true}} + diff --git a/calm-widgets/test-fixtures/list-widget/unordered-strings/context.json b/calm-widgets/test-fixtures/list-widget/unordered-strings/context.json new file mode 100644 index 000000000..a5e39da63 --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/unordered-strings/context.json @@ -0,0 +1,9 @@ +{ + "features": [ + "Authentication", + "User Management", + "Real-time Notifications", + "Data Analytics" + ] +} + diff --git a/calm-widgets/test-fixtures/list-widget/unordered-strings/expected.md b/calm-widgets/test-fixtures/list-widget/unordered-strings/expected.md new file mode 100644 index 000000000..cea14b39e --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/unordered-strings/expected.md @@ -0,0 +1,17 @@ +
+
    +
  • + Authentication +
  • +
  • + User Management +
  • +
  • + Real-time Notifications +
  • +
  • + Data Analytics +
  • +
+ +
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/list-widget/unordered-strings/template.hbs b/calm-widgets/test-fixtures/list-widget/unordered-strings/template.hbs new file mode 100644 index 000000000..0c16271cc --- /dev/null +++ b/calm-widgets/test-fixtures/list-widget/unordered-strings/template.hbs @@ -0,0 +1,4 @@ +
+ {{list features}} +
+ diff --git a/calm-widgets/test-fixtures/table-widget/array-no-headers/context.json b/calm-widgets/test-fixtures/table-widget/array-no-headers/context.json new file mode 100644 index 000000000..2e87ebc91 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/array-no-headers/context.json @@ -0,0 +1,20 @@ +{ + "services": [ + { + "name": "auth-service", + "port": 8080, + "language": "Java" + }, + { + "name": "user-service", + "port": 8081, + "language": "Python" + }, + { + "name": "notification-service", + "port": 8082, + "language": "Node.js" + } + ] +} + diff --git a/calm-widgets/test-fixtures/table-widget/array-no-headers/expected.md b/calm-widgets/test-fixtures/table-widget/array-no-headers/expected.md new file mode 100644 index 000000000..77ef8e570 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/array-no-headers/expected.md @@ -0,0 +1,93 @@ +
+ + + + + + + + + + + + + + + +
Auth Service +
+ + + + + + + + + + + + + + + +
Name + auth-service +
Port + 8080 +
Language + Java +
+
+
User Service +
+ + + + + + + + + + + + + + + +
Name + user-service +
Port + 8081 +
Language + Python +
+
+
Notification Service +
+ + + + + + + + + + + + + + + +
Name + notification-service +
Port + 8082 +
Language + Node.js +
+
+
+
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/table-widget/array-no-headers/template.hbs b/calm-widgets/test-fixtures/table-widget/array-no-headers/template.hbs new file mode 100644 index 000000000..17de0d0d0 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/array-no-headers/template.hbs @@ -0,0 +1,2 @@ +{{table services headers=false key="name"}} + diff --git a/calm-widgets/test-fixtures/table-widget/column-filtering/context.json b/calm-widgets/test-fixtures/table-widget/column-filtering/context.json new file mode 100644 index 000000000..7c462dc52 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/column-filtering/context.json @@ -0,0 +1,32 @@ +{ + "services": [ + { + "id": "svc1", + "name": "auth", + "port": 8080, + "internal": true, + "secret": "hidden", + "version": "1.0.0", + "status": "active" + }, + { + "id": "svc2", + "name": "user", + "port": 8081, + "internal": false, + "secret": "also-hidden", + "version": "2.1.0", + "status": "running" + }, + { + "id": "svc3", + "name": "notification", + "port": 8082, + "internal": true, + "secret": "very-secret", + "version": "1.5.0", + "status": "stopped" + } + ] +} + diff --git a/calm-widgets/test-fixtures/table-widget/column-filtering/expected.md b/calm-widgets/test-fixtures/table-widget/column-filtering/expected.md new file mode 100644 index 000000000..c9f5acfa4 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/column-filtering/expected.md @@ -0,0 +1,32 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdNamePortVersion
svc1auth80801.0.0
svc2user80812.1.0
svc3notification80821.5.0
+
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/table-widget/column-filtering/template.hbs b/calm-widgets/test-fixtures/table-widget/column-filtering/template.hbs new file mode 100644 index 000000000..1c44496f6 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/column-filtering/template.hbs @@ -0,0 +1,2 @@ +{{table services columns="id,name,port,version"}} + diff --git a/calm-widgets/test-fixtures/table-widget/nested-objects/context.json b/calm-widgets/test-fixtures/table-widget/nested-objects/context.json new file mode 100644 index 000000000..5a07d7f61 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/nested-objects/context.json @@ -0,0 +1,17 @@ +{ + "config": { + "database": { + "host": "localhost", + "port": 5432, + "credentials": { + "username": "admin", + "password": "secret" + } + }, + "api": { + "timeout": 30000, + "retries": 3 + } + } +} + diff --git a/calm-widgets/test-fixtures/table-widget/nested-objects/expected.md b/calm-widgets/test-fixtures/table-widget/nested-objects/expected.md new file mode 100644 index 000000000..3801d8063 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/nested-objects/expected.md @@ -0,0 +1,93 @@ +
+ + + + + + + + + + + + + + + + + +
KeyValue
Database +
+ + + + + + + + + + + + + + + + + + + +
Host + localhost +
Port + 5432 +
Credentials +
+ + + + + + + + + + + +
Username + admin +
Password + secret +
+
+
Unique Id + database +
+
+
Api +
+ + + + + + + + + + + + + + + +
Timeout + 30000 +
Retries + 3 +
Unique Id + api +
+
+
+
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/table-widget/nested-objects/template.hbs b/calm-widgets/test-fixtures/table-widget/nested-objects/template.hbs new file mode 100644 index 000000000..e9ededed0 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/nested-objects/template.hbs @@ -0,0 +1,2 @@ +{{table config}} + diff --git a/calm-widgets/test-fixtures/table-widget/simple-object/context.json b/calm-widgets/test-fixtures/table-widget/simple-object/context.json new file mode 100644 index 000000000..c69da794d --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/simple-object/context.json @@ -0,0 +1,20 @@ +{ + "data": { + "service-a": { + "type": "API", + "version": "1.0.0", + "status": "active" + }, + "service-b": { + "type": "Database", + "version": "2.1.0", + "status": "deprecated" + }, + "service-c": { + "type": "Queue", + "version": "1.5.0", + "status": "active" + } + } +} + diff --git a/calm-widgets/test-fixtures/table-widget/simple-object/expected.md b/calm-widgets/test-fixtures/table-widget/simple-object/expected.md new file mode 100644 index 000000000..a583d1051 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/simple-object/expected.md @@ -0,0 +1,119 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + +
KeyValue
Service A +
+ + + + + + + + + + + + + + + + + + + +
Type + API +
Version + 1.0.0 +
Status + active +
Unique Id + service-a +
+
+
Service B +
+ + + + + + + + + + + + + + + + + + + +
Type + Database +
Version + 2.1.0 +
Status + deprecated +
Unique Id + service-b +
+
+
Service C +
+ + + + + + + + + + + + + + + + + + + +
Type + Queue +
Version + 1.5.0 +
Status + active +
Unique Id + service-c +
+
+
+
+
\ No newline at end of file diff --git a/calm-widgets/test-fixtures/table-widget/simple-object/template.hbs b/calm-widgets/test-fixtures/table-widget/simple-object/template.hbs new file mode 100644 index 000000000..290f04269 --- /dev/null +++ b/calm-widgets/test-fixtures/table-widget/simple-object/template.hbs @@ -0,0 +1,4 @@ +
+ {{table data headers=true}} +
+ diff --git a/calm-widgets/tsconfig.json b/calm-widgets/tsconfig.json new file mode 100644 index 000000000..60edb7814 --- /dev/null +++ b/calm-widgets/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "types": ["vitest"] + }, + "include": ["src", "src/**/*.spec.ts", "../vitest-globals.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/calm-widgets/tsup.config.ts b/calm-widgets/tsup.config.ts new file mode 100644 index 000000000..fb7149c93 --- /dev/null +++ b/calm-widgets/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + external: ['handlebars'], + onSuccess: 'node scripts/copy-widgets.mjs' +}); \ No newline at end of file diff --git a/calm-widgets/vitest.config.ts b/calm-widgets/vitest.config.ts new file mode 100644 index 000000000..0769debda --- /dev/null +++ b/calm-widgets/vitest.config.ts @@ -0,0 +1,28 @@ +import {defineConfig} from 'vitest/config'; + +import {CoverageV8Options} from 'vitest/node'; + +const v8CoverageSettings: CoverageV8Options = { + enabled: true, + reporter: ['text', 'json', 'html'], + thresholds: { + branches: 85, + functions: 75, + lines: 75, + statements: 75 + }, + exclude: ['test_fixtures/**', '*.config.ts', 'src/scripts/update-fixtures.ts'], + include: ['**/*.ts'], +}; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + ...v8CoverageSettings, + }, + testTimeout: 2000000 + } +}); \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index 1289fef6f..ae8c0b714 100644 --- a/cli/package.json +++ b/cli/package.json @@ -15,10 +15,11 @@ "calm": "dist/index.js" }, "scripts": { - "build": "tsup && npm run copy-calm-schema && npm run copy-docify-templates", + "build": "tsup && npm run copy-calm-schema && npm run copy-docify-templates && npm run copy-widgets", "watch": "node watch.mjs", "copy-calm-schema": "copyfiles \"../calm/release/**/meta/*\" \"../calm/draft/**/meta/*\" dist/calm/", "copy-docify-templates": "copyfiles \"../shared/dist/template-bundles/**/*\" dist --up 3", + "copy-widgets": "copyfiles \"../calm-widgets/dist/cli/widgets/**/*\" dist --up 4", "test": "vitest run", "lint": "eslint src", "lint-fix": "eslint src --fix", diff --git a/cli/src/cli.e2e.spec.ts b/cli/src/cli.e2e.spec.ts index 2fcc38416..0d3fecb5e 100644 --- a/cli/src/cli.e2e.spec.ts +++ b/cli/src/cli.e2e.spec.ts @@ -40,7 +40,6 @@ describe('CLI Integration Tests', () => { const targetTarball = path.join(tempDir, tgzName); fs.renameSync(sourceTarball, targetTarball); - // Create clean test consumer execSync('npm init -y', { cwd: tempDir, stdio: 'inherit' }); execSync(`npm install ${targetTarball}`, { cwd: tempDir, @@ -412,6 +411,47 @@ describe('CLI Integration Tests', () => { }); + describe('calm docify command - widget rendering', () => { + function runTemplateWidgetTest(templateName: string, outputName: string) { + return async () => { + + const GETTING_STARTED_TEST_FIXTURES_DIR = join( + __dirname, + '../../cli/test_fixtures/getting-started' + ); + + const testModelPath = path.resolve( + GETTING_STARTED_TEST_FIXTURES_DIR, + 'STEP-3/conference-signup-with-flow.arch.json' + ); + + const fixtureDir = path.resolve(__dirname, '../test_fixtures/template'); + const templatePath = path.join(fixtureDir, `widget-tests/${templateName}`); + const expectedOutputPath = path.join(fixtureDir, `expected-output/widget-tests/${outputName}`); + const outputDir = path.join(tempDir, 'widget-tests'); + const outputFile = path.join(outputDir, outputName); + + await run( + calm(), ['docify', '--input', testModelPath, '--template', templatePath, '--output', outputFile] + ); + + expect(fs.existsSync(outputFile)).toBe(true); + const actual = fs.readFileSync(outputFile, 'utf8').trim(); + const expected = fs.readFileSync(expectedOutputPath, 'utf8').trim(); + expect(actual).toEqual(expected); + }; + } + + test('template command works with table widget', runTemplateWidgetTest('table-test.hbs', 'table-test.md')); + + test('template command works with list widget', runTemplateWidgetTest('list-test.hbs', 'list-test.md')); + + test('A user can render a json view their document or parts of their document', runTemplateWidgetTest('json-viewer-test.hbs', 'json-viewer-test.md')); + + test('A user can render a SAD document', runTemplateWidgetTest('sad-test.hbs', 'sad-test.md')); + }); + + test('Getting Started Verification - CLI Steps', async () => { const GETTING_STARTED_DIR = join( __dirname, diff --git a/cli/test_fixtures/template/expected-output/widget-tests/json-viewer-test.md b/cli/test_fixtures/template/expected-output/widget-tests/json-viewer-test.md new file mode 100644 index 000000000..2d929dcb1 --- /dev/null +++ b/cli/test_fixtures/template/expected-output/widget-tests/json-viewer-test.md @@ -0,0 +1,319 @@ +### Test Full Document + +```json +{ + "nodes": [ + { + "unique-id": "conference-website", + "node-type": "webclient", + "name": "Conference Website", + "description": "Website to sign up for a conference", + "interfaces": [ + { + "unique-id": "conference-website-url", + "url": "[[ URL ]]" + } + ] + }, + { + "unique-id": "load-balancer", + "node-type": "network", + "name": "Load Balancer", + "description": "The attendees service, or a placeholder for another application", + "interfaces": [ + { + "unique-id": "load-balancer-host-port", + "host": "[[ HOST ]]", + "port": -1 + } + ] + }, + { + "unique-id": "attendees", + "node-type": "service", + "name": "Attendees Service", + "description": "The attendees service, or a placeholder for another application", + "interfaces": [ + { + "unique-id": "attendees-image", + "image": "[[ IMAGE ]]" + }, + { + "unique-id": "attendees-port", + "port": -1 + } + ] + }, + { + "unique-id": "attendees-store", + "node-type": "database", + "name": "Attendees Store", + "description": "Persistent storage for attendees", + "interfaces": [ + { + "unique-id": "database-image", + "image": "[[ IMAGE ]]" + }, + { + "unique-id": "database-port", + "port": -1 + } + ] + }, + { + "unique-id": "k8s-cluster", + "node-type": "system", + "name": "Kubernetes Cluster", + "description": "Kubernetes Cluster with network policy rules enabled", + "controls": { + "security": { + "description": "Security requirements for the Kubernetes cluster", + "requirements": [ + { + "requirement-url": "https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json", + "$schema": "https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json", + "$id": "https://calm.finos.org/getting-started/controls/micro-segmentation.config.json", + "control-id": "security-001", + "name": "Micro-segmentation of Kubernetes Cluster", + "description": "Micro-segmentation in place to prevent lateral movement outside of permitted flows", + "permit-ingress": true, + "permit-egress": false + } + ] + } + } + } + ], + "relationships": [ + { + "unique-id": "conference-website-load-balancer", + "relationship-type": { + "connects": { + "source": { + "node": "conference-website" + }, + "destination": { + "node": "load-balancer" + } + } + }, + "controls": { + "security": { + "description": "Security Controls for the connection", + "requirements": [ + { + "requirement-url": "https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json", + "$schema": "https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json", + "control-id": "security-002", + "name": "Permitted Connection", + "description": "Permits a connection on a relationship specified in the architecture", + "reason": "Required to enable flow between architecture components", + "protocol": "HTTP" + } + ] + } + }, + "description": "Request attendee details", + "protocol": "HTTPS" + }, + { + "unique-id": "load-balancer-attendees", + "relationship-type": { + "connects": { + "source": { + "node": "load-balancer" + }, + "destination": { + "node": "attendees" + } + } + }, + "controls": { + "security": { + "description": "Security Controls for the connection", + "requirements": [ + { + "requirement-url": "https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json", + "$schema": "https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json", + "control-id": "security-002", + "name": "Permitted Connection", + "description": "Permits a connection on a relationship specified in the architecture", + "reason": "Required to enable flow between architecture components", + "protocol": "HTTP" + } + ] + } + }, + "description": "Forward", + "protocol": "mTLS" + }, + { + "unique-id": "attendees-attendees-store", + "relationship-type": { + "connects": { + "source": { + "node": "attendees" + }, + "destination": { + "node": "attendees-store" + } + } + }, + "controls": { + "security": { + "description": "Security Controls for the connection", + "requirements": [ + { + "requirement-url": "https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json", + "$schema": "https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json", + "control-id": "security-003", + "name": "Permitted Connection", + "description": "Permits a connection on a relationship specified in the architecture", + "reason": "Permitted to allow the connection between application and database", + "protocol": "JDBC" + } + ] + } + }, + "description": "Store or request attendee details", + "protocol": "JDBC" + }, + { + "unique-id": "deployed-in-k8s-cluster", + "relationship-type": { + "deployed-in": { + "container": "k8s-cluster", + "nodes": [ + "load-balancer", + "attendees", + "attendees-store" + ] + } + }, + "description": "Components deployed on the k8s cluster" + } + ], + "flows": [ + { + "unique-id": "flow-conference-signup", + "name": "Conference Signup Flow", + "description": "Flow for registering a user through the conference website and storing their details in the attendee database.", + "requirement-url": "", + "transitions": [ + { + "relationship-unique-id": "conference-website-load-balancer", + "sequence-number": 1, + "description": "User submits sign-up form via Conference Website to Load Balancer", + "direction": "source-to-destination" + }, + { + "relationship-unique-id": "load-balancer-attendees", + "sequence-number": 2, + "description": "Load Balancer forwards request to Attendees Service", + "direction": "source-to-destination" + }, + { + "relationship-unique-id": "attendees-attendees-store", + "sequence-number": 3, + "description": "Attendees Service stores attendee info in the Attendees Store", + "direction": "source-to-destination" + } + ] + } + ], + "metadata": { + "kubernetes": { + "namespace": "conference" + } + } +} +``` + + + +### Test Partial Document + +```json +[ + { + "unique-id": "conference-website", + "node-type": "webclient", + "name": "Conference Website", + "description": "Website to sign up for a conference", + "interfaces": [ + { + "unique-id": "conference-website-url", + "url": "[[ URL ]]" + } + ] + }, + { + "unique-id": "load-balancer", + "node-type": "network", + "name": "Load Balancer", + "description": "The attendees service, or a placeholder for another application", + "interfaces": [ + { + "unique-id": "load-balancer-host-port", + "host": "[[ HOST ]]", + "port": -1 + } + ] + }, + { + "unique-id": "attendees", + "node-type": "service", + "name": "Attendees Service", + "description": "The attendees service, or a placeholder for another application", + "interfaces": [ + { + "unique-id": "attendees-image", + "image": "[[ IMAGE ]]" + }, + { + "unique-id": "attendees-port", + "port": -1 + } + ] + }, + { + "unique-id": "attendees-store", + "node-type": "database", + "name": "Attendees Store", + "description": "Persistent storage for attendees", + "interfaces": [ + { + "unique-id": "database-image", + "image": "[[ IMAGE ]]" + }, + { + "unique-id": "database-port", + "port": -1 + } + ] + }, + { + "unique-id": "k8s-cluster", + "node-type": "system", + "name": "Kubernetes Cluster", + "description": "Kubernetes Cluster with network policy rules enabled", + "controls": { + "security": { + "description": "Security requirements for the Kubernetes cluster", + "requirements": [ + { + "requirement-url": "https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json", + "$schema": "https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json", + "$id": "https://calm.finos.org/getting-started/controls/micro-segmentation.config.json", + "control-id": "security-001", + "name": "Micro-segmentation of Kubernetes Cluster", + "description": "Micro-segmentation in place to prevent lateral movement outside of permitted flows", + "permit-ingress": true, + "permit-egress": false + } + ] + } + } + } +] +``` \ No newline at end of file diff --git a/cli/test_fixtures/template/expected-output/widget-tests/list-test.md b/cli/test_fixtures/template/expected-output/widget-tests/list-test.md new file mode 100644 index 000000000..f8265e70a --- /dev/null +++ b/cli/test_fixtures/template/expected-output/widget-tests/list-test.md @@ -0,0 +1,61 @@ +### Nodes (Bullet) + +
    +
  • + unique-id: conference-website, node-type: webclient, name: Conference Website, description: Website to sign up for a conference, interfaces: [object Object] +
  • +
  • + unique-id: load-balancer, node-type: network, name: Load Balancer, description: The attendees service, or a placeholder for another application, interfaces: [object Object] +
  • +
  • + unique-id: attendees, node-type: service, name: Attendees Service, description: The attendees service, or a placeholder for another application, interfaces: [object Object],[object Object] +
  • +
  • + unique-id: attendees-store, node-type: database, name: Attendees Store, description: Persistent storage for attendees, interfaces: [object Object],[object Object] +
  • +
  • + unique-id: k8s-cluster, node-type: system, name: Kubernetes Cluster, description: Kubernetes Cluster with network policy rules enabled, controls: [object Object] +
  • +
+ + +### Nodes (Ordered) + +
    +
  1. + unique-id: conference-website, node-type: webclient, name: Conference Website, description: Website to sign up for a conference, interfaces: [object Object] +
  2. +
  3. + unique-id: load-balancer, node-type: network, name: Load Balancer, description: The attendees service, or a placeholder for another application, interfaces: [object Object] +
  4. +
  5. + unique-id: attendees, node-type: service, name: Attendees Service, description: The attendees service, or a placeholder for another application, interfaces: [object Object],[object Object] +
  6. +
  7. + unique-id: attendees-store, node-type: database, name: Attendees Store, description: Persistent storage for attendees, interfaces: [object Object],[object Object] +
  8. +
  9. + unique-id: k8s-cluster, node-type: system, name: Kubernetes Cluster, description: Kubernetes Cluster with network policy rules enabled, controls: [object Object] +
  10. +
+ + +### Nodes With Property (Ordered) + +
    +
  1. + Conference Website +
  2. +
  3. + Load Balancer +
  4. +
  5. + Attendees Service +
  6. +
  7. + Attendees Store +
  8. +
  9. + Kubernetes Cluster +
  10. +
\ No newline at end of file diff --git a/cli/test_fixtures/template/expected-output/widget-tests/sad-test.md b/cli/test_fixtures/template/expected-output/widget-tests/sad-test.md new file mode 100644 index 000000000..c58cf918d --- /dev/null +++ b/cli/test_fixtures/template/expected-output/widget-tests/sad-test.md @@ -0,0 +1,1046 @@ +# Solution Architecture Summary + +## ๐Ÿ“„ Overview + +
+ + + + + + + + + + + + + +
KeyValue
Kubernetes +
+ + + + + + + + + + + +
Namespace + conference +
Unique Id + kubernetes +
+
+
+
+ +--- + +## ๐ŸŒ System Components + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Conference Website +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + conference-website +
Node Type + webclient +
Name + Conference Website +
Description + Website to sign up for a conference +
Interfaces +
+ + + + + + + + + + + +
Unique Id + conference-website-url +
Url + [[ URL ]] +
+
+
+
Load Balancer +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + load-balancer +
Node Type + network +
Name + Load Balancer +
Description + The attendees service, or a placeholder for another application +
Interfaces +
+ + + + + + + + + + + + + + + +
Unique Id + load-balancer-host-port +
Host + [[ HOST ]] +
Port + -1 +
+
+
+
Attendees +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees +
Node Type + service +
Name + Attendees Service +
Description + The attendees service, or a placeholder for another application +
Interfaces +
+ + + + + + + + + + + +
Unique Id + attendees-image +
Image + [[ IMAGE ]] +
+
+ + + + + + + + + + + +
Unique Id + attendees-port +
Port + -1 +
+
+
+
Attendees Store +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees-store +
Node Type + database +
Name + Attendees Store +
Description + Persistent storage for attendees +
Interfaces +
+ + + + + + + + + + + +
Unique Id + database-image +
Image + [[ IMAGE ]] +
+
+ + + + + + + + + + + +
Unique Id + database-port +
Port + -1 +
+
+
+
K8s Cluster +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + k8s-cluster +
Node Type + system +
Name + Kubernetes Cluster +
Description + Kubernetes Cluster with network policy rules enabled +
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security requirements for the Kubernetes cluster +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json +
$id + https://calm.finos.org/getting-started/controls/micro-segmentation.config.json +
Control Id + security-001 +
Name + Micro-segmentation of Kubernetes Cluster +
Description + Micro-segmentation in place to prevent lateral movement outside of permitted flows +
Permit Ingress + true +
Permit Egress + false +
+
+
+
+
+
+
+
+
+ +--- + +## ๐Ÿ”— Relationships + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Conference Website Load Balancer +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + conference-website-load-balancer +
Relationship Type +
+ + + + + + + +
Connects +
+ + + + + + + + + + + +
Source +
+ + + + + + + +
Node + conference-website +
+
+
Destination +
+ + + + + + + +
Node + load-balancer +
+
+
+
+
+
+
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security Controls for the connection +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
Control Id + security-002 +
Name + Permitted Connection +
Description + Permits a connection on a relationship specified in the architecture +
Reason + Required to enable flow between architecture components +
Protocol + HTTP +
+
+
+
+
+
Description + Request attendee details +
Protocol + HTTPS +
+
+
Load Balancer Attendees +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + load-balancer-attendees +
Relationship Type +
+ + + + + + + +
Connects +
+ + + + + + + + + + + +
Source +
+ + + + + + + +
Node + load-balancer +
+
+
Destination +
+ + + + + + + +
Node + attendees +
+
+
+
+
+
+
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security Controls for the connection +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
Control Id + security-002 +
Name + Permitted Connection +
Description + Permits a connection on a relationship specified in the architecture +
Reason + Required to enable flow between architecture components +
Protocol + HTTP +
+
+
+
+
+
Description + Forward +
Protocol + mTLS +
+
+
Attendees Attendees Store +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees-attendees-store +
Relationship Type +
+ + + + + + + +
Connects +
+ + + + + + + + + + + +
Source +
+ + + + + + + +
Node + attendees +
+
+
Destination +
+ + + + + + + +
Node + attendees-store +
+
+
+
+
+
+
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security Controls for the connection +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
Control Id + security-003 +
Name + Permitted Connection +
Description + Permits a connection on a relationship specified in the architecture +
Reason + Permitted to allow the connection between application and database +
Protocol + JDBC +
+
+
+
+
+
Description + Store or request attendee details +
Protocol + JDBC +
+
+
Deployed In K8s Cluster +
+ + + + + + + + + + + + + + + +
Unique Id + deployed-in-k8s-cluster +
Relationship Type +
+ + + + + + + +
Deployed In +
+ + + + + + + + + + + +
Container + k8s-cluster +
Nodes + load-balancer + attendees + attendees-store +
+
+
+
+
Description + Components deployed on the k8s cluster +
+
+
+
+ +--- + +## ๐Ÿ” Flow: Conference Signup +
    +
  1. + relationship-unique-id: conference-website-load-balancer, sequence-number: 1, description: User submits sign-up form via Conference Website to Load Balancer, direction: source-to-destination +
  2. +
  3. + relationship-unique-id: load-balancer-attendees, sequence-number: 2, description: Load Balancer forwards request to Attendees Service, direction: source-to-destination +
  4. +
  5. + relationship-unique-id: attendees-attendees-store, sequence-number: 3, description: Attendees Service stores attendee info in the Attendees Store, direction: source-to-destination +
  6. +
+ + +--- + +
    +
  1. + load-balancer +
  2. +
  3. + attendees +
  4. +
  5. + attendees-store +
  6. +
\ No newline at end of file diff --git a/cli/test_fixtures/template/expected-output/widget-tests/table-test.md b/cli/test_fixtures/template/expected-output/widget-tests/table-test.md new file mode 100644 index 000000000..e422440af --- /dev/null +++ b/cli/test_fixtures/template/expected-output/widget-tests/table-test.md @@ -0,0 +1,1136 @@ +### Table of Nodes (Flat) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Node TypeDescription
webclientWebsite to sign up for a conference
networkThe attendees service, or a placeholder for another application
serviceThe attendees service, or a placeholder for another application
databasePersistent storage for attendees
systemKubernetes Cluster with network policy rules enabled
+
+ +### Table of Nodes (Nested) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Conference Website +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + conference-website +
Node Type + webclient +
Name + Conference Website +
Description + Website to sign up for a conference +
Interfaces +
+ + + + + + + + + + + +
Unique Id + conference-website-url +
Url + [[ URL ]] +
+
+
+
Load Balancer +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + load-balancer +
Node Type + network +
Name + Load Balancer +
Description + The attendees service, or a placeholder for another application +
Interfaces +
+ + + + + + + + + + + + + + + +
Unique Id + load-balancer-host-port +
Host + [[ HOST ]] +
Port + -1 +
+
+
+
Attendees +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees +
Node Type + service +
Name + Attendees Service +
Description + The attendees service, or a placeholder for another application +
Interfaces +
+ + + + + + + + + + + +
Unique Id + attendees-image +
Image + [[ IMAGE ]] +
+
+ + + + + + + + + + + +
Unique Id + attendees-port +
Port + -1 +
+
+
+
Attendees Store +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees-store +
Node Type + database +
Name + Attendees Store +
Description + Persistent storage for attendees +
Interfaces +
+ + + + + + + + + + + +
Unique Id + database-image +
Image + [[ IMAGE ]] +
+
+ + + + + + + + + + + +
Unique Id + database-port +
Port + -1 +
+
+
+
K8s Cluster +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + k8s-cluster +
Node Type + system +
Name + Kubernetes Cluster +
Description + Kubernetes Cluster with network policy rules enabled +
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security requirements for the Kubernetes cluster +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/micro-segmentation.requirement.json +
$id + https://calm.finos.org/getting-started/controls/micro-segmentation.config.json +
Control Id + security-001 +
Name + Micro-segmentation of Kubernetes Cluster +
Description + Micro-segmentation in place to prevent lateral movement outside of permitted flows +
Permit Ingress + true +
Permit Egress + false +
+
+
+
+
+
+
+
+
+ +### Table of Relationships + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Conference Website Load Balancer +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + conference-website-load-balancer +
Relationship Type +
+ + + + + + + +
Connects +
+ + + + + + + + + + + +
Source +
+ + + + + + + +
Node + conference-website +
+
+
Destination +
+ + + + + + + +
Node + load-balancer +
+
+
+
+
+
+
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security Controls for the connection +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
Control Id + security-002 +
Name + Permitted Connection +
Description + Permits a connection on a relationship specified in the architecture +
Reason + Required to enable flow between architecture components +
Protocol + HTTP +
+
+
+
+
+
Description + Request attendee details +
Protocol + HTTPS +
+
+
Load Balancer Attendees +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + load-balancer-attendees +
Relationship Type +
+ + + + + + + +
Connects +
+ + + + + + + + + + + +
Source +
+ + + + + + + +
Node + load-balancer +
+
+
Destination +
+ + + + + + + +
Node + attendees +
+
+
+
+
+
+
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security Controls for the connection +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
Control Id + security-002 +
Name + Permitted Connection +
Description + Permits a connection on a relationship specified in the architecture +
Reason + Required to enable flow between architecture components +
Protocol + HTTP +
+
+
+
+
+
Description + Forward +
Protocol + mTLS +
+
+
Attendees Attendees Store +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees-attendees-store +
Relationship Type +
+ + + + + + + +
Connects +
+ + + + + + + + + + + +
Source +
+ + + + + + + +
Node + attendees +
+
+
Destination +
+ + + + + + + +
Node + attendees-store +
+
+
+
+
+
+
Controls +
+ + + + + + + +
Security +
+ + + + + + + + + + + +
Description + Security Controls for the connection +
Requirements +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requirement Url + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
$schema + https://calm.finos.org/getting-started/controls/permitted-connection.requirement.json +
Control Id + security-003 +
Name + Permitted Connection +
Description + Permits a connection on a relationship specified in the architecture +
Reason + Permitted to allow the connection between application and database +
Protocol + JDBC +
+
+
+
+
+
Description + Store or request attendee details +
Protocol + JDBC +
+
+
Deployed In K8s Cluster +
+ + + + + + + + + + + + + + + +
Unique Id + deployed-in-k8s-cluster +
Relationship Type +
+ + + + + + + +
Deployed In +
+ + + + + + + + + + + +
Container + k8s-cluster +
Nodes + load-balancer + attendees + attendees-store +
+
+
+
+
Description + Components deployed on the k8s cluster +
+
+
+
+ +### Table of `Nodes of Type Service` + +
+ + + + + + + + + + + + + +
KeyValue
Attendees +
+ + + + + + + + + + + + + + + + + + + + + + + +
Unique Id + attendees +
Node Type + service +
Name + Attendees Service +
Description + The attendees service, or a placeholder for another application +
Interfaces +
+ + + + + + + + + + + +
Unique Id + attendees-image +
Image + [[ IMAGE ]] +
+
+ + + + + + + + + + + +
Unique Id + attendees-port +
Port + -1 +
+
+
+
+
+ +### Table of Controls off of node with unique-id "k8s-cluster" + +
+ + + + + + + + + + + + + + + +
Control IdNameDescription
security-001Micro-segmentation of Kubernetes ClusterMicro-segmentation in place to prevent lateral movement outside of permitted flows
+
+ +### Table with post filtering + +
+ + + + + + + + + + + + + +
Node TypeDescription
databasePersistent storage for attendees
+
\ No newline at end of file diff --git a/cli/test_fixtures/template/widget-tests/json-viewer-test.hbs b/cli/test_fixtures/template/widget-tests/json-viewer-test.hbs new file mode 100644 index 000000000..dc8280ae7 --- /dev/null +++ b/cli/test_fixtures/template/widget-tests/json-viewer-test.hbs @@ -0,0 +1,7 @@ +### Test Full Document + +{{json-viewer this}} + +### Test Partial Document + +{{json-viewer nodes}} \ No newline at end of file diff --git a/cli/test_fixtures/template/widget-tests/list-test.hbs b/cli/test_fixtures/template/widget-tests/list-test.hbs new file mode 100644 index 000000000..863afa729 --- /dev/null +++ b/cli/test_fixtures/template/widget-tests/list-test.hbs @@ -0,0 +1,11 @@ +### Nodes (Bullet) + +{{list nodes }} + +### Nodes (Ordered) + +{{list nodes ordered="true"}} + +### Nodes With Property (Ordered) + +{{list nodes ordered="true" property="name"}} \ No newline at end of file diff --git a/cli/test_fixtures/template/widget-tests/sad-test.hbs b/cli/test_fixtures/template/widget-tests/sad-test.hbs new file mode 100644 index 000000000..34668aee6 --- /dev/null +++ b/cli/test_fixtures/template/widget-tests/sad-test.hbs @@ -0,0 +1,32 @@ +# Solution Architecture Summary + +## ๐Ÿ“„ Overview + +{{table metadata}} + +--- + +## ๐ŸŒ System Components + +{{table nodes + key="unique-id" + headers=true +}} + +--- + +## ๐Ÿ”— Relationships + +{{table relationships + key="unique-id" + headers=true +}} + +--- + +## ๐Ÿ” Flow: Conference Signup +{{list flows['flow-conference-signup'].transitions ordered="true"}} + +--- + +{{list relationships['deployed-in-k8s-cluster']['relationship-type']['deployed-in'].nodes ordered="true"}} diff --git a/cli/test_fixtures/template/widget-tests/table-test.hbs b/cli/test_fixtures/template/widget-tests/table-test.hbs new file mode 100644 index 000000000..d0888fc4e --- /dev/null +++ b/cli/test_fixtures/template/widget-tests/table-test.hbs @@ -0,0 +1,23 @@ +### Table of Nodes (Flat) + +{{table nodes columns="node-type, description" }} + +### Table of Nodes (Nested) + +{{table nodes key="unique-id"}} + +### Table of Relationships + +{{table relationships key="unique-id" header="false"}} + +### Table of `Nodes of Type Service` + +{{table nodes[node-type=="service"]}} + +### Table of Controls off of node with unique-id "k8s-cluster" + +{{table nodes["k8s-cluster"].controls.security.requirements columns="control-id,name,description"}} + +### Table with post filtering + +{{table nodes key="unique-id" columns="node-type, description" filter="node-type=='database'"}} \ No newline at end of file diff --git a/cli/tsup.config.ts b/cli/tsup.config.ts index 3e2c865b2..52abf1b4d 100644 --- a/cli/tsup.config.ts +++ b/cli/tsup.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ sourcemap: false, clean: true, external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node'], - noExternal: ['@finos/calm-shared', /tsup/], + noExternal: ['@finos/calm-shared', '@finos/calm-widgets', /tsup/], bundle: true, splitting: false, minify: false, diff --git a/package-lock.json b/package-lock.json index 3c1326d84..27838c2f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "calm-hub-ui" ], "dependencies": { - "@finos/calm-shared": "^0.2.2" + "@finos/calm-shared": "^0.2.2", + "@finos/calm-widgets": "^1.0.0" }, "devDependencies": { "@vitest/coverage-v8": "^3.1.1", @@ -157,6 +158,33 @@ } } }, + "calm-widgets": { + "name": "@finos/calm-widgets", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "handlebars": "^4.7.8", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@stoplight/types": "^14.1.1", + "@types/handlebars": "^4.1.0", + "@types/js-yaml": "^4.0.9", + "@types/json-pointer": "^1.0.34", + "@types/junit-report-builder": "^3.0.2", + "@types/lodash": "^4.17.16", + "@types/node": "^22.15.0", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "axios-mock-adapter": "^2.1.0", + "eslint": "^9.24.0", + "fetch-mock": "^12.5.2", + "globals": "^16.0.0", + "memfs": "^4.17.0", + "msw": "^2.7.3", + "typescript": "^5.8.3" + } + }, "calm/getting-started/website": { "name": "arch-docs", "version": "1.0.0", @@ -6261,6 +6289,10 @@ "resolved": "shared", "link": true }, + "node_modules/@finos/calm-widgets": { + "resolved": "calm-widgets", + "link": true + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -9341,6 +9373,17 @@ "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==", "license": "MIT" }, + "node_modules/@types/handlebars": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.1.0.tgz", + "integrity": "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==", + "deprecated": "This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "handlebars": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", diff --git a/package.json b/package.json index 8e8eda5c3..99870a2f1 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "workspaces": [ + "calm-widgets", "shared", "cli", "docs", @@ -10,17 +11,19 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", - "build:cli": "npm run build --workspace shared --workspace cli", - "build:shared": "npm run build --workspace shared", + "build:cli": "npm run build --workspace calm-widgets --workspace shared --workspace cli", + "build:shared": "npm run build --workspace calm-widgets --workspace shared", "build:docs": "npm run build --workspace docs", "test": "npm run test --workspaces --if-present", "test:cli": "npm run build:cli && npm run test --workspace cli", "test:shared": "npm run build:shared && npm run test --workspace shared", + "test:calm-widgets": "npm run build:calm-widgets && npm run test --workspace calm-widgets", "lint": "npm run lint --workspaces --if-present", "lint-fix": "npm run lint-fix --workspaces --if-present", "watch": "run-p watch:cli watch:shared", "watch:cli": "npm run watch --workspace cli", "watch:shared": "npm run watch --workspace shared", + "watch:calm-widgets": "npm run watch --workspace calm-widgets", "link:cli": "npm link --workspace cli", "calm-hub-ui:run": "npm run start --workspace calm-hub-ui" }, @@ -35,6 +38,7 @@ "on-headers": "^1.1.0" }, "dependencies": { - "@finos/calm-shared": "^0.2.2" + "@finos/calm-shared": "^0.2.2", + "@finos/calm-widgets": "^1.0.0" } } diff --git a/shared/src/docify/docifier.spec.ts b/shared/src/docify/docifier.spec.ts index 287bf745b..2b09c5ff2 100644 --- a/shared/src/docify/docifier.spec.ts +++ b/shared/src/docify/docifier.spec.ts @@ -35,7 +35,8 @@ describe('Docifier', () => { expect.stringContaining('template-bundles/docusaurus'), outputPath, urlToLocalPathMapping, - 'bundle' + 'bundle', + false ); expect(processTemplateMock).toHaveBeenCalled(); }); @@ -69,7 +70,8 @@ describe('Docifier', () => { customTemplatePath, outputPath, urlToLocalPathMapping, - 'template-directory' + 'template-directory', + true ); expect(processTemplateMock).toHaveBeenCalled(); @@ -89,4 +91,65 @@ describe('Docifier', () => { expect(calledInput).toBe(inputPath); expect(calledTemplatePath).toMatch(/template-bundles\/docusaurus/); }); + + describe('widget engine support', () => { + it('should enable widget engine for all modes except WEBSITE', async () => { + const processTemplateMock = vi.fn().mockResolvedValue(undefined); + MockedTemplateProcessor.mockImplementation(() => ({ + processTemplate: processTemplateMock, + })); + + // Test WEBSITE mode - should disable widget engine (supportWidgetEngine = false) + const docifierWebsite = new Docifier('WEBSITE', inputPath, outputPath, urlToLocalPathMapping); + await docifierWebsite.docify(); + + expect(MockedTemplateProcessor).toHaveBeenCalledWith( + inputPath, + expect.stringContaining('template-bundles/docusaurus'), + outputPath, + urlToLocalPathMapping, + 'bundle', + false // supportWidgetEngine should be false for WEBSITE mode + ); + + vi.clearAllMocks(); + + // Test USER_PROVIDED mode - should enable widget engine (supportWidgetEngine = true) + // Need to provide templatePath for USER_PROVIDED mode + const customTemplatePath = '/custom/template/path'; + const docifierUserProvided = new Docifier('USER_PROVIDED', inputPath, outputPath, urlToLocalPathMapping, 'bundle', customTemplatePath); + await docifierUserProvided.docify(); + + expect(MockedTemplateProcessor).toHaveBeenCalledWith( + inputPath, + customTemplatePath, + outputPath, + urlToLocalPathMapping, + 'bundle', + true // supportWidgetEngine should be true for USER_PROVIDED mode + ); + }); + + it('should include TODO comment logic for widget engine decision', async () => { + const processTemplateMock = vi.fn().mockResolvedValue(undefined); + MockedTemplateProcessor.mockImplementation(() => ({ + processTemplate: processTemplateMock, + })); + + // This test verifies the logic: supportWidgetEngine = mode !== 'WEBSITE' + const docifier = new Docifier('WEBSITE', inputPath, outputPath, urlToLocalPathMapping); + await docifier.docify(); + + // For WEBSITE mode, supportWidgetEngine should be false due to the comment: + // "TODO: need to move docifier and graphing package to widget framework. Until then widgets will clash" + expect(MockedTemplateProcessor).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(Map), + expect.any(String), + false // This reflects the TODO comment about widget clashing + ); + }); + }); }); diff --git a/shared/src/docify/docifier.ts b/shared/src/docify/docifier.ts index 24dd2db87..3bcbf776e 100644 --- a/shared/src/docify/docifier.ts +++ b/shared/src/docify/docifier.ts @@ -30,12 +30,16 @@ export class Docifier { const finalTemplatePath = templatePath ?? Docifier.TEMPLATE_BUNDLE_PATHS[mode]; + //TODO: need to move docifier and graphing package to widget framework. Until then widgets will clash + const supportWidgetEngine = mode !== 'WEBSITE'; + this.templateProcessor = new TemplateProcessor( inputPath, finalTemplatePath, outputPath, urlToLocalPathMapping, - templateProcessingMode + templateProcessingMode, + supportWidgetEngine ); } diff --git a/shared/src/docify/template-bundles/docusaurus/c4-container.hbs b/shared/src/docify/template-bundles/docusaurus/c4-container.hbs index 51189a78d..e39c73d5e 100644 --- a/shared/src/docify/template-bundles/docusaurus/c4-container.hbs +++ b/shared/src/docify/template-bundles/docusaurus/c4-container.hbs @@ -1,28 +1,28 @@ ```mermaid C4Container {{#each C4model.elements}} - {{#if (eq this.elementType "System")}} - System_Boundary("{{this.name}}","{{this.description}}"){ - {{#each this.children}} - {{#with (lookup ../../this.C4model.elements this)}} - Container({{this.uniqueId}},"{{this.name}}","","{{this.description}}") + {{#if (eq elementType "System")}} + System_Boundary("{{name}}","{{description}}"){ + {{#each children}} + {{#with (lookup ../../C4model.elements this)}} + Container({{uniqueId}},"{{name}}","","{{description}}") {{/with}} {{/each}} } {{/if}} - {{#if (eq this.elementType "Person")}} - Person({{this.uniqueId}},"{{this.name}}","{{this.description}}") + {{#if (eq elementType "Person")}} + Person({{uniqueId}},"{{name}}","{{description}}") {{/if}} - {{#if (eq this.elementType "Container")}} + {{#if (eq elementType "Container")}} {{#unless parentId}} - Container({{this.uniqueId}},"{{this.name}}","","{{this.description}}") + Container({{uniqueId}},"{{name}}","","{{description}}") {{/unless}} {{/if}} {{/each}} {{#each C4model.relationships}} - Rel({{this.source}},{{this.destination}},"{{this.relationshipType}}") + Rel({{source}},{{destination}},"{{relationshipType}}") {{/each}} UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="0") diff --git a/shared/src/docify/template-bundles/docusaurus/sidebar.js.hbs b/shared/src/docify/template-bundles/docusaurus/sidebar.js.hbs index 3c8554623..6e7260c76 100644 --- a/shared/src/docify/template-bundles/docusaurus/sidebar.js.hbs +++ b/shared/src/docify/template-bundles/docusaurus/sidebar.js.hbs @@ -11,7 +11,7 @@ module.exports = { label: 'Nodes', items: [ {{#each nodes}} - 'nodes/{{this.id}}'{{#unless @last}},{{/unless}} + 'nodes/{{id}}'{{#unless @last}},{{/unless}} {{/each}} ], }, @@ -22,7 +22,7 @@ module.exports = { label: 'Relationships', items: [ {{#each relationships}} - 'relationships/{{this.id}}'{{#unless @last}},{{/unless}} + 'relationships/{{id}}'{{#unless @last}},{{/unless}} {{/each}} ], }, @@ -33,7 +33,7 @@ module.exports = { label: 'Flows', items: [ {{#each flows}} - 'flows/{{this.id}}'{{#unless @last}},{{/unless}} + 'flows/{{id}}'{{#unless @last}},{{/unless}} {{/each}} ], } diff --git a/shared/src/template/template-default-transfomer.spec.ts b/shared/src/template/template-default-transfomer.spec.ts deleted file mode 100644 index 638fe3a07..000000000 --- a/shared/src/template/template-default-transfomer.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import TemplateDefaultTransformer from './template-default-transformer'; -import {CalmCore} from '../model/core'; - -describe('TemplateDefaultTransformer', () => { - const transformer = new TemplateDefaultTransformer(); - - const paymentGatewayJson = JSON.stringify( - { - nodes: [ - { - 'unique-id': 'payment-api', - 'name': 'Payment API', - 'description': 'Handles incoming payment requests', - 'node-type': 'service' - }, - { - 'unique-id': 'payment-db', - 'name': 'Payment Database', - 'description': 'Stores transaction records', - 'node-type': 'database' - } - ], - relationships: [ - { - 'unique-id': 'api-to-db', - 'description': 'API stores transaction data in DB', - 'relationship-type': { - connects: { - source: { - node: 'payment-api' - }, - destination: { - node: 'payment-db' - } - } - } - } - ] - } - ); - - it('should transform a Payment Gateway CALM document with two nodes and one relationship', () => { - const result = transformer.getTransformedModel(CalmCore.fromSchema(JSON.parse(paymentGatewayJson))); - - expect(result).toHaveProperty('document'); - const doc = result.document; - expect(doc.nodes).toHaveLength(2); - expect(doc.nodes[0].name).toBe('Payment API'); - expect(doc.nodes[1].name).toBe('Payment Database'); - - expect(doc.relationships).toHaveLength(1); - expect(doc.relationships[0].description).toContain('transaction'); - }); - - it('should register and execute all helpers correctly', () => { - const helpers = transformer.registerTemplateHelpers(); - - expect(helpers.eq('a', 'a')).toBe(true); - expect(helpers.eq(1, 2)).toBe(false); - expect(helpers.lookup({ foo: 'bar' }, 'foo')).toBe('bar'); - expect(helpers.json({ a: 1 })).toContain('"a": 1'); - expect(helpers.instanceOf([], 'Array')).toBe(true); - - expect(helpers.kebabToTitleCase('payment-api')).toBe('Payment Api'); - expect(helpers.kebabCase('Payment API')).toBe('payment-api'); - expect(helpers.isObject({})).toBe(true); - expect(helpers.isObject(null)).toBe(false); - expect(helpers.isArray(['x'])).toBe(true); - expect(helpers.isArray({})).toBe(false); - expect(helpers.join(['x', 'y'], '|')).toBe('x|y'); - }); - - it('should throw for invalid JSON input', () => { - const invalidJson = '{ invalid json }'; - expect(() => transformer.getTransformedModel(CalmCore.fromSchema(JSON.parse(invalidJson)))).toThrow(); - }); -}); diff --git a/shared/src/template/template-default-transformer.ts b/shared/src/template/template-default-transformer.ts index 1b15555fb..0b6619314 100644 --- a/shared/src/template/template-default-transformer.ts +++ b/shared/src/template/template-default-transformer.ts @@ -4,13 +4,17 @@ import {CalmCore} from '../model/core'; export default class TemplateDefaultTransformer implements CalmTemplateTransformer { getTransformedModel(calmCore: CalmCore) { + const canonicalModel = calmCore.toCanonicalSchema(); return { - 'document': calmCore.toCanonicalSchema(), + 'document': canonicalModel }; } registerTemplateHelpers(): Record unknown> { + // TODO: if this is the default transformer even used by docify then this will clash with widget helpers. + // Move these out in subsequent PR + return { eq: (a, b) => a === b, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/shared/src/template/template-engine.spec.ts b/shared/src/template/template-engine.spec.ts index 949c535ed..51811c511 100644 --- a/shared/src/template/template-engine.spec.ts +++ b/shared/src/template/template-engine.spec.ts @@ -4,8 +4,12 @@ import { CalmTemplateTransformer, IndexFile } from './types'; import fs from 'fs'; import path from 'path'; import { vi } from 'vitest'; +import { TemplatePreprocessor } from './template-preprocessor.js'; +import { TemplatePathExtractor } from './template-path-extractor.js'; vi.mock('fs'); +vi.mock('./template-preprocessor.js'); +vi.mock('./template-path-extractor.js'); describe('TemplateEngine', () => { let mockFileLoader: ReturnType>; @@ -27,6 +31,12 @@ describe('TemplateEngine', () => { loggerInfoSpy = vi.spyOn(TemplateEngine['logger'], 'info').mockImplementation(vi.fn()); loggerWarnSpy = vi.spyOn(TemplateEngine['logger'], 'warn').mockImplementation(vi.fn()); + + // Mock TemplatePreprocessor to return the input unchanged by default + vi.mocked(TemplatePreprocessor.preprocessTemplate).mockImplementation((input: string) => input); + + // Mock TemplatePathExtractor + vi.mocked(TemplatePathExtractor.convertFromDotNotation).mockReturnValue([]); }); afterEach(() => { @@ -242,4 +252,119 @@ describe('TemplateEngine', () => { expect(loggerInfoSpy).toHaveBeenCalledWith(expect.stringContaining('โœ… Registering partial template: header.hbs')); }); + + describe('template preprocessing', () => { + it('should preprocess templates before compilation', () => { + const templateConfig: IndexFile = { + name: 'Test Template', + transformer: 'mock-transformer', + templates: [{ template: 'main.hbs', from: 'data', output: 'output.txt', 'output-type': 'single' }], + }; + + const originalTemplate = 'User: {{name}}'; + const preprocessedTemplate = 'User: {{name}} - Preprocessed'; + + const templateFiles = { + 'main.hbs': originalTemplate, + }; + + mockFileLoader.getConfig.mockReturnValue(templateConfig); + mockFileLoader.getTemplateFiles.mockReturnValue(templateFiles); + + vi.mocked(TemplatePreprocessor.preprocessTemplate).mockReturnValue(preprocessedTemplate); + + new TemplateEngine(mockFileLoader, mockTransformer); + + expect(TemplatePreprocessor.preprocessTemplate).toHaveBeenCalledWith(originalTemplate); + expect(loggerInfoSpy).toHaveBeenCalledWith(preprocessedTemplate); + }); + }); + + describe('convertFromDotNotation helper', () => { + it('should register convertFromDotNotation helper successfully', () => { + const templateConfig: IndexFile = { + name: 'Test Template', + transformer: 'mock-transformer', + templates: [{ template: 'main.hbs', from: 'data', output: 'output.txt', 'output-type': 'single' }], + }; + + const templateFiles = { + 'main.hbs': 'User: {{convertFromDotNotation this "user.name"}}', + }; + + mockFileLoader.getConfig.mockReturnValue(templateConfig); + mockFileLoader.getTemplateFiles.mockReturnValue(templateFiles); + + vi.mocked(TemplatePreprocessor.preprocessTemplate).mockReturnValue(templateFiles['main.hbs']); + vi.mocked(TemplatePathExtractor.convertFromDotNotation).mockReturnValue(['John Doe']); + + const engine = new TemplateEngine(mockFileLoader, mockTransformer); + + // Test the helper was registered by generating output + const testData = { data: { id: 'test-id', user: { name: 'John Doe' } } }; + engine.generate(testData, './test-output'); + + expect(TemplatePathExtractor.convertFromDotNotation).toHaveBeenCalledWith( + { id: 'test-id', user: { name: 'John Doe' } }, + 'user.name', + {} + ); + }); + + it('should handle convertFromDotNotation helper errors gracefully', () => { + const templateConfig: IndexFile = { + name: 'Test Template', + transformer: 'mock-transformer', + templates: [{ template: 'main.hbs', from: 'data', output: 'output.txt', 'output-type': 'single' }], + }; + + const templateFiles = { + 'main.hbs': 'User: {{convertFromDotNotation this "invalid.path"}}', + }; + + mockFileLoader.getConfig.mockReturnValue(templateConfig); + mockFileLoader.getTemplateFiles.mockReturnValue(templateFiles); + + vi.mocked(TemplatePreprocessor.preprocessTemplate).mockReturnValue(templateFiles['main.hbs']); + vi.mocked(TemplatePathExtractor.convertFromDotNotation).mockImplementation(() => { + throw new Error('Invalid path'); + }); + + const engine = new TemplateEngine(mockFileLoader, mockTransformer); + const testData = { data: { id: 'test-id' } }; + engine.generate(testData, './test-output'); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Failed to convert from DotNotation path "invalid.path": Invalid path' + ); + }); + + it('should pass options hash to convertFromDotNotation', () => { + const templateConfig: IndexFile = { + name: 'Test Template', + transformer: 'mock-transformer', + templates: [{ template: 'main.hbs', from: 'data', output: 'output.txt', 'output-type': 'single' }], + }; + + const templateFiles = { + 'main.hbs': 'User: {{convertFromDotNotation this "user.name" option1="value1"}}', + }; + + mockFileLoader.getConfig.mockReturnValue(templateConfig); + mockFileLoader.getTemplateFiles.mockReturnValue(templateFiles); + + vi.mocked(TemplatePreprocessor.preprocessTemplate).mockReturnValue(templateFiles['main.hbs']); + vi.mocked(TemplatePathExtractor.convertFromDotNotation).mockReturnValue(['John Doe']); + + const engine = new TemplateEngine(mockFileLoader, mockTransformer); + const testData = { data: { id: 'test-id', user: { name: 'John Doe' } } }; + engine.generate(testData, './test-output'); + + expect(TemplatePathExtractor.convertFromDotNotation).toHaveBeenCalledWith( + { id: 'test-id', user: { name: 'John Doe' } }, + 'user.name', + { option1: 'value1' } + ); + }); + }); }); diff --git a/shared/src/template/template-engine.ts b/shared/src/template/template-engine.ts index 5967b2006..99ae3c10e 100644 --- a/shared/src/template/template-engine.ts +++ b/shared/src/template/template-engine.ts @@ -5,7 +5,8 @@ import {ITemplateBundleLoader} from './template-bundle-file-loader.js'; import { initLogger } from '../logger.js'; import fs from 'fs'; import path from 'path'; - +import {TemplatePathExtractor} from './template-path-extractor.js'; +import {TemplatePreprocessor} from './template-preprocessor.js'; export class TemplateEngine { private readonly templates: Record; @@ -25,7 +26,9 @@ export class TemplateEngine { const compiledTemplates: Record = {}; for (const [fileName, content] of Object.entries(templateFiles)) { - compiledTemplates[fileName] = Handlebars.compile(content); + const preprocessed = TemplatePreprocessor.preprocessTemplate(content); + logger.info(preprocessed); + compiledTemplates[fileName] = Handlebars.compile(preprocessed); } logger.info(`โœ… Compiled ${Object.keys(compiledTemplates).length} Templates`); @@ -38,6 +41,15 @@ export class TemplateEngine { const helperFunctions = this.transformer.registerTemplateHelpers(); + Handlebars.registerHelper('convertFromDotNotation', (context: unknown, path: string, options?: any) => { + try { + return TemplatePathExtractor.convertFromDotNotation(context, path, options?.hash || {}); + } catch (err) { + logger.warn(`Failed to convert from DotNotation path "${path}": ${(err as Error).message}`); + return []; + } + }); + Object.entries(helperFunctions).forEach(([name, fn]) => { Handlebars.registerHelper(name, fn); logger.info(`โœ… Registered helper: ${name}`); diff --git a/shared/src/template/template-path-extractor.spec.ts b/shared/src/template/template-path-extractor.spec.ts new file mode 100644 index 000000000..a70720ae8 --- /dev/null +++ b/shared/src/template/template-path-extractor.spec.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from 'vitest'; +import {JsonFragment, TemplatePathExtractor } from './template-path-extractor'; + +const architecture = { + $schema: 'https://calm.finos.org/workshop/account-system.pattern.json', + metadata: [{ owner: 'Platform Team' }], + nodes: [ + { + 'unique-id': 'user-interface', + name: 'User Interface', + description: 'Front-end application for user interaction', + 'node-type': 'webclient', + interfaces: [ + { + 'unique-id': 'ui-http-endpoint', + 'definition-url': 'https://calm.finos.org/interface/http-client.json', + url: 'https://account.example.com', + headers: [ + { key: 'Accept', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer token' } + ] + } + ] + }, + { + 'unique-id': 'account-system', + name: 'Account System', + description: 'System handling account logic and storage', + 'node-type': 'system', + controls: { + 'platform-hardening': { + description: 'Ensure the system applies secure platform configurations', + requirements: [ + { + 'control-requirement-url': + 'https://calm.finos.org/release/1.0-rc2/platform/platform-hardening-requirement.json', + 'os-hardening': true, + 'network-isolation': true, + 'audit-logging-enabled': true + } + ] + }, + 'data-protection': { + description: 'Ensure protection of sensitive user account data', + requirements: [ + { + 'control-requirement-url': + 'https://calm.finos.org/release/1.0-rc2/prototype/data-protection-requirement.json', + 'data-at-rest': true, + 'data-in-transit': true, + 'key-rotation-period': '90-days', + encryption: { + algorithm: 'AES', + strength: 256 + } + } + ] + } + }, + interfaces: [ + { + 'unique-id': 'account-api', + 'definition-url': 'https://calm.finos.org/interface/http-server.json', + host: 'account-system.internal', + port: 443 + } + ], + details: { + nodes: [ + { + 'unique-id': 'account-service', + name: 'Account Service', + description: 'Business logic for account operations', + 'node-type': 'service', + interfaces: [ + { + 'unique-id': 'account-service-image', + 'definition-url': 'https://calm.finos.org/interface/container-image.json', + image: 'ghcr.io/org/account-service:latest' + }, + { + 'unique-id': 'account-service-port', + 'definition-url': 'https://calm.finos.org/interface/container-port.json', + port: 8080 + } + ] + }, + { + 'unique-id': 'account-db', + name: 'Account Database', + description: 'Persistent store for account data', + 'node-type': 'database', + interfaces: [ + { + 'unique-id': 'account-db-image', + 'definition-url': 'https://calm.finos.org/interface/container-image.json', + image: 'ghcr.io/org/postgres:14' + }, + { + 'unique-id': 'account-db-port', + 'definition-url': 'https://calm.finos.org/interface/container-port.json', + port: 5432 + } + ] + } + ], + relationships: [ + { + 'unique-id': 'service-to-db', + description: 'Account Service reads/writes to Account DB', + 'relationship-type': { + connects: { + source: { node: 'account-service' }, + destination: { node: 'account-db' } + } + } + } + ] + } + } + ], + relationships: [ + { + 'unique-id': 'ui-to-account-system', + description: 'UI calls into the Account System', + 'relationship-type': { + connects: { + source: { node: 'user-interface' }, + destination: { node: 'account-system' } + } + }, + controls: { + 'transport-security': { + description: 'Ensure TLS 1.2+ encryption between frontend and backend', + requirements: [ + { + 'control-requirement-url': + 'https://calm.finos.org/release/1.0-rc2/transport/transport-security-requirement.json', + protocol: 'TLS', + 'min-version': '1.2', + 'data-in-transit': true + } + ] + } + } + } + ] +}; + +const architectureDocument = { architecture }; + +/* eslint-disable quotes */ +describe('TemplatePathExtractor', () => { + + it('gets all top-level nodes', () => { + const result = TemplatePathExtractor.convertFromDotNotation(architectureDocument, 'architecture.nodes'); + expect(Array.isArray(result)).toBe(true); + expect((result as JsonFragment[]).length).toBe(2); + }); + + it('filters nodes by node-type', () => { + const result = TemplatePathExtractor.convertFromDotNotation(architectureDocument, "architecture.nodes[node-type=='system']"); + expect(Array.isArray(result)).toBe(true); + const resultArray = result as JsonFragment[]; + expect(resultArray.length).toBe(1); + expect(resultArray[0]['unique-id']).toBe('account-system'); + }); + + it('retrieves nested details.nodes', () => { + const result = TemplatePathExtractor.convertFromDotNotation(architectureDocument, "architecture.nodes['account-system'].details.nodes"); + expect(Array.isArray(result)).toBe(true); + expect((result as JsonFragment[]).length).toBe(2); + }); + + it('filters nested nodes by node-type', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].details.nodes[node-type=='service']" + ); + expect(Array.isArray(result)).toBe(true); + const resultArray = result as JsonFragment[]; + expect(resultArray.length).toBe(1); + expect(resultArray[0]['name']).toBe('Account Service'); + }); + + it('retrieves interfaces for the UI node', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['user-interface'].interfaces" + ); + expect(Array.isArray(result)).toBe(true); + const resultArray = result as JsonFragment[]; + expect(resultArray[0]['url']).toBe('https://account.example.com'); + }); + + it('retrieves a nested interface property from the database', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].details.nodes['account-db'].interfaces[definition-url=='https://calm.finos.org/interface/container-port.json']" + ); + expect(Array.isArray(result)).toBe(true); + const resultArray = result as JsonFragment[]; + expect(resultArray[0]['port']).toBe(5432); + }); + + it('retrieves a system control requirement field', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].controls['data-protection'].requirements[0].key-rotation-period" + ); + // This is a deep path that should return the actual value, not an array + expect(result).toBe('90-days'); + }); + + it('retrieves a boolean from the controls section', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].controls['platform-hardening'].requirements[0].os-hardening" + ); + // This is a deep path that should return the actual value, not an array + expect(result).toBe(true); + }); + + it('retrieves relationship metadata from filtered relationship', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.relationships['ui-to-account-system'].controls['transport-security'].requirements[0].protocol" + ); + // This is a deep path that should return the actual value, not an array + expect(result).toBe('TLS'); + }); + + it('applies sorting to nested nodes', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].details.nodes", + { sort: 'name' } + ); + expect(Array.isArray(result)).toBe(true); + const resultArray = result as JsonFragment[]; + expect(resultArray[0]['name']).toBe('Account Database'); + expect(resultArray[1]['name']).toBe('Account Service'); + }); + + it('applies limit to query results', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].details.nodes", + { limit: 1 } + ); + expect(Array.isArray(result)).toBe(true); + expect((result as JsonFragment[]).length).toBe(1); + }); + + it('applies extra filter on node name', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].details.nodes", + { filter: { name: 'Account Database' } } + ); + expect(Array.isArray(result)).toBe(true); + const resultArray = result as JsonFragment[]; + expect(resultArray.length).toBe(1); + expect(resultArray[0]['unique-id']).toBe('account-db'); + }); + + it('retrieves nested control record field', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['account-system'].controls['data-protection'].requirements[0].encryption.strength" + ); + // This is a deep path that should return the actual value, not an array + expect(result).toBe(256); + }); + + it('retrieves nested array item from interface', () => { + const result = TemplatePathExtractor.convertFromDotNotation( + architectureDocument, + "architecture.nodes['user-interface'].interfaces['ui-http-endpoint'].headers[key=='Authorization'].value" + ); + // This is a deep path that should return the actual value, not an array + expect(result).toBe('Bearer token'); + }); + + +}); \ No newline at end of file diff --git a/shared/src/template/template-path-extractor.ts b/shared/src/template/template-path-extractor.ts new file mode 100644 index 000000000..6cb1411c5 --- /dev/null +++ b/shared/src/template/template-path-extractor.ts @@ -0,0 +1,248 @@ +import { JSONPath } from 'jsonpath-plus'; +import _ from 'lodash'; +import { initLogger } from '../logger.js'; + +export interface PathExtractionOptions { + filter?: Record; + sort?: string | string[]; + limit?: number; +} + +export type JsonFragment = string | number | boolean | null | JsonFragment[] | { [key: string]: JsonFragment }; + +/** + * Utility class to extract data from document models using path-like expressions. + * It translates custom dotted path syntax into JSONPath internally. + */ +export class TemplatePathExtractor { + private static logger = initLogger(process.env.DEBUG === 'true', TemplatePathExtractor.name); + + static convertFromDotNotation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + document: any, + path: string, + options: PathExtractionOptions = {} + ): JsonFragment { + const logger = TemplatePathExtractor.logger; + + logger.info(`Extracting path "${path}" from document with options: ${JSON.stringify(options, null, 2)}`); + + try { + // Check if we have options that need processing + const hasOptions = options.filter || options.sort || (options.limit && options.limit > 0); + + + + // Optimization: if it's a simple property name (no dots, brackets) AND no options need processing + if (this.isSimpleProperty(path) && !hasOptions) { + logger.info(`PATH: ${path}`); + logger.info('Direct property access (no JSONPath needed, no options to process)'); + const result = document[path]; + return result; + } + + // For complex paths or when options need processing, use JSONPath + const jsonPath = this.toJsonPath(path); + logger.info(`PATH: ${path}`); + logger.info(`Converted to JSONPath: ${jsonPath}`); + + let result = JSONPath({ + path: jsonPath, + json: document, + flatten: false + }); + + logger.info(`Raw JSONPath result: ${JSON.stringify(result, null, 2)}`); + + // Ensure result is always an array for consistent processing + result = Array.isArray(result) ? result : [result]; + logger.info(`After array normalization: ${JSON.stringify(result, null, 2)}`); + + // Handle empty results early + if (result.length === 0) { + logger.warn(`No results found for path: ${path} (JSONPath: ${jsonPath})`); + return []; + } + + // Check if the single result is itself an array - if so, we need special handling + const shouldReturnArrayFromPath = this.shouldReturnArray(path); + const shouldReturnArrayFromContent = result.length === 1 && Array.isArray(result[0]); + + logger.info(`shouldReturnArrayFromPath: ${shouldReturnArrayFromPath}`); + logger.info(`shouldReturnArrayFromContent: ${shouldReturnArrayFromContent}`); + + // If we have a single result that is an array, apply options to its contents + if (shouldReturnArrayFromContent) { + let arrayContents = result[0]; + logger.info(`Processing array contents (length: ${arrayContents.length})`); + + // Apply filtering to array contents + if (options.filter) { + logger.info(`Applying filter: ${JSON.stringify(options.filter)}`); + const beforeFilter = arrayContents.length; + + // If filter is a string, parse it; otherwise, use as-is + const filterObj = typeof options.filter === 'string' + ? this.parseFilter(options.filter) + : options.filter; + + arrayContents = arrayContents.filter(item => this.matchesFilter(item, filterObj!)); + logger.info(`After filtering: ${beforeFilter} -> ${arrayContents.length} items`); + } + + // Apply sorting to array contents + if (options.sort) { + const sortKeys = Array.isArray(options.sort) ? options.sort : [options.sort]; + logger.info(`Applying sort by: ${JSON.stringify(sortKeys)}`); + arrayContents = _.orderBy(arrayContents, sortKeys); + } + + // Apply limiting to array contents + if (options.limit && options.limit > 0) { + logger.info(`Applying limit: ${options.limit}`); + const beforeLimit = arrayContents.length; + arrayContents = arrayContents.slice(0, options.limit); + logger.info(`After limiting: ${beforeLimit} -> ${arrayContents.length} items`); + } + + logger.info(`Final array contents result: ${JSON.stringify(arrayContents, null, 2)}`); + return arrayContents; + } + + // For non-array content, apply filtering, sorting, and limiting normally + logger.info(`Processing non-array content (${result.length} items)`); + + if (options.filter) { + logger.info(`Applying filter: ${JSON.stringify(options.filter)}`); + const beforeFilter = result.length; + const filterObj = typeof options.filter === 'string' + ? this.parseFilter(options.filter) + : options.filter; + result = result.filter(item => this.matchesFilter(item, filterObj!)); + logger.info(`After filtering: ${beforeFilter} -> ${result.length} items`); + } + + if (options.sort) { + const sortKeys = Array.isArray(options.sort) ? options.sort : [options.sort]; + logger.info(`Applying sort by: ${JSON.stringify(sortKeys)}`); + result = _.orderBy(result, sortKeys); + } + + if (options.limit && options.limit > 0) { + logger.info(`Applying limit: ${options.limit}`); + const beforeLimit = result.length; + result = result.slice(0, options.limit); + logger.info(`After limiting: ${beforeLimit} -> ${result.length} items`); + } + + // Decide whether to return single object or array based on the path type + if (shouldReturnArrayFromPath || result.length !== 1) { + logger.info(`Returning array result (length: ${result.length})`); + logger.info(`Final result: ${JSON.stringify(result, null, 2)}`); + return result; + } + + logger.info('Returning single result'); + logger.info(`Final result: ${JSON.stringify(result[0], null, 2)}`); + return result[0]; + } catch (err) { + logger.warn(`Failed to extract path "${path}": ${err.message}`); + return []; + } + } + + private static parseFilter(filter: string | undefined): Record | undefined { + if (!filter) return undefined; + const match = filter.match(/^([a-zA-Z0-9_-]+)==['"]([^'"]+)['"]$/); + if (match) { + const [, key, value] = match; + return { [key]: value }; + } + return undefined; + } + + /** + * Converts a custom dotted path with bracket filters into JSONPath syntax + */ + private static toJsonPath(input: string): string { + let path = input.trim(); + + if (!path.startsWith('$')) { + path = '$.' + path; + } + + const nonFilterable = ['controls', 'metadata']; + + const quoteIfNeeded = (key: string): string => { + // Valid JS identifier: leave as-is; otherwise, quote it + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) ? key : `'${key}'`; + }; + + // Convert [key=='value'] filters - always quote the key for JSONPath + path = path.replace( + /\[(\w[\w-]*)==['"]([^'"]+)['"]\]/g, + (_match, key, value) => `[?(@['${key}']=='${value}')]` + ); + + // Handle bracketed lookups like nodes['id'] or nodes["id"] + path = path.replace( + /(\b\w+)\[['"]([^'"]+)['"]\]/g, + (_match, parent, id) => + nonFilterable.includes(parent) + ? `${parent}['${id}']` + : `${parent}[?(@['unique-id']=='${id}')]` + ); + + // Ensure bracketed keys are quoted even in plain bracket usage: e.g. flows[flow-conference-signup] โ†’ flows['flow-conference-signup'] + path = path.replace( + /\[(?!\?@)([^[\]'"]+?)\]/g, + (_match, key) => `[${quoteIfNeeded(key)}]` + ); + + return path; + } + + + private static matchesFilter(item: JsonFragment, filter: Record): boolean { + for (const [key, expected] of Object.entries(filter)) { + const actual = _.get(item, key); + if (Array.isArray(expected)) { + if (!expected.includes(actual)) { + return false; + } + } else { + if (actual !== expected) { + return false; + } + } + } + return true; + } + + /** + * Check if a path is a simple property name (no dots, brackets, etc.) + */ + private static isSimpleProperty(path: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(path.trim()); + } + + /** + * Determine if a path should always return an array based on its structure + */ + private static shouldReturnArray(path: string): boolean { + // If path contains brackets but doesn't continue with a property access after the last bracket, + // it's likely a filter or array access that should return an array + if (path.includes('[')) { + // Check if path ends with a property access after brackets + const afterBrackets = path.substring(path.lastIndexOf(']') + 1); + if (afterBrackets && afterBrackets.match(/^\.[a-zA-Z_][a-zA-Z0-9_.-]*$/)) { + // Something comes after brackets - could be a property or collection + // Let content-based detection decide (shouldReturnArrayFromContent) + return false; + } + return true; // Ends with brackets/filters, return array + } + // If no brackets, let the content-based detection handle it + return false; + } +} \ No newline at end of file diff --git a/shared/src/template/template-preprocessor.spec.ts b/shared/src/template/template-preprocessor.spec.ts new file mode 100644 index 000000000..b4d351b56 --- /dev/null +++ b/shared/src/template/template-preprocessor.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { TemplatePreprocessor } from './template-preprocessor'; + +describe('TemplatePreprocessor', () => { + it('rewrites path with single quotes and extras', () => { + const input = '{{list nodes[\'unique-id=="Upload Service"\'] ordered="true" property="name"}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + + expect(output).toBe( + '{{list (convertFromDotNotation this "nodes[\'unique-id==\\"Upload Service\\"\']" ordered="true" property="name") ordered="true" property="name"}}' + ); + }); + + it('rewrites path with single quotes and no extras', () => { + const input = '{{list nodes[\'unique-id=="Upload Service"\']}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + + expect(output).toBe('{{list (convertFromDotNotation this "nodes[\'unique-id==\\"Upload Service\\"\']" )}}'); + }); + + it('rewrites path with double quotes already present', () => { + const input = '{{list nodes[unique-id=="Upload Service"]}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + + expect(output).toBe('{{list (convertFromDotNotation this "nodes[unique-id==\\"Upload Service\\"]" )}}'); + }); + + it('leaves non-convertFromDotNotation able paths unchanged', () => { + const input = '{{list nodes ordered="true"}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + + expect(output).toBe('{{list (convertFromDotNotation this "nodes" ordered="true") ordered="true"}}'); + }); + + it('handles multiple occurrences in the same template', () => { + const input = ` + {{list nodes['unique-id=="A"'] ordered="true"}} + {{summary nodes['unique-id=="B"']}} + `; + const output = TemplatePreprocessor.preprocessTemplate(input); + + expect(output).toContain('{{list (convertFromDotNotation this "nodes[\'unique-id==\\"A\\"\']" ordered="true") ordered="true"}}'); + expect(output).toContain('{{summary (convertFromDotNotation this "nodes[\'unique-id==\\"B\\"\']" )}}'); + }); + + it('wraps nodes[...] without helper as convertFromDotNotation', () => { + const input = '{{nodes[unique-id=="Upload Service"]}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + expect(output).toBe('{{convertFromDotNotation this "nodes[unique-id==\\"Upload Service\\"]"}}'); + }); + + it('wraps quoted nodes[...] without helper as convertFromDotNotation', () => { + const input = '{{nodes[\'unique-id=="Upload Service"\']}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + expect(output).toBe('{{convertFromDotNotation this "nodes[\'unique-id==\\"Upload Service\\"\']"}}'); + }); + + it('wraps multiple bracket filters correctly', () => { + const input = '{{relationships[\'type=="connect"\'][\'direction=="inbound"\']}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + expect(output).toBe('{{convertFromDotNotation this "relationships[\'type==\\"connect\\"\'][\'direction==\\"inbound\\"\']"}}'); + }); + + it('wraps deeply nested relationship path using bracket and dot notation', () => { + const input = '{{list relationships[\'deployed-in-k8s-cluster\'][\'relationship-type\'][\'deployed-in\'].nodes ordered="true"}}'; + const output = TemplatePreprocessor.preprocessTemplate(input); + + expect(output).toBe('{{list (convertFromDotNotation this "relationships[\'deployed-in-k8s-cluster\'][\'relationship-type\'][\'deployed-in\'].nodes" ordered="true") ordered="true"}}'); + }); + +}); diff --git a/shared/src/template/template-preprocessor.ts b/shared/src/template/template-preprocessor.ts new file mode 100644 index 000000000..e778cd9a6 --- /dev/null +++ b/shared/src/template/template-preprocessor.ts @@ -0,0 +1,39 @@ +export class TemplatePreprocessor { + static preprocessTemplate(template: string): string { + // Handlebars control structures and built-in keywords that should not be processed + const handlebarsKeywords = new Set([ + 'else', 'if', 'unless', 'each', 'with', 'lookup', 'this', + 'true', 'false', 'null', 'undefined', '@index', '@key', '@first', '@last' + ]); + + // Matches helper-based calls like: {{list nodes[...] ...}} + const helperPattern = + /{{\s*(\w+)\s+((?:\w+|\[[^\]]+\]|\.|\['[^']+'']|\.\w+)+)((?:\s+\w+="[^"]*")*)\s*}}/g; + + // Matches standalone paths like: {{nodes[...]}} + const extractablePattern = + /{{\s*((?:\w+|\[[^\]]+\]|\.|\['[^']+'']|\.\w+)+)\s*}}/g; + + // Replace helper-based matches + template = template.replace(helperPattern, (_match, helper, path, extras) => { + // Skip if the path is a Handlebars keyword + if (handlebarsKeywords.has(path.trim())) { + return _match; + } + const safePath = path.replace(/"/g, '\\"'); + //TODO: What happens if a widget has same helper function as the DotNotation helper filter + sort etc? + return `{{${helper} (convertFromDotNotation this "${safePath}" ${extras})${extras}}}`; + }); + + // Replace standalone paths, but exclude Handlebars keywords + return template.replace(extractablePattern, (_match, path) => { + // Skip if it's a Handlebars keyword or control structure + if (handlebarsKeywords.has(path.trim())) { + return _match; // Return unchanged + } + + const safePath = path.replace(/"/g, '\\"'); + return `{{convertFromDotNotation this "${safePath}"}}`; + }); + } +} diff --git a/shared/src/template/template-processor.spec.ts b/shared/src/template/template-processor.spec.ts index 0e99f7993..aa5fadf91 100644 --- a/shared/src/template/template-processor.spec.ts +++ b/shared/src/template/template-processor.spec.ts @@ -3,8 +3,10 @@ import path from 'path'; import { TemplateProcessor } from './template-processor'; import { CalmTemplateTransformer, IndexFile } from './types'; import { Mock } from 'vitest'; +import {WidgetEngine, WidgetRegistry} from '@finos/calm-widgets'; vi.mock('fs'); +vi.mock('@finos/calm-widgets'); const fakeConfig: IndexFile = { name: 'Test Bundle', @@ -89,7 +91,9 @@ describe('TemplateProcessor', () => { }); (fs.readFileSync as Mock).mockImplementation((filePath: string) => { if (filePath.includes('simple-nodes.json')) return '{"some": "data"}'; - return ''; + if (filePath.includes('./input')) return '{"test": "data"}'; + // Return valid JSON for any other file reads + return '{"mock": "data"}'; }); (fs.rmSync as Mock).mockImplementation(() => {}); (fs.mkdirSync as Mock).mockImplementation(() => {}); @@ -175,4 +179,77 @@ describe('TemplateProcessor', () => { expect(TemplateBundleFileLoader).toHaveBeenCalledWith('bundle-dir'); }); + describe('widget engine support', () => { + it('should initialize widget engine when supportWidgetEngine is true', async () => { + const mockWidgetEngine = { + registerDefaultWidgets: vi.fn() + } as unknown as WidgetEngine; + + const mockWidgetRegistry = { + register: vi.fn(), + getWidget: vi.fn(), + getAllWidgets: vi.fn() + } as unknown as WidgetRegistry; + + const { WidgetEngine, WidgetRegistry } = await import('@finos/calm-widgets'); + vi.mocked(WidgetEngine).mockImplementation(() => mockWidgetEngine); + vi.mocked(WidgetRegistry).mockImplementation(() => mockWidgetRegistry); + + const processor = new TemplateProcessor( + './input', + './template', + './output', + new Map(), + 'bundle', + true + ); + + await processor.processTemplate(); + + expect(WidgetEngine).toHaveBeenCalledWith(expect.anything(), expect.anything()); + expect(WidgetRegistry).toHaveBeenCalledWith(expect.anything()); + expect(mockWidgetEngine.registerDefaultWidgets).toHaveBeenCalled(); + }); + + it('should not initialize widget engine when supportWidgetEngine is false', async () => { + const { WidgetEngine, WidgetRegistry } = await import('@finos/calm-widgets'); + + // Clear any previous calls + vi.clearAllMocks(); + + const processor = new TemplateProcessor( + './input', + './template', + './output', + new Map(), + 'bundle', + false + ); + + await processor.processTemplate(); + + expect(WidgetEngine).not.toHaveBeenCalled(); + expect(WidgetRegistry).not.toHaveBeenCalled(); + }); + + it('should default supportWidgetEngine to false when not provided', async () => { + const { WidgetEngine, WidgetRegistry } = await import('@finos/calm-widgets'); + + // Clear any previous calls + vi.clearAllMocks(); + + const processor = new TemplateProcessor( + './input', + './template', + './output', + new Map(), + 'bundle' + ); + + await processor.processTemplate(); + + expect(WidgetEngine).not.toHaveBeenCalled(); + expect(WidgetRegistry).not.toHaveBeenCalled(); + }); + }); }); diff --git a/shared/src/template/template-processor.ts b/shared/src/template/template-processor.ts index 4c8201bf3..47221f0ea 100644 --- a/shared/src/template/template-processor.ts +++ b/shared/src/template/template-processor.ts @@ -15,6 +15,8 @@ import {pathToFileURL} from 'node:url'; import TemplateDefaultTransformer from './template-default-transformer'; import {CalmCore} from '../model/core'; import {DereferencingVisitor} from '../model-visitor/dereference-visitor'; +import { WidgetEngine, WidgetRegistry } from '@finos/calm-widgets'; +import Handlebars from 'handlebars'; export type TemplateProcessingMode = 'template' | 'template-directory' | 'bundle'; @@ -25,13 +27,15 @@ export class TemplateProcessor { private readonly urlToLocalPathMapping:Map; private readonly mode: TemplateProcessingMode; private static logger = initLogger(process.env.DEBUG === 'true', TemplateProcessor.name); + private readonly supportWidgetEngine: boolean; - constructor(inputPath: string, templateBundlePath: string, outputPath: string, urlToLocalPathMapping:Map, mode: TemplateProcessingMode = 'bundle') { + constructor(inputPath: string, templateBundlePath: string, outputPath: string, urlToLocalPathMapping:Map, mode: TemplateProcessingMode = 'bundle', supportWidgetEngine: boolean = false) { this.inputPath = inputPath; this.templateBundlePath = templateBundlePath; this.outputPath = outputPath; this.urlToLocalPathMapping = urlToLocalPathMapping; this.mode = mode; + this.supportWidgetEngine = supportWidgetEngine; } public async processTemplate(): Promise { @@ -63,6 +67,12 @@ export class TemplateProcessor { const config = loader.getConfig(); + if(this.supportWidgetEngine === true) { + //TODO: Handlebars supports local instance. Ideally to make testable we should use a local instance of Handlebars and inject dependency. + const widgetEngine = new WidgetEngine(Handlebars, new WidgetRegistry(Handlebars)); + widgetEngine.registerDefaultWidgets(); + } + try { this.cleanOutputDirectory(resolvedOutputPath); diff --git a/shared/test_fixtures/template/bundles/default-transformer/main.hbs b/shared/test_fixtures/template/bundles/default-transformer/main.hbs index 2a992e3b0..d8d7c1866 100644 --- a/shared/test_fixtures/template/bundles/default-transformer/main.hbs +++ b/shared/test_fixtures/template/bundles/default-transformer/main.hbs @@ -19,12 +19,12 @@ {{/if}} {{#if (lookup relationship-type "composed-of")}} {{#with (lookup relationship-type "composed-of") as |composed|}} -| {{../unique-id}} | composed-of | {{composed.container}} | {{join composed.nodes ", "}} | {{../description}} | +| {{../unique-id}} | composed-of | {{container}} | {{join nodes ", "}} | {{../description}} | {{/with}} {{/if}} {{#if (lookup relationship-type "deployed-in")}} {{#with (lookup relationship-type "deployed-in") as |deploy|}} -| {{../unique-id}} | deployed-in | {{deploy.container}} | {{join deploy.nodes ", "}} | {{../description}} | +| {{../unique-id}} | deployed-in | {{container}} | {{join nodes ", "}} | {{../description}} | {{/with}} {{/if}} {{/each}} diff --git a/shared/tsup.config.ts b/shared/tsup.config.ts index 08986baa2..b9ec1481e 100644 --- a/shared/tsup.config.ts +++ b/shared/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ format: ['esm'], clean: true, keepNames: true, + noExternal: ['@finos/calm-widgets', /tsup/], outDir: 'dist/template-bundles/docusaurus', target: 'node18', outExtension: () => ({ js: '.js' })