feat: add spec command

This commit is contained in:
caele
2025-11-21 14:23:51 +01:00
parent 34a29f3e78
commit 3ea5a0d7b3
8 changed files with 600 additions and 8 deletions

View File

@@ -39,8 +39,12 @@ const tryAddCommand = (m) => {
}
};
['@nebula.js/cli-create', '@nebula.js/cli-build', '@nebula.js/cli-serve', '@nebula.js/cli-sense'].forEach(
tryAddCommand
);
[
'@nebula.js/cli-create',
'@nebula.js/cli-build',
'@nebula.js/cli-serve',
'@nebula.js/cli-sense',
'@nebula.js/cli-spec',
].forEach(tryAddCommand);
yargs.demandCommand().alias('h', 'help').wrap(Math.min(80, yargs.terminalWidth())).argv;

View File

@@ -27,7 +27,8 @@
"peerDependencies": {
"@nebula.js/cli-build": "5.0.0 - 6",
"@nebula.js/cli-sense": "5.0.0 - 6",
"@nebula.js/cli-serve": "5.0.0 - 6"
"@nebula.js/cli-serve": "5.0.0 - 6",
"@nebula.js/cli-spec": "5.0.0 - 6"
},
"dependencies": {
"@nebula.js/cli-create": "^6.1.1",

188
commands/spec/README.md Normal file
View File

@@ -0,0 +1,188 @@
# @nebula.js/cli-spec
Generate TypeScript interfaces and JSON schemas from property definitions with JSDoc @default annotations.
## Installation
This command is part of the nebula.js CLI suite and is installed as a peer dependency when you install `@nebula.js/cli`.
## Usage
### Basic Usage
Generate both JSON schema and TypeScript defaults:
```bash
nebula spec
```
This uses default settings:
- Input: `src/PropertyDef.ts`
- Output: `generated/`
- Interface: `WordCloudProperties`
### Custom Options
```bash
nebula spec -i src/MyProps.ts -o dist --interface MyProperties
```
### Generate Only Schema
```bash
nebula spec --schema-only
```
### Generate Only Defaults
```bash
nebula spec --defaults-only
```
### Custom Project Name
```bash
nebula spec --projectName my-custom-name
```
## CLI Options
| Option | Alias | Description | Default |
| ----------------- | ----- | ------------------------------------------------------- | --------------------- |
| `--config` | `-c` | Path to JavaScript config file | `nebula.config.js` |
| `--input` | `-i` | Path to TypeScript interface file | `src/PropertyDef.ts` |
| `--output` | `-o` | Output directory for generated files | `generated` |
| `--interface` | | Name of TypeScript interface to process | `WordCloudProperties` |
| `--projectName` | `-p` | Project name (reads from package.json if not specified) | |
| `--schema-only` | | Generate only JSON schema | `false` |
| `--defaults-only` | | Generate only defaults file | `false` |
## Configuration File
You can specify default options in your `nebula.config.js` file under the `spec` section:
```javascript
module.exports = {
spec: {
source: 'src/extension/PropertyDef.ts', // equivalent to --input
output: 'schema', // equivalent to --output
interface: 'MyProperties', // equivalent to --interface
projectName: 'my-project', // equivalent to --projectName
schemaOnly: false, // equivalent to --schema-only
defaultsOnly: false, // equivalent to --defaults-only
},
};
```
**Note**: The `source` property is an alias for `input` to maintain compatibility with existing configurations.
CLI options will override config file settings:
```bash
# Uses config file settings, but overrides output directory
nebula spec -o dist
```
## Input Format
Your TypeScript interface should use JSDoc @default annotations:
```typescript
export interface WordCloudProperties {
/**
* Minimum font size for words
* @default 20
*/
MinSize: number;
/**
* Color scale for word cloud
* @default ["#FEE391", "#FEC44F", "#FE9929"]
*/
ScaleColor: string[];
/**
* Enable custom color range
* @default false
*/
customRange: boolean;
}
```
## Output Files
### JSON Schema
Generated as `{project-name}-properties.schema.json`:
```json
{
"$id": "https://qlik.com/schemas/my-project-properties.schema.json",
"title": "Word Cloud Properties Schema",
"type": "object",
"properties": {
"MinSize": {
"type": "number",
"default": 20
},
"ScaleColor": {
"type": "array",
"items": { "type": "string" },
"default": ["#FEE391", "#FEC44F", "#FE9929"]
}
}
}
```
### TypeScript Defaults
Generated as `default-properties.ts`:
```typescript
export const WordCloudDefaults = {
MinSize: 20,
ScaleColor: ['#FEE391', '#FEC44F', '#FE9929'],
customRange: false,
} as const;
export function getDefaultValue<K extends keyof typeof WordCloudDefaults>(key: K): (typeof WordCloudDefaults)[K] {
return WordCloudDefaults[key];
}
```
## Integration with Build Process
Add to your package.json scripts:
```json
{
"scripts": {
"spec:generate": "nebula spec",
"spec:schema": "nebula spec --schema-only",
"spec:defaults": "nebula spec --defaults-only"
}
}
```
## Example Workflow
1. **Define Properties**: Create TypeScript interface with JSDoc @default annotations
2. **Generate Schema**: Run `nebula spec` to create JSON schema and defaults
3. **Use in Code**: Import generated defaults in your visualization code
4. **Version Control**: Commit generated files or regenerate during build
```typescript
// In your visualization code
import { WordCloudDefaults, getDefaultValue } from './generated/default-properties';
// Use defaults
const defaultSize = getDefaultValue('MinSize'); // 20
const allDefaults = WordCloudDefaults;
```
## Requirements
- TypeScript project with valid `tsconfig.json`
- Input file must exist (unless using `--defaults-only`)
- For `--defaults-only`: corresponding schema file must exist

13
commands/spec/command.js Normal file
View File

@@ -0,0 +1,13 @@
const spec = require('./lib/spec');
const initConfig = require('./lib/init-config');
module.exports = {
command: 'spec',
desc: 'Generate TypeScript interfaces and JSON schemas from property definitions',
builder(yargs) {
initConfig(yargs).argv;
},
handler(argv) {
spec(argv);
},
};

View File

@@ -0,0 +1,66 @@
/* eslint global-require: 0, no-param-reassign: 0 */
const fs = require('fs');
const defaultFilename = 'nebula.config.js';
const RX = new RegExp(`${defaultFilename.replace(/\./g, '\\.')}$`);
const options = {
config: {
type: 'string',
description: 'Path to a JavaScript config file',
default: defaultFilename,
alias: 'c',
},
input: {
type: 'string',
description: 'Path to TypeScript interface file (e.g., PropertyDef.ts)',
alias: 'i',
default: 'src/PropertyDef.ts',
},
source: {
type: 'string',
description: 'Alias for input (for config file compatibility)',
hidden: true,
},
output: {
type: 'string',
description: 'Output directory for generated files',
alias: 'o',
default: 'generated',
},
interface: {
type: 'string',
description: 'Name of the TypeScript interface to generate schema from',
default: 'ChartProperties',
},
projectName: {
type: 'string',
description: 'Project name (will be read from package.json if not specified)',
alias: 'p',
},
};
module.exports = (yargs) =>
yargs
.options(options)
.config('config', (configPath) => {
if (configPath === null) {
return {};
}
if (!fs.existsSync(configPath)) {
if (RX.test(configPath)) {
// do nothing if default filename doesn't exist
return {};
}
throw new Error(`Config ${configPath} not found`);
}
return require(configPath).spec || {};
})
.example([
['$0 spec', 'Generate schema and defaults using default settings'],
['$0 spec -i src/MyProps.ts -o dist --interface MyProperties', 'Custom input, output, and interface'],
['$0 spec --schema-only', 'Generate only JSON schema'],
['$0 spec --defaults-only', 'Generate only defaults file'],
['$0 spec -c my-config.js', 'Use custom config file'],
['$0 spec -o dist', 'Override config file output directory'],
]);

229
commands/spec/lib/spec.js Normal file
View File

@@ -0,0 +1,229 @@
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const generateSchema = async (options) => {
console.log(chalk.blue('🔄 Generating JSON Schema from TypeScript...'));
const { createGenerator } = await import('ts-json-schema-generator');
// Read package.json to get project name
const packagePath = path.join(process.cwd(), 'package.json');
if (!fs.existsSync(packagePath)) {
throw new Error('package.json not found in current directory');
}
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const projectName = options.projectName || packageJson.name.split('/').pop().replace(/\./g, '-');
// Generate schema filename and ID from project name
const schemaFileName = `${projectName}-properties.schema.json`;
const schemaId = `https://qlik.com/schemas/${projectName}-properties.schema.json`;
console.log(`📦 Project: ${packageJson.name}`);
console.log(`📄 Schema: ${schemaFileName}`);
console.log(`🆔 Schema ID: ${schemaId}`);
// Configure the schema generator to extract JSDoc @default tags
const config = {
path: options.input,
tsconfig: 'tsconfig.json',
type: options.interface,
schemaId,
expose: 'export',
topRef: true,
jsDoc: 'extended',
sortProps: true,
strictTuples: false,
encodeRefs: true,
extraTags: ['default'],
};
try {
const generator = createGenerator(config);
const schema = generator.createSchema(config.type);
schema.$id = config.schemaId;
schema.title = `${options.interface.replace(/([A-Z])/g, ' $1').trim()} Schema`;
schema.description = `Configuration schema for ${packageJson.description || packageJson.name}`;
// Write schema to output directory
const outputDir = path.join(process.cwd(), options.output);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, schemaFileName);
fs.writeFileSync(outputPath, JSON.stringify(schema, null, 2));
console.log(chalk.green(`✅ JSON Schema generated: ${outputPath}`));
console.log(`📊 Schema stats:`);
console.log(` • Properties: ${Object.keys(schema.properties || {}).length}`);
console.log(` • Definitions: ${Object.keys(schema.definitions || schema.$defs || {}).length}`);
return { schema, projectName, schemaFileName, outputPath };
} catch (error) {
console.error(chalk.red(`❌ Failed to generate schema: ${error.message}`));
throw error;
}
};
const extractSchemaInfo = (schema) => {
const mainRef = schema.$ref;
if (!mainRef) {
throw new Error('Schema does not have a main $ref');
}
const mainTypeName = mainRef.replace('#/definitions/', '');
const mainTypeDef = schema.definitions?.[mainTypeName];
if (!mainTypeDef?.properties) {
throw new Error(`Could not find ${mainTypeName} definition in schema`);
}
const defaults = {};
Object.entries(mainTypeDef.properties).forEach(([key, prop]) => {
if (prop.default !== undefined && key !== 'version') {
defaults[key] = prop.default;
}
});
return { mainTypeName, defaults };
};
const generateTypeScriptDefaults = (mainTypeName, defaults, options) => {
const baseName = mainTypeName.replace(/Properties$/, '');
const defaultsObjectName = `${baseName}Defaults`;
// Determine the relative path to PropertyDef based on output directory
const relativePath = options.output.includes('src/') ? './PropertyDef.js' : '../src/extension/PropertyDef.js';
const imports = [`import type { ${mainTypeName} } from "${relativePath}";`];
const defaultEntries = Object.entries(defaults).map(([key, value]) => {
const jsonValue = JSON.stringify(value, null, 2);
if (jsonValue.includes('\n')) {
const indentedValue = jsonValue
.split('\n')
.map((line, index) => (index === 0 ? line : ` ${line}`))
.join('\n');
return ` ${key}: ${indentedValue},`;
}
return ` ${key}: ${jsonValue},`;
});
// Read package.json to get project info for header
const packagePath = path.join(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const projectName = packageJson.name.split('/').pop().replace(/\./g, '-');
const schemaFileName = `${projectName}-properties.schema.json`;
return `/**
* Generated TypeScript defaults from JSON Schema
*
* This file is auto-generated from ${schemaFileName}
* Do not edit manually - regenerate using: nebula spec
*
* Generated at: ${new Date().toISOString()}
*/
${imports.join('\n')}
/**
* Default property values extracted from schema @default annotations
*/
const ${defaultsObjectName}: Omit<${mainTypeName}, "version"> = {
${defaultEntries.join('\n')}
};
export default ${defaultsObjectName};
`;
};
const generateDefaults = (options, schemaInfo) => {
console.log(chalk.blue('🔄 Generating TypeScript defaults from JSON Schema...'));
const { schema } = schemaInfo;
try {
// Extract schema metadata
const { mainTypeName, defaults } = extractSchemaInfo(schema);
// Generate TypeScript file
const tsContent = generateTypeScriptDefaults(mainTypeName, defaults, options);
// Write to output directory
const outputDir = path.join(process.cwd(), options.output);
const outputPath = path.join(outputDir, 'generated-default-properties.ts');
fs.writeFileSync(outputPath, tsContent);
console.log(chalk.green(`✅ TypeScript defaults generated: ${outputPath}`));
console.log(`📊 Extracted ${Object.keys(defaults).length} default values from schema`);
return outputPath;
} catch (error) {
console.error(chalk.red(`❌ Failed to generate defaults: ${error.message}`));
throw error;
}
};
module.exports = async (argv) => {
try {
console.log(chalk.cyan('🚀 Starting spec generation...'));
// Log if config was loaded
if (argv.config && argv.config !== 'nebula.config.js') {
console.log(chalk.gray(`📝 Using config: ${argv.config}`));
} else if (fs.existsSync('nebula.config.js')) {
console.log(chalk.gray('📝 Using config: nebula.config.js'));
}
const options = {
input: argv.source || argv.input, // source takes precedence (for config file compatibility)
output: argv.output,
interface: argv.interface,
projectName: argv.projectName,
schemaOnly: argv.schemaOnly,
defaultsOnly: argv.defaultsOnly,
}; // Validate input file exists (only needed if generating schema)
if (!options.defaultsOnly && !fs.existsSync(options.input)) {
throw new Error(`Input file not found: ${options.input}`);
}
let schemaInfo;
// Generate schema unless only generating defaults
if (!options.defaultsOnly) {
schemaInfo = await generateSchema(options);
}
// Generate defaults unless only generating schema
if (!options.schemaOnly) {
if (options.defaultsOnly) {
// If only generating defaults, we need to read the existing schema
const packagePath = path.join(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const projectName = options.projectName || packageJson.name.split('/').pop().replace(/\./g, '-');
const schemaFileName = `${projectName}-properties.schema.json`;
const schemaPath = path.join(process.cwd(), options.output, schemaFileName);
if (!fs.existsSync(schemaPath)) {
throw new Error(`Schema file not found: ${schemaPath}. Run without --defaults-only first.`);
}
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
schemaInfo = { schema, projectName, schemaFileName };
}
generateDefaults(options, schemaInfo);
}
console.log(chalk.green('✅ Spec generation complete!'));
} catch (error) {
console.error(chalk.red(`❌ Spec generation failed: ${error.message}`));
process.exit(1);
}
};

View File

@@ -0,0 +1,30 @@
{
"name": "@nebula.js/cli-spec",
"version": "6.1.1",
"description": "Generate TypeScript interfaces and JSON schemas from property definitions",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"main": "lib/spec.js",
"files": [
"lib",
"command.js"
],
"scripts": {
"lint": "eslint src"
},
"dependencies": {
"chalk": "4.1.2",
"fs-extra": "^11.1.1",
"ts-json-schema-generator": "2.4.0",
"typescript": ">=5.9.3",
"yargs": "17.7.2"
}
}

View File

@@ -4805,6 +4805,18 @@ __metadata:
languageName: unknown
linkType: soft
"@nebula.js/cli-spec@workspace:commands/spec":
version: 0.0.0-use.local
resolution: "@nebula.js/cli-spec@workspace:commands/spec"
dependencies:
chalk: "npm:4.1.2"
fs-extra: "npm:^11.1.1"
ts-json-schema-generator: "npm:2.4.0"
typescript: "npm:>=5.9.3"
yargs: "npm:17.7.2"
languageName: unknown
linkType: soft
"@nebula.js/cli@workspace:commands/cli":
version: 0.0.0-use.local
resolution: "@nebula.js/cli@workspace:commands/cli"
@@ -4816,6 +4828,7 @@ __metadata:
"@nebula.js/cli-build": 5.0.0 - 6
"@nebula.js/cli-sense": 5.0.0 - 6
"@nebula.js/cli-serve": 5.0.0 - 6
"@nebula.js/cli-spec": 5.0.0 - 6
bin:
nebula: lib/index.js
languageName: unknown
@@ -8830,6 +8843,13 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:^13.1.0":
version: 13.1.0
resolution: "commander@npm:13.1.0"
checksum: 10c0/7b8c5544bba704fbe84b7cab2e043df8586d5c114a4c5b607f83ae5060708940ed0b5bd5838cf8ce27539cde265c1cbd59ce3c8c6b017ed3eec8943e3a415164
languageName: node
linkType: hard
"commander@npm:^14.0.2":
version: 14.0.2
resolution: "commander@npm:14.0.2"
@@ -11884,7 +11904,7 @@ __metadata:
languageName: node
linkType: hard
"fs-extra@npm:11.3.2":
"fs-extra@npm:11.3.2, fs-extra@npm:^11.1.1":
version: 11.3.2
resolution: "fs-extra@npm:11.3.2"
dependencies:
@@ -12378,6 +12398,22 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^11.0.1":
version: 11.1.0
resolution: "glob@npm:11.1.0"
dependencies:
foreground-child: "npm:^3.3.1"
jackspeak: "npm:^4.1.1"
minimatch: "npm:^10.1.1"
minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^2.0.0"
bin:
glob: dist/esm/bin.mjs
checksum: 10c0/1ceae07f23e316a6fa74581d9a74be6e8c2e590d2f7205034dd5c0435c53f5f7b712c2be00c3b65bf0a49294a1c6f4b98cd84c7637e29453b5aa13b79f1763a2
languageName: node
linkType: hard
"glob@npm:^11.0.3":
version: 11.0.3
resolution: "glob@npm:11.0.3"
@@ -19846,6 +19882,13 @@ __metadata:
languageName: node
linkType: hard
"safe-stable-stringify@npm:^2.5.0":
version: 2.5.0
resolution: "safe-stable-stringify@npm:2.5.0"
checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49
languageName: node
linkType: hard
"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
@@ -21514,6 +21557,24 @@ __metadata:
languageName: node
linkType: hard
"ts-json-schema-generator@npm:2.4.0":
version: 2.4.0
resolution: "ts-json-schema-generator@npm:2.4.0"
dependencies:
"@types/json-schema": "npm:^7.0.15"
commander: "npm:^13.1.0"
glob: "npm:^11.0.1"
json5: "npm:^2.2.3"
normalize-path: "npm:^3.0.0"
safe-stable-stringify: "npm:^2.5.0"
tslib: "npm:^2.8.1"
typescript: "npm:^5.8.2"
bin:
ts-json-schema-generator: bin/ts-json-schema-generator.js
checksum: 10c0/b8dad83ab0a13bb938ed0b99fd0afc72dca1e35257d6609ce4c05dd08009e710b5ef4a062db0b1a82bcd43af1870df7f49229a419f414dff4e0e2541cadecc75
languageName: node
linkType: hard
"tsconfig-paths@npm:^3.15.0":
version: 3.15.0
resolution: "tsconfig-paths@npm:3.15.0"
@@ -21544,7 +21605,7 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.0.0, tslib@npm:^2.0.1":
"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.8.1":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
@@ -21840,7 +21901,7 @@ __metadata:
languageName: node
linkType: hard
"typescript@npm:>=5.9.3":
"typescript@npm:>=5.9.3, typescript@npm:^5.8.2":
version: 5.9.3
resolution: "typescript@npm:5.9.3"
bin:
@@ -21860,7 +21921,7 @@ __metadata:
languageName: node
linkType: hard
"typescript@patch:typescript@npm%3A>=5.9.3#optional!builtin<compat/typescript>":
"typescript@patch:typescript@npm%3A>=5.9.3#optional!builtin<compat/typescript>, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin<compat/typescript>":
version: 5.9.3
resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin<compat/typescript>::version=5.9.3&hash=5786d5"
bin: