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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to `@directededges/specs-cli` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.15.1] - 2026-05-20

Patch release fixing the `scan` → `generate` round-trip. `generate` now accepts the v2 (markdown-table) manifests that `scan` has emitted since 0.15.0, restoring the documented workflow.

### Fixed

- **`generate` now accepts v2 (table) manifests** — `generate`'s source auto-detection only recognized the legacy v1 checkbox-list format (`- [`), so any manifest produced by `specs scan` in 0.15.0 failed with `Error: Unrecognized source format`, breaking the documented `scan && generate` round-trip. `generate` now detects v2 manifests via the `**Scan format version:**` header and dispatches to `ManifestParserV2`, while continuing to support v1 manifests and raw JSON files. ([#101](https://github.com/DirectedEdges/specs/issues/101))
- **Escaped pipes in component names round-trip** — `scan` escapes `|` as `\|` in manifest table cells; `ManifestParserV2` now unescapes them so names like `Toggle | On/Off` parse back to their literal form.

### Dependency updates

- No upstream dependency changes since 0.15.0. Continues to reference `@directededges/specs-schema ^0.20.0` and `@directededges/specs-from-figma ^0.18.0`.

## [0.15.0] - 2026-05-15

`scan` now drives curation from Figma's **Ready for Dev** signal and merges intelligently with prior manifests, preserving manual edits except where Figma's devStatus has changed. Introduces a new v2 manifest format with automatic migration from v1.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@directededges/specs-cli",
"version": "0.15.0",
"version": "0.15.1",
"description": "Command-line interface for Specs design system operations",
"type": "module",
"main": "./dist/index.js",
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/src/commands/GenerateCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { ProgressEvent, RestLicenseInput } from '@directededges/specs-from-
import { ConfigLoader } from '../Config/ConfigLoader.js';
import { loadFoundations } from '../utilities/loadFoundations.js';
import { ManifestParser } from '../utilities/ManifestParser.js';
import { ManifestParserV2 } from '../utilities/ManifestParserV2.js';
import { LicenseStatus } from '../utilities/LicenseStatus.js';
import { FileManifest } from '../Writers/FileManifest.js';
import { SingleFileWriter } from '../Writers/SingleFileWriter.js';
Expand Down Expand Up @@ -129,10 +130,15 @@ export const Generate = new Command('generate')
process.exit(ERROR_CODES.FILE_ERROR);
}

// Auto-detect mode by content
// Auto-detect mode by content.
// - v2 manifest: markdown table emitted by `specs scan` (declares **Scan format version:** 2)
// - v1 manifest: checkbox bullet list emitted by `specs audit`
// - JSON: raw Figma file (file mode)
const sourceContent = await fs.readFile(sourcePath, 'utf-8');
const trimmed = sourceContent.trimStart();
const isManifest = trimmed.includes('- [');
const isV2Manifest = ManifestParserV2.isV2(sourceContent);
const isV1Manifest = trimmed.includes('- [');
const isManifest = isV2Manifest || isV1Manifest;
const isJson = trimmed.startsWith('{');

if (!isManifest && !isJson) {
Expand All @@ -158,7 +164,9 @@ export const Generate = new Command('generate')
process.exit(ERROR_CODES.INVALID_ARGS);
}

const { components, metadata } = ManifestParser.parse(sourceContent);
const { components, metadata } = isV2Manifest
? ManifestParserV2.parse(sourceContent)
: ManifestParser.parse(sourceContent);

if (components.length === 0) {
console.error('Error: No components found in manifest');
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/utilities/ManifestParserV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export class ManifestParserV2 {
const [, checkbox, name, id, type, devStatus] = match;
components.push({
id,
name: name.trim(),
// `specs scan` escapes pipes in cell values (`escapeCell`: | → \|).
// Undo that here so names round-trip back to their literal form.
name: name.trim().replace(/\\\|/g, '|'),
type: type.toUpperCase() as 'COMPONENT' | 'COMPONENT_SET',
included: checkbox.toLowerCase() === 'x',
devStatus: devStatus.toUpperCase() as DevStatus
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/tests/unit/commands/GenerateCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Generate } from '../../../src/commands/GenerateCommand.js';
import { ManifestParser } from '../../../src/utilities/ManifestParser.js';
import { ManifestParserV2 } from '../../../src/utilities/ManifestParserV2.js';
import { LicenseStatus } from '../../../src/utilities/LicenseStatus.js';
import type { ComponentsData } from '@directededges/specs-from-figma';

Expand Down Expand Up @@ -275,6 +276,90 @@ describe('source auto-detection', () => {
});
});

// ============================================================================
// V1 + V2 MANIFEST DETECTION & DISPATCH (issue #101)
// ============================================================================

describe('manifest format detection (v1 + v2)', () => {
// Mirrors the detection logic in GenerateCommand.action:
// const isV2Manifest = ManifestParserV2.isV2(sourceContent);
// const isV1Manifest = trimmed.includes('- [');
// const isManifest = isV2Manifest || isV1Manifest;
// const isJson = trimmed.startsWith('{');
function detect(content: string) {
const trimmed = content.trimStart();
const isV2Manifest = ManifestParserV2.isV2(content);
const isV1Manifest = trimmed.includes('- [');
return {
isV2Manifest,
isV1Manifest,
isManifest: isV2Manifest || isV1Manifest,
isJson: trimmed.startsWith('{'),
};
}

const V2_TABLE = [
'# Component Manifest',
'',
'**Scan format version:** 2 ',
'**File:** data/specs-testing.file.json',
'',
'## Components',
'',
'| ✓ | Name | ID | Type | Dev Status |',
'|------|------|------|------|------------|',
'| [x] | DS Button | 639:11013 | COMPONENT_SET | NONE |',
].join('\n');

const V1_LIST = [
'# Component Manifest',
'',
'**File:** data/specs-testing.file.json',
'',
'- [x] DS Button (639:11013, COMPONENT_SET)',
].join('\n');

it('detects a v2 table manifest as a manifest (was the #101 regression)', () => {
const d = detect(V2_TABLE);
expect(d.isV2Manifest).toBe(true);
expect(d.isManifest).toBe(true);
expect(d.isJson).toBe(false);
});

it('still detects a v1 checkbox-list manifest as a manifest', () => {
const d = detect(V1_LIST);
expect(d.isV1Manifest).toBe(true);
expect(d.isManifest).toBe(true);
expect(d.isJson).toBe(false);
});

it('detects raw JSON as file mode, not manifest', () => {
const d = detect('{ "name": "library", "components": {} }');
expect(d.isManifest).toBe(false);
expect(d.isJson).toBe(true);
});

it('rejects unrecognized content (neither manifest nor JSON)', () => {
const d = detect('just some prose without checkboxes or a version header');
expect(d.isManifest).toBe(false);
expect(d.isJson).toBe(false);
});

it('dispatches v2 content to ManifestParserV2', () => {
const { components, metadata } = ManifestParserV2.parse(V2_TABLE);
expect(metadata.scanFormatVersion).toBe(2);
expect(components).toHaveLength(1);
expect(components[0]).toMatchObject({ id: '639:11013', name: 'DS Button', included: true });
});

it('dispatches v1 content to the legacy ManifestParser', () => {
const { components, metadata } = ManifestParser.parse(V1_LIST);
expect(metadata.file).toBe('data/specs-testing.file.json');
expect(components).toHaveLength(1);
expect(components[0]).toMatchObject({ id: '639:11013', name: 'DS Button', included: true });
});
});

// ============================================================================
// LICENSE KEY RESOLUTION (T04)
// ============================================================================
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/tests/unit/utilities/ManifestParserV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,22 @@ describe('ManifestParserV2', () => {
const { components } = ManifestParserV2.parse(empty);
expect(components).toEqual([]);
});

it('unescapes escaped pipes in names (round-trips scan escapeCell)', () => {
const fixture = [
'**Scan format version:** 2',
'',
'## Components',
'',
'| ✓ | Name | ID | Type | Dev Status |',
'|------|------|------|------|------------|',
'| [x] | Toggle \\| On/Off | 1:23 | COMPONENT_SET | NONE |',
''
].join('\n');

const { components } = ManifestParserV2.parse(fixture);
expect(components).toHaveLength(1);
expect(components[0].name).toBe('Toggle | On/Off');
expect(components[0].id).toBe('1:23');
});
});