Skip to content

feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io#2630

Merged
sraphaz merged 25 commits intolikec4:mainfrom
sraphaz:main
Feb 14, 2026
Merged

feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io#2630
sraphaz merged 25 commits intolikec4:mainfrom
sraphaz:main

Conversation

@sraphaz
Copy link
Copy Markdown
Collaborator

@sraphaz sraphaz commented Feb 14, 2026

feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io

Summary

Reapply of PR #2622. was reverted due to @likec4/e2e issue

This PR adds export of LikeC4 views to Draw.io (.drawio) format from the CLI (likec4 export drawio) and the Playground (right-click on diagram → DrawIO → Export view / Export all). It does not include import; import will be proposed in a separate PR.

Context for maintainers: This work was first submitted in PR #2614, which received substantial review feedback focused on clean code, structure, and maintainability. We took that feedback seriously and ran a deliberate refactor pass (Uncle Bob / Clean Code) over the DrawIO-related code. This PR re-submits the same feature set with a cleaner, refactored codebase that we believe is easier to review and maintain. We’re grateful for the earlier review and have aimed to address those concerns in this iteration.


What’s in this PR

1. Generators (@likec4/generators)

  • generate-drawio.ts — Export single or multiple views to Draw.io XML; layout, styles, round-trip comments. Refactors: computeDiagramLayout split into smaller helpers; getViewDescriptionString extracted; buildNodeCellXml / buildEdgeCellXml and constants (SOLID/DRY/KISS); exported type DrawioViewModelLike.
  • parse-drawio.ts — Round-trip comment parsing and parse-to-LikeC4 for future import. Refactors: parseDrawioToLikeC4Multi split into mergeDiagramStatesIntoMaps, buildRootsFromFqnToCell, emitMultiDiagramModel (orchestrator ~50–60 lines); buildViewBlockLines / escapeLikec4Quotes; O(n²) deduplication replaced with parsedIds Set; UserObject fullTag uses innerXml so sibling <data> is preserved.
  • Tests: generate-drawio.spec.ts, parse-drawio.spec.ts; snapshots in __snapshots__/. Decompress error assertion accepts (base64 decode|inflate|URI decode) for Node/env behavior; snapshots updated for CI.

2. CLI (@likec4/likec4)

  • export/drawio/handler.tslikec4 export drawio with --outdir/-o, --all-in-one, --roundtrip, --uncompressed, --project, --use-dot. Uses DEFAULT_DRAWIO_ALL_FILENAME from @likec4/generators (DRY). Phase comments and thin handler pattern aligned with other export commands.
  • export/png/handler.ts — Type PngExportArgs, runExportPng(args, logger); PNG export supports --outdir/-o (docs updated).
  • No likec4 import drawio in this PR (no packages/likec4/src/cli/import/).

3. Playground

  • DrawIO context menu export only: Export view…, Export all… (DrawioContextMenuProvider, DrawioContextMenuDropdown, useDrawioContextMenuActions). Uses generateDrawio / generateDrawioMulti and parseDrawioRoundtripComments. No Import menu item or file input.
  • Monaco: only “Export to DrawIO” in editor context menu.

4. Documentation

  • drawio.mdx — Export only: mapping, options, multi-diagram, troubleshooting, re-export via comment blocks.
  • cli.mdx / docker.mdx — Export to DrawIO and PNG --outdir; no Import section.

5. E2E & tests

  • e2e/tests/drawio-playground.spec.ts — DrawIO menu (Export view / Export all). Run with playwright.playground.config.ts.
  • likec4: drawio-demo-export-import.spec.ts, drawio-tutorial-export-import.spec.ts — export tests; import/round-trip tests skipped in this PR.

What’s not in this PR

  • No likec4 import drawio command.
  • No Playground “Import from DrawIO” or Monaco Import action.
  • No docs for importing from Draw.io.
  • Import/round-trip tests remain skipped until the import PR.

Refactor summary (response to PR #2614 feedback)

After the initial submission (PR #2614), we applied a structured clean-code pass:

  • Generators: Smaller, single-responsibility functions; named types (DrawioViewModelLike); constants; phase comments; DRY (e.g. DEFAULT_DRAWIO_ALL_FILENAME, shared helpers).
  • CLI: Thin handlers; args types and runExport*(args, logger) pattern for drawio/PNG/JSON; consistent error handling and JSDoc.
  • Playground: Clear separation in useDrawioContextMenuActions (fillFromLayoutedModel, fillFromViewStates, etc.); constants for fonts/sizes.
  • E2E: Shared helpers (selectors, timeouts) in e2e/helpers/.

We believe this version is in better shape for review and long-term maintenance.


Checklist

  • I have read the latest contribution guidelines.
  • My branch is synced with main (merge/rebase as appropriate).
  • Commit messages follow Conventional Commits (e.g. feat:, refactor:, test:).
  • Tests added/updated; import-related tests skipped in this branch. pnpm ci:test (Vitest) passes.
  • Documentation updated (drawio.mdx, cli.mdx, docker.mdx for export only).
  • Changesets can be added for user-facing packages if maintainers request.

Verification

  • pnpm build (filter !./apps/*), pnpm typecheck, pnpm test (or pnpm ci:test) — pass.
  • E2E drawio-playground: run with playground Playwright config when validating.

Notes for reviewers

  • Export behavior is unchanged from the original feature; changes are structural (refactors, types, constants, tests).
  • Generators: DrawioViewModelLike is the public type for view models passed to generateDrawio / generateDrawioMulti.
  • CLI: Drawio and PNG export handlers follow the same pattern as other export commands (args type + runExport* + thin handler).
  • We’re happy to address any further feedback and to add a changeset for the export feature if desired.

Reference

  • Latest related: PR #2622. was reverted due to @likec4/e2e
  • Initial submission and review context: PR #2614.
  • Original DrawIO bidirectional discussion: PR #2593. This PR is export-only; import will follow in a separate PR.

Summary by CodeRabbit

  • New Features

    • Draw.io export: --roundtrip, --all-in-one, and --uncompressed options; per-view round-trip preservation (layout, stroke, waypoints).
    • Playground context menu can supply source content enabling round-trip exports.
  • Changes

    • CLI export is export-focused; Draw.io import/CLI import command removed.
    • PNG CLI flag renamed --output → --outdir.
  • Documentation

    • New Draw.io integration guide; updated CLI docs and Playground troubleshooting.
  • Tests

    • New E2E and integration tests covering Draw.io export and playground flows.
  • Bug Fixes / Chores

    • Improved cross-platform packaging and safer language-server error handling.

sraphaz and others added 10 commits February 14, 2026 11:39
… Draw.io" (likec4#2629)

This reverts commit 654240a, reversing
changes made to fdd46ff.
…ctors/specs, parse-drawio metaObj, icons revert postpack, worker switch(true), docs JSDoc, logger debug, tsdown copyPreview, vscode utils/types, log destructure, stage-exclude it.todo, config filenames JSDoc, HoverProvider @param/@returns, json/png/drawio handlers, E2E-COVERAGE en, test skip reason)

Co-authored-by: Cursor <[email protected]>
…uild picking JSX-in-js); ignore diagram/src/**/*.js

Co-authored-by: Cursor <[email protected]>
feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io (reapply + code scanning)
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 14, 2026

🦋 Changeset detected

Latest commit: cb709db

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces comprehensive Draw.io export with round-trip support (CLI and Playground), refactors playground Drawio context menu/actions to support source-driven options, expands generators parsing/generation for multi-diagram and round-trip data, adds E2E Playwright coverage, standardizes postpack scripts via likec4ops, and updates docs, CI runners, and assorted tooling and logging enhancements.

Changes

Cohort / File(s) Summary
Draw.io generation & parsing
packages/generators/src/drawio/constants.ts, .../generate-drawio.ts, .../generate-drawio.spec.ts, .../parse-drawio.ts, .../parse-drawio.spec.ts, .../xml-utils.ts, .../index.ts
New generation/parsing pipeline with public types (DrawioViewModelLike, GenerateDrawioOptions), multi-diagram exports, round-trip data extraction (layout, stroke, edge waypoints), compression support, XML utilities, and broad spec coverage.
CLI: Draw.io export & import surface
packages/likec4/src/cli/export/drawio/handler.ts, packages/likec4/src/cli/import/drawio/handler.ts
Refactored export into runExportDrawio with flags --roundtrip, --all-in-one, --uncompressed; reads workspace source content for roundtrip options; import-from-drawio CLI removed.
Playground Draw.io UI & actions
apps/playground/src/components/drawio/DrawioContextMenu.tsx, .../DrawioContextMenuDropdown.tsx, .../DrawioContextMenuProvider.tsx, .../useDrawioContextMenuActions.ts, .../useDrawioActions.ts (deleted), .../DrawioContextMenuView.tsx (deleted), .../drawio-events.ts
Switched to actions-driven context menu: new useDrawioContextMenuActions hook, presentational DrawioContextMenuDropdown, provider wired for layoutedModelApi and optional getSourceContent for roundtrip; removed legacy view/hook files; added DRAWIO_MIME_TYPE.
Playground ⇄ Language / Layout API wiring
apps/playground/src/monaco/LanguageClientSync.tsx, apps/playground/src/monaco/MonacoEditor.tsx, apps/playground/src/monaco/index.tsx, apps/playground/src/routes/w.$workspaceId/route.tsx
Adds LayoutedModelApi exposure (getLayoutedModel, layoutViews), LanguageClientSync exposes setLayoutedModelApi, MonacoEditor forwards setter, route wires layoutedModelApi to provider for layout-driven exports.
E2E tests & Playwright config
e2e/playwright.playground.config.ts, e2e/playwright.config.ts, e2e/tests/drawio-playground.spec.ts, e2e/tests/docs-smoke.spec.ts, e2e/tests/static-navigation.spec.ts, e2e/helpers/*, e2e/package.json, e2e/E2E-COVERAGE.md
Adds Playwright playground config and tests covering Draw.io context menu (single/all export), editor integration, docs smoke and static navigation; centralizes selectors/timeouts and adds npm script test:playground.
Docs & CLI docs
apps/docs/src/content/docs/tooling/drawio.mdx, apps/docs/src/content/docs/tooling/cli.mdx, apps/docs/src/content/docs/tooling/docker.mdx, apps/playground/README.md
New Draw.io tooling docs, CLI updates documenting --roundtrip, --all-in-one, --uncompressed, rename --outdir alias handling; removed Import-from-DrawIO doc section; playground troubleshooting added.
Postpack standardization
devops/commands/postpack.ts, devops/likec4ops.ts, packages/*/package.json (many packages)
Adds likec4ops postpack command and replaces ad-hoc cp ... package.tgz postpack scripts across workspace packages.
Testing & integration utilities
packages/likec4/src/drawio-test-utils.ts, packages/likec4/src/drawio-demo-export-import.spec.ts, packages/likec4/src/drawio-tutorial-export-import.spec.ts, e2e/src/likec4-cli-export-drawio.spec.ts
New integration tests and utilities validating Draw.io XML loadability, cell counts, multi-diagram parity, and CLI export behavior.
Logging & utils
packages/likec4/src/logger.ts, packages/log/src/*
Enhanced logger API (debug, startTimer, inMillis, boxen), safe serialization via loggable, new formatters and utilities, minor sink/formatter API adjustments.
Language-server & model locator
packages/language-server/src/model/model-locator.ts, .../last-seen-artifacts.ts, .../documentation-provider.ts, .../lsp/HoverProvider.ts, .../browser/worker.ts
Adds LikeC4ModelLocator and ViewLocateResult; improved documentation provider, safer browser worker error handling, and type/doc updates.
Build / Vite / tooling tweaks
packages/diagram/vite.config.ts, packages/vscode-preview/vite.config.ts, packages/likec4/src/vite/*, packages/vscode/*
Prefer TS/TSX resolution via resolve.extensions, alias tweaks, added Logger type assertions, small utility doc additions and error path refinements.
Miscellaneous & housekeeping
.github/workflows/*, .gitignore, pnpm-workspace.yaml, vitest.config.ts, deleted BEGINNER_NONTES.md, ci scripts scripts/ci-validate-in-docker.*, assorted package.json postpack updates, small UI tweaks (NotationPanel)
CI runner bumps to ubuntu-24.04, gitignore additions, pako added to catalog, Vitest exclude updates, added Docker CI helpers, removed legacy beginner notes, and other small refactors.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Playground as Playground UI
  participant Actions as DrawioContextMenuActions
  participant LayoutAPI as LayoutedModelApi
  participant Generator as Drawio Generator
  participant FS as File/Download

  User->>Playground: right-click canvas / select Export (--roundtrip?)
  Playground->>Actions: openMenu & triggerExport(viewId, opts)
  Actions->>LayoutAPI: request layouted view(s) (getLayoutedModel / layoutViews)
  LayoutAPI-->>Actions: return DiagramView(s)
  Actions->>Generator: build viewmodel(s) + per-view GenerateDrawioOptions (include roundtrip data)
  Generator-->>Actions: generate .drawio XML (single/multi)
  Actions->>FS: trigger download/write (.drawio) via blob or file write
  FS-->>User: downloaded .drawio file available
Loading

(Note: colored rectangles not used because sequence boxes are standard here.)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • davydkov

Poem

🐰
Hopping through XML and code,
I nudge roundtrip paths to load,
Exports, tests, and postpack cheer,
Downloads land — the carrots near,
A bunny braid of logs and node.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly describes the primary feature: exporting LikeC4 views to Draw.io format, and includes scope (cli, playground, docs, generators). It directly summarizes the main change and is specific enough for context.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering summary, what's included, what's excluded, refactoring context, checklist completion, and verification notes. All required template sections are addressed with substantive content.
Docstring Coverage ✅ Passed Docstring coverage is 91.43% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
apps/playground/src/components/drawio/DrawioContextMenuDropdown.tsx (2)

23-32: Add an explicit return type for the exported component.

This keeps TSX exports aligned with the explicit-typing rule.

Suggested change
-export function DrawioContextMenuDropdown({
+export function DrawioContextMenuDropdown({
   menuPosition,
   opened,
   onClose,
   onExport,
   onExportAllViews,
   canExport,
   canExportAllViews = false,
   exporting = false,
-}: DrawioContextMenuDropdownProps) {
+}: DrawioContextMenuDropdownProps): JSX.Element {

As per coding guidelines: "TypeScript must use explicit types and avoid using any."


79-83: Disable “Export view…” while an export is in progress.

Prevents overlapping export actions when exporting is true.

Suggested change
-          disabled={!canExport}
+          disabled={!canExport || exporting}
packages/likec4/src/cli/export/drawio/handler.ts (2)

12-15: Group type-only imports before value imports.

Keeps imports aligned with the repo’s ordering rule.

Suggested change
-import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'
-import { dirname, join, relative, resolve } from 'node:path'
-import k from 'tinyrainbow'
-import type { Argv } from 'yargs'
+import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'
+import { dirname, join, relative, resolve } from 'node:path'
+import type { Argv } from 'yargs'
+import k from 'tinyrainbow'

As per coding guidelines: "Use oxlint for linting; keep imports sorted and group type-only imports first."


313-314: Add an explicit return type for drawioCmd.

Keeps the public API aligned with explicit-typing expectations.

Suggested change
-export function drawioCmd(yargs: Argv) {
+export function drawioCmd(yargs: Argv): Argv {

As per coding guidelines: "TypeScript must use explicit types and avoid using any."

apps/playground/src/components/drawio/useDrawioContextMenuActions.ts (1)

191-297: Add an explicit return type for the exported hook.

Consider introducing a named return type so the export stays explicit and easy to reuse.

Suggested change
+export type DrawioContextMenuActions = {
+  openMenu: (event: React.MouseEvent | MouseEvent) => void
+  handleExport: () => Promise<void>
+  handleExportAllViews: () => Promise<void>
+  menuPosition: { x: number; y: number }
+  opened: boolean
+  close: () => void
+  canExport: boolean
+  canExportAllViews: boolean
+  exporting: boolean
+}
+
-export function useDrawioContextMenuActions({
+export function useDrawioContextMenuActions({
   diagram,
   likec4model,
   viewStates = {},
   getSourceContent,
   getLayoutedModel,
   layoutViews,
   onExportError,
-}: UseDrawioContextMenuActionsParams) {
+}: UseDrawioContextMenuActionsParams): DrawioContextMenuActions {

As per coding guidelines: "TypeScript must use explicit types and avoid using any."

apps/playground/src/components/drawio/DrawioContextMenuProvider.tsx (1)

66-74: Add an explicit return type for the provider component.

Suggested change
-export function DrawioContextMenuProvider({
+export function DrawioContextMenuProvider({
   children,
   layoutedModelApi,
   onExportError,
-}: PropsWithChildren<{
+}: PropsWithChildren<{
   layoutedModelApi?: LayoutedModelApi | null
   /** Optional: called when export fails so UI can show toast/snackbar. */
   onExportError?: OnDrawioExportError
-}>) {
+}>): JSX.Element {

As per coding guidelines: "TypeScript must use explicit types and avoid using any."

apps/playground/src/components/drawio/DrawioContextMenu.tsx (1)

21-26: Add an explicit return type for the component.

Suggested change
-export function DrawioContextMenu({
+export function DrawioContextMenu({
   children,
   diagram,
   likec4model,
   getSourceContent,
-}: DrawioContextMenuProps) {
+}: DrawioContextMenuProps): JSX.Element {

As per coding guidelines: "TypeScript must use explicit types and avoid using any."

apps/playground/src/monaco/LanguageClientSync.tsx (1)

51-59: Add an explicit return type for the component.

Suggested change
-export function LanguageClientSync({
+export function LanguageClientSync({
   config,
   wrapper,
   setLayoutedModelApi,
-}: {
+}: {
   config: CustomWrapperConfig
   wrapper: MonacoEditorLanguageClientWrapper
   setLayoutedModelApi?: (api: LayoutedModelApi | null) => void
-}) {
+}): JSX.Element | null {

As per coding guidelines: "TypeScript must use explicit types and avoid using any."


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/config/src/filenames.ts (1)

49-61: ⚠️ Potential issue | 🟠 Major

Unsound type predicates — isLikeC4NonJsonConfig and isLikeC4Config narrow the full parameter path despite checking only the basename.

Both functions internally call basename(filename) to extract and check the leaf filename, but their type predicates narrow the original filename parameter to literal types like 'likec4.config.ts'. If a caller passes a full path like '/foo/likec4.config.ts', the function returns true and TypeScript narrows filename to the literal union, even though the runtime value remains the full path. While all current callers already extract basename() before calling these functions (avoiding the issue in practice), the type signature is misleading and could be misused.

Change both functions to return plain boolean instead of type predicates to fix the type-safety issue:

Proposed fix
-export function isLikeC4NonJsonConfig(filename: string): filename is typeof configNonJsonFilenames[number] {
+export function isLikeC4NonJsonConfig(filename: string): boolean {
   return (configNonJsonFilenames as readonly string[]).includes(basename(filename))
 }
 
-export function isLikeC4Config(filename: string): filename is typeof ConfigFilenames[number] {
+export function isLikeC4Config(filename: string): boolean {
   return isLikeC4JsonConfig(filename) || isLikeC4NonJsonConfig(filename)
 }
🤖 Fix all issues with AI agents
In @.cursor/rules/git-push-remotes.mdc:
- Around line 1-10: This file (.cursor/rules/git-push-remotes.mdc) contains
user-specific config (e.g., the mapping "fork = sraphaz/likec4" and the unsafe
"alwaysApply: true") and must be removed from the PR: delete the file from the
commit, add the .cursor/ directory to .gitignore so contributor-local settings
aren’t tracked, and replace the committed entry with guidance for users to
create their own local config (or a template in docs) rather than committing
personal remotes; ensure no other files under .cursor/ are staged before
pushing.

In `@packages/generators/package.json`:
- Line 56: Add both "pako" and "@types/pako" to the dependency catalog
referenced by pnpm-workspace.yaml and update packages/generators/package.json to
use the catalog versions instead of hardcoded ranges; locate the dependency
entries for "pako" and "@types/pako" in package.json and replace the literal
"^2.1.0"/"^2.0.3" with the workspace catalog references, and add matching
entries (with the chosen version keys) to the pnpm-workspace.yaml catalog so
they resolve consistently with other packages like "langium" and "remeda".

In `@packages/generators/src/drawio/parse-drawio.ts`:
- Around line 656-699: In computeContainerTitles, the search for a container's
title incorrectly checks v.parent === cont.parent (treating title cells as
siblings); change that check to compare against the container id (v.parent ===
cont.id) so generated title cells created with parent="${containerId}" are
detected; update the predicate used when finding best to reference cont.id and
then ensure containerIdToTitle and titleCellIds population remains the same.

In `@packages/likec4/src/drawio-demo-export-import.spec.ts`:
- Around line 46-86: The test "exported DrawIO has same number of elements and
edges per view (no extra, none missing)" (and the other workspace-loading tests
mentioned) call the expensive LikeC4.fromWorkspace and layoutedModel operations
without increasing the default Vitest timeout; update each affected it(...)
declaration to pass a timeout option (e.g., it('exported DrawIO has same ...', {
timeout: 20000 }, async () => { ... })) so the tests invoking
LikeC4.fromWorkspace and layoutedModel have a longer timeout and stop flaking on
slow CI.

In `@packages/likec4/src/logger.ts`:
- Around line 66-70: The ViteLogger type still keeps hasErrorLogged required
because Omit<ReturnType<typeof createLikeC4Logger>, 'debug'> doesn't remove it;
update the Omit to also exclude 'hasErrorLogged' so both debug and
hasErrorLogged become optional for Vite compatibility. Locate the type alias
ViteLogger and change the Omit target to remove 'debug' and 'hasErrorLogged'
from ReturnType<typeof createLikeC4Logger>, keeping the subsequent optional
debug? and hasErrorLogged? declarations intact. Ensure you reference
createLikeC4Logger, ViteLogger, hasErrorLogged and debug in your change so the
final type matches the JSDoc intent.

In `@scripts/ci-validate-in-docker.sh`:
- Around line 7-17: The script sets NODE_ENV=production before running pnpm
install which makes pnpm skip devDependencies and breaks subsequent commands
like pnpm typecheck and pnpm test:e2e:typecheck; move the export
NODE_ENV=production to after pnpm install (or call pnpm install with
--prod=false) so devDependencies are installed, and also align the pinned
Corepack pnpm version in corepack prepare (currently corepack prepare
[email protected]) to the version declared in .tool-versions (10.29.3) so versions
remain consistent.

In `@vitest.config.ts`:
- Around line 10-15: The custom exclude array currently replaces Vitest's
defaults; import and reference configDefaults and merge its exclude into your
config by changing the exclude value to spread configDefaults.exclude first and
then append your custom patterns (e.g., exclude: [...configDefaults.exclude,
'**/*.spec.js', '**/*.test.js']), and ensure configDefaults is imported from
Vitest (so update the top-level imports to include configDefaults) to preserve
defaults like node_modules, dist, cypress, and config file patterns.
🧹 Nitpick comments (22)
scripts/ci-validate-in-docker.ps1 (1)

9-9: Consider Write-Information or Write-Output instead of Write-Host.

Static analysis (PSAvoidUsingWriteHost) flags this. For a developer-facing CI script it's largely harmless, but Write-Output or Write-Information would be more robust across hosts and allow suppression/redirection.

Proposed fix
-Write-Host "==> Starting Linux container (node:22-bookworm) with workspace: $workspace"
+Write-Output "==> Starting Linux container (node:22-bookworm) with workspace: $workspace"
packages/language-server/src/documentation/documentation-provider.ts (1)

44-62: Inconsistent null-handling: parseDeploymentNode and parseDeployedInstance aren't null-checked unlike parseElement.

On line 64, parseElement has an explicit null guard, but parseDeploymentNode (line 46) and parseDeployedInstance (line 50) do not. If those parsers can return null/undefined, the resulting NPE would be silently swallowed by the catch block, returning undefined without clear context. If they can't return null, the parseElement guard may be unnecessary — either way, the inconsistency is worth aligning.

packages/language-server/src/lsp/HoverProvider.ts (1)

15-23: Consider documenting the class and constructor.

While the protected methods now have excellent documentation, the public class and constructor lack JSDoc. Adding class-level documentation would help developers understand the hover provider's role in the language server.

As per coding guidelines, public classes and methods should use JSDoc.

📝 Suggested class and constructor documentation
+/**
+ * Provides hover information for LikeC4 AST nodes in the language server.
+ * Generates markdown-formatted hover content for elements, deployment nodes, and deployed instances,
+ * including summaries, relationships, and links to views.
+ */
 export class LikeC4HoverProvider extends AstNodeHoverProvider {
   private parser: LikeC4ModelParser
   private locator: LikeC4ModelLocator
 
+  /**
+   * Creates a new LikeC4HoverProvider.
+   * `@param` services - The LikeC4 language services container providing model parser and locator.
+   */
   constructor(protected services: LikeC4Services) {
packages/language-server/src/browser/index.ts (1)

17-20: Consider adding JSDoc documentation for this public function.

The startLanguageServer function is exported and serves as a public API entry point for browser-based language server initialization. Adding JSDoc would improve maintainability by documenting the purpose, parameters, and return value. As per coding guidelines, public methods should include JSDoc documentation.

📝 Example JSDoc documentation
+/**
+ * Starts the LikeC4 language server in a browser context.
+ * 
+ * `@param` port - The message port or dedicated worker global scope for LSP communication
+ * `@returns` The initialized language services including shared and LikeC4-specific services
+ */
 export function startLanguageServer(port: MessagePort | DedicatedWorkerGlobalScope): {
   shared: LikeC4SharedServices
   likec4: LikeC4Services
 } {
scripts/ci-validate-in-docker.sh (1)

13-13: Hardcoded pnpm version may drift from packageManager in package.json.

Consider reading the version from package.json's packageManager field to keep them in sync, or add a comment noting the version must match.

e2e/src/likec4-cli-export-drawio.spec.ts (2)

46-54: Dead branch in assertion failure message.

The ternary at Line 51 checks result.exitCode === 1, but this message is only evaluated when the expect fails — which means failedByExitCode is false and result.exitCode !== 1. The true branch ('expected exitCode 1') is unreachable.

Simplify to just the descriptive else-branch message:

Proposed fix
     expect(
       failedByExitCode || failedByStderr,
-      result.exitCode === 1
-        ? 'expected exitCode 1'
-        : `expected exitCode 1 or error on stderr; got exitCode ${result.exitCode}, stderr: ${String(result.stderr).slice(0, 200)}`,
+      `expected exitCode 1 or error on stderr; got exitCode ${result.exitCode}, stderr: ${String(result.stderr).slice(0, 200)}`,
     ).toBe(true)

6-6: Global $.nothrow = false is already the default for zx.

zx throws on non-zero exit codes by default, so $.nothrow = false is a no-op. If you intend this as documentation of intent, a comment would be clearer; otherwise it can be removed.

.gitignore (2)

42-48: Broad glob patterns may have unintended side-effects.

*.spec.js and *.test.js (Lines 43-44) are root-level globs that match anywhere in the repo tree. If any package ever ships JS test fixtures or generated test files that should be tracked, they'll be silently ignored. Similarly, packages/*/src/**/*.js (Line 47) blanket-ignores all JS under package sources.

This is likely fine for a TypeScript-first repo, but worth noting the blast radius. Consider scoping these under specific directories if issues arise.


121-132: Personal workflow files in shared .gitignore.

Patterns like BEGINNER_*.md, PR-*-review.md, COMMIT_MSG.txt, .commit_msg*.txt, and pr-*-to-origin.md look like individual developer workflow artifacts. These are better placed in each developer's ~/.gitignore_global or .git/info/exclude to keep the shared .gitignore focused on project-level artifacts.

packages/likec4/src/cli/export/drawio/handler.ts (1)

205-214: Sequential per-view export keeps output deterministic — consider parallel export for large workspaces.

The sequential for loop is safe and gives predictable CLI output ordering. If throughput ever becomes an issue with many views, a bounded Promise.all could speed this up, but it's fine as-is for the current scope.

apps/playground/src/components/drawio/useDrawioContextMenuActions.ts (2)

240-272: Simplify conditional spread of onExportError.

Line 251 uses ...(onExportError != null && { onExportError }) — since CollectViewModelsOptions.onExportError is already optional (OnDrawioExportError | undefined), you can always pass it:

✏️ Suggested simplification
     const viewModels = await collectViewModelsForExportAll({
       viewIdsInModel,
       allViewModelsFromState,
       likec4model,
       viewStates,
       getLayoutedModel,
       layoutViews,
-      ...(onExportError != null && { onExportError }),
+      onExportError,
     })

200-206: useMemo for allViewModelsFromState depends on viewStates object reference.

If the parent re-creates the viewStates object on every render (e.g., from a state machine snapshot), this memo won't memoize effectively. Ensure the caller stabilizes viewStates via useMemo or a ref, or consider using a shallow-comparison hook if this becomes a performance bottleneck.

apps/playground/src/monaco/MonacoEditor.tsx (1)

80-86: Consider simplifying the conditional spread.

The conditional spread {...(setLayoutedModelApi != null && { setLayoutedModelApi })} works but is harder to read than just passing the prop directly. Since LanguageClientSync already types setLayoutedModelApi as optional, passing undefined is fine.

♻️ Simplified prop passing
       {wrapper && (
         <LanguageClientSync
           config={wrapperConfig}
           wrapper={wrapper}
-          {...(setLayoutedModelApi != null && { setLayoutedModelApi })}
+          setLayoutedModelApi={setLayoutedModelApi}
         />
       )}
apps/playground/src/monaco/LanguageClientSync.tsx (1)

112-144: Consider logging silenced errors in registerLayoutedModelApi for debuggability.

The empty catch blocks at Lines 123 and 136 silently discard errors from FetchLayoutedModel and LayoutView requests. While returning null/skipping is the right fallback, a logger.warn would help diagnose issues in the playground without surfacing them to users.

Optional: add debug-level logging
             try {
               const { model } = await c.sendRequest(FetchLayoutedModel.req, {})
               return model ?? null
-            } catch {
+            } catch (err) {
+              logger.warn(loggable(err))
               return null
             }
               try {
                 const res = await requestLayoutView(c, viewId as ViewId)
                 if (res.result?.diagram) out[viewId] = res.result.diagram
-              } catch {
+              } catch (err) {
+                logger.warn`layout view ${viewId} failed: ${loggable(err)}`
                 // skip failed view
               }
packages/generators/src/drawio/generate-drawio.spec.ts (2)

14-29: Duplicated expectDrawioXmlLoadableInDrawio helper.

This function duplicates the one in packages/likec4/src/drawio-test-utils.ts with slightly different regex patterns. The external version uses named constants (NESTED_ARRAY_IN_GEOMETRY, EDGE_GEOMETRY_SINGLE_ARRAY) while this uses inline regexes. If the validation logic evolves, they could drift apart.

Consider importing from a shared location or at least adding a comment noting the duplication.


71-73: vi.fn wrapper is unused for its mocking capabilities.

mockViewModel is wrapped in vi.fn but no spy assertions (e.g. toHaveBeenCalled, toHaveBeenCalledWith) are made on it. A plain function would be simpler.

e2e/tests/drawio-playground.spec.ts (1)

30-40: Inline import type for Download return type.

Minor nit: the inline Promise<import('@playwright/test').Download> could be a top-level type import for clarity, but it's functional as-is.

Proposed fix
-import type { Page } from '@playwright/test'
-import { expect, test } from '@playwright/test'
+import type { Download, Page } from '@playwright/test'
+import { expect, test } from '@playwright/test'

Then on line 34:

-): Promise<import('@playwright/test').Download> {
+): Promise<Download> {
packages/generators/src/drawio/generate-drawio.ts (3)

720-728: Duplicate html=1; in vertex style string.

vertexTextStyle for non-container nodes (line 723) already includes html=1;, and line 728 appends another html=1; at the end of the concatenated style. Draw.io silently deduplicates, so this is functionally harmless, but it adds unnecessary bytes to every vertex cell.

Suggested fix: remove trailing html=1;
   const styleStr =
-    `${vertexTextStyle}${shapeStyle}${colorStyle}${strokeWidthStyle}${containerDashed}${fillOpacityStyle}${navLinkStyle}${likec4Style}html=1;`
+    `${vertexTextStyle}${shapeStyle}${colorStyle}${strokeWidthStyle}${containerDashed}${fillOpacityStyle}${navLinkStyle}${likec4Style}`

Note: verify that the container branch (line 722) also includes html=1; or add it conditionally only when not already present.


188-193: getDefaultStrokeWidth ternary returns '' for non-container leaf nodes without explicit border.

return isContainer ? '1' : borderVal ? '1' : ''

When borderVal is undefined and isContainer is false, this returns '', which means no strokeWidth is set on the cell. Draw.io will then use its own default (typically 1). This seems intentional but the semantics are subtle — if the intent is "always 1 unless border=none", consider defaulting to '1' unconditionally (except for 'none').


1070-1078: Node sort comparator is not fully transitive.

The sort comparator checks a.id.startsWith(b.id + '.') but doesn't handle all transitive cases. For example, if a.id = "x.y.z" and b.id = "x.y", then a > b. But for two siblings like "x.y" and "x.z" (neither is a prefix of the other), the comparator returns 0. Combined with the parent checks, this generally works because Array.sort doesn't require strict total ordering for a valid grouping — but it could produce different orderings across JS engines for non-parent-related nodes. Since the exported XML is position-based (not order-dependent), this shouldn't cause correctness issues.

packages/generators/src/drawio/parse-drawio.ts (2)

420-444: Self-closing tag detection heuristic relies on last 10 chars.

Line 428: xml.slice(Math.max(tagStart, endOpen - 10), endOpen).trimEnd() checks the last 10 characters before > for a trailing / to detect self-closing tags like <mxCell ... />. This works for typical DrawIO XML but could theoretically fail if there's a very long attribute value ending with / right before > in a non-self-closing tag (e.g., <mxCell value="path/" >). In practice, DrawIO XML is well-formed enough that this is unlikely, but a more robust check would be xml[endOpen - 1] === '/' after trimming whitespace.


1707-1818: Round-trip comment parser is correct but has significant code duplication.

The parseDrawioRoundtripComments function has four nearly identical blocks for each comment section (layout, strokeColor, strokeWidth, waypoints) — each repeats the pattern: check start marker → accumulate lines → parse content → check end marker. This could be reduced with a small helper that handles the generic "parse block between markers" pattern.

Since the function works correctly and import is deferred, this is a good candidate for a future cleanup pass.

sraphaz and others added 7 commits February 14, 2026 12:59
- config/filenames: isLikeC4Config/isLikeC4NonJsonConfig return boolean (no unsound type predicates)
- .cursor/ gitignored, removed from tracking; AGENTS.md note for contributors
- pako + @types/pako in pnpm catalog; generators use catalog:
- parse-drawio: computeContainerTitles v.parent === cont.id; self-closing tag trim; TODO roundtrip
- drawio-demo-export-import.spec: timeout 20s for fromWorkspace/layoutedModel tests
- logger: ViteLogger Omit hasErrorLogged
- ci-validate-in-docker: NODE_ENV after install; pnpm 10.29.3; version comment
- vitest: configDefaults.exclude merged
- Nitpicks: Write-Output, null checks (documentation-provider), JSDoc (HoverProvider, browser, handler), useDrawioContextMenuActions/Monaco/LanguageClientSync simplifications, generate-drawio html=1/sort comments, generate-drawio.spec duplication note + plain mockViewModel, e2e dead branch + $.nothrow, .gitignore comments

Co-authored-by: Cursor <[email protected]>
…portError, setLayoutedModelApi)

Co-authored-by: Cursor <[email protected]>
feat: DrawIO export (CLI, Playground, round-trip) + cleanup
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@apps/playground/src/components/drawio/useDrawioContextMenuActions.ts`:
- Around line 86-103: fillFromLayoutedModel currently unconditionally overwrites
seeded entries in collectViewModelsForExportAll by calling byId.set(view.id,
toViewModel(...)), which differs from Phases 2–4; change fillFromLayoutedModel
so it only writes missing entries (if (!byId.has(view.id)) byId.set(view.id,
toViewModel(view, styles))) to preserve state-seeded view models, and ensure the
change is applied inside the fillFromLayoutedModel function where view.id and
toViewModel are used; alternatively add a concise comment there documenting that
layouted model should intentionally override if you prefer that behavior.

In `@packages/generators/src/drawio/generate-drawio.ts`:
- Around line 1227-1251: The modified timestamp passed into wrapInMxFile is
embedded into XML attributes without escaping, allowing a caller-supplied
modified string to inject XML; update wrapInMxFile to run the chosen value
through escapeXml (e.g. use escapeXml(modified ?? new Date().toISOString()))
before interpolating it into the mxfile modified="..." attributes (both the
empty-diagrams and non-empty-diagrams branches), keeping the existing
DRAWIO_DIAGRAM_ID_PREFIX and escapeXml usage for diagram names/ids consistent.

In `@packages/generators/src/drawio/parse-drawio.ts`:
- Around line 136-146: The function findOpenTagEnd only tracks double quotes and
fails for single-quoted attributes; update it to use a quoteChar (or similar)
that is set to the opening quote character (' or ") and cleared only when the
same quote is seen, so that single-quoted attributes (and nested opposite
quotes) are handled correctly; modify the logic in findOpenTagEnd (replace
inQuote boolean with quoteChar check and toggle on both "'" and '"' accordingly)
so '>' is ignored while quoteChar is non-empty.

In `@packages/likec4/src/vite/vite-dev.ts`:
- Around line 104-105: The Vite dev server config in vite-dev.ts currently sets
server.fs.deny: [] which overrides Vite's safe defaults and can expose secrets;
remove the explicit deny override (delete or stop setting server.fs.deny) so
Vite uses its built-in deny list, or if you must customize, merge with Vite's
defaults instead of assigning an empty array; locate the dev server config
object (the server.fs.allow/deny block) and either remove the deny field
entirely or implement a merge that preserves default patterns while extending
allow as needed.

In `@scripts/ci-validate-in-docker.ps1`:
- Around line 10-17: The docker run invocation in
scripts/ci-validate-in-docker.ps1 sets NODE_ENV container-wide via the flag `-e
NODE_ENV=production`, which causes pnpm to skip devDependencies; remove the `-e
NODE_ENV=production` entry from the docker run arguments so the container starts
without NODE_ENV set and let the existing scripts/ci-validate-in-docker.sh
export NODE_ENV=production at the correct point after install.
🧹 Nitpick comments (20)
pnpm-workspace.yaml (1)

19-20: @types/pako is out of alphabetical order in the catalog.

The default catalog groups @-scoped packages first (lines 9–13), sorted alphabetically, then unscoped packages. @types/pako on line 20 should be placed between @types/node and @typescript/native-preview to stay consistent.

Suggested reordering
   '@types/node': ~22.19.10
+  '@types/pako': ^2.0.3
   '@typescript/native-preview': 7.0.0-dev.20260208.1
   ...
   oxlint-tsgolint: 0.11.5
   pako: ^2.1.0
-  '@types/pako': ^2.0.3
   playwright: 1.56.1

Based on learnings: "Configure workspace packages and maintain dependency catalog in pnpm-workspace.yaml for consistency."

.gitignore (2)

39-50: Lines 39-40 are now redundant with the broader glob on Line 49.

packages/*/src/**/*.js (Line 49) already covers both packages/log/src/*.js and packages/diagram/src/**/*.js. Consider removing the specific entries to reduce noise, or keep them if you want them as explicit documentation of known offenders.

Proposed cleanup
-packages/log/src/*.js
-packages/diagram/src/**/*.js
 packages/vscode-preview/src/generated-icons.tsx
 
 # Compiled artifacts (do not commit). Root-level *.spec.js/*.test.js and packages/*/src/**/*.js

123-134: Personal workflow files belong in ~/.gitignore_global, not the shared .gitignore.

The comment on Line 123 already acknowledges this. Patterns like PR-*-review.md, COMMIT_MSG.txt, .commit_msg*.txt, BEGINNER_*.md, etc. are developer-specific workflow artifacts. Moving them to each contributor's global gitignore keeps the shared file focused on project build/test artifacts.

e2e/src/likec4-cli-export-drawio.spec.ts (2)

40-42: Add cleanup before creating emptyWorkspaceDir for test isolation.

The skipped test above does rmSync before mkdirSync to ensure a clean slate. This test should do the same — if a previous run (or manual debugging) left stale files in emptyWorkspaceDir, the CLI might behave unexpectedly.

Proposed fix
   async ({ expect }) => {
+    rmSync(emptyWorkspaceDir, { recursive: true, force: true })
     mkdirSync(emptyWorkspaceDir, { recursive: true })

43-50: The dual exit-code check is pragmatic but may mask silent CLI regressions.

If the CLI is ever changed to exit 0 without printing an error, this test would incorrectly pass. Consider tightening the fallback condition once the CI tgz quirk is resolved, or adding a tracking comment/issue reference.

e2e/tests/drawio-playground.spec.ts (3)

56-62: exact: false on 'DrawIO' may over-match sibling menu items.

On Line 59, menu.getByText('DrawIO', { exact: false }) will match any item containing "DrawIO", including "Export to DrawIO" and "Export all" (if it were renamed to include "DrawIO"). This assertion doesn't specifically verify that a standalone "DrawIO" parent/header item exists — it just confirms at least one text node contains the substring.

If the intent is to verify a distinct "DrawIO" submenu header, consider using { exact: true } for that specific assertion, while keeping exact: false for the export items which are more uniquely named.

Proposed fix
-    await expect(menu.getByText('DrawIO', { exact: false })).toBeVisible()
+    await expect(menu.getByText('DrawIO', { exact: true })).toBeVisible()

64-72: Consider validating the downloaded filename more precisely.

The regex /\.drawio$/ on Line 66 only checks the file extension. A slightly stricter pattern could also confirm the filename contains meaningful characters (e.g., the view name) to catch regressions where the filename becomes empty or generic.

Example
-    expect(download.suggestedFilename()).toMatch(/\.drawio$/)
+    expect(download.suggestedFilename()).toMatch(/^.+\.drawio$/)

79-89: Consider reusing MENU_SELECTOR via the imported constant instead of re-calling page.locator directly.

Line 84 creates a locator with page.locator(MENU_SELECTOR).first() inline. This is functionally fine, but it duplicates the pattern used inside openDrawioContextMenu. Since the editor context menu is a different trigger surface, extracting a small helper or at least adding a brief comment would clarify that this is intentionally not the canvas context menu.

Minor nit — no functional issue.

scripts/ci-validate-in-docker.sh (1)

17-17: Consider --frozen-lockfile for reproducible CI validation.

pnpm install --prefer-offline without --frozen-lockfile allows lockfile mutations. For a CI validation script, --frozen-lockfile is the standard practice to catch lockfile drift.

Suggested change
-pnpm install --prefer-offline
+pnpm install --frozen-lockfile --prefer-offline
scripts/ci-validate-in-docker.ps1 (1)

17-17: Piping through bash loses script identity in error traces.

sed ... | bash means set -e failures will reference bash (stdin) rather than the script filename, making debugging harder. Consider writing to a temp file instead:

Alternative approach
-  bash -c "sed 's/\r$//' /workspace/scripts/ci-validate-in-docker.sh | bash"
+  bash -c "tmp=\$(mktemp) && sed 's/\r$//' /workspace/scripts/ci-validate-in-docker.sh > \$tmp && bash \$tmp"
apps/playground/src/components/drawio/useDrawioContextMenuActions.ts (2)

228-230: Consider logging layout-fetch failures in development mode.

The empty catch block silently discards the error. The fallback behavior (using the current diagram) is fine, but a DEV-only warning — like the one at Line 156 — would help during debugging.

♻️ Suggested change
         } catch {
-          // use current diagram if layout fetch fails
+          // use current diagram if layout fetch fails
+          if (import.meta.env.DEV) console.warn('DrawIO export: layout fetch failed for view, using current diagram')
         }

245-253: Unnecessary conditional spread for optional property.

onExportError is already optional in CollectViewModelsOptions, so ...(onExportError != null && { onExportError }) can be simplified to just onExportError. Passing undefined for an optional field is harmless and the current pattern adds cognitive overhead.

♻️ Suggested simplification
     const viewModels = await collectViewModelsForExportAll({
       viewIdsInModel,
       allViewModelsFromState,
       likec4model,
       viewStates,
       getLayoutedModel,
       layoutViews,
-      ...(onExportError != null && { onExportError }),
+      onExportError,
     })
packages/likec4/src/cli/export/drawio/handler.ts (2)

96-107: Symlinked subdirectories are silently skipped.

Dirent.isDirectory() returns false for symlinks pointing to directories — only isSymbolicLink() would match them. If a workspace uses symlinked source folders, those .c4/.likec4 files won't be picked up during round-trip reads. This is likely fine for most setups, but worth a note if symlinked workspaces are a supported pattern.


269-272: mkdir is called before the empty-views guard.

Line 256 creates the output directory, but Line 269 may throw if there are zero views — leaving behind an empty directory. Consider moving the mkdir call after the empty-views check, or at least after validating that there's work to do.

Suggested reorder
-  // 3) Prepare output dir and view list
-  await mkdir(args.outdir, { recursive: true })
   const viewmodels = [...model.views()]
 
+  // 4) Export: all-in-one file or one file per view
+  if (viewmodels.length === 0) {
+    logger.warn('No views to export')
+    throw new Error(ERR_NO_VIEWS_EXPORTED)
+  }
+
+  await mkdir(args.outdir, { recursive: true })
+
   const exportParams: ExportDrawioParams = {
     ...
   }
-
-  // 4) Export: all-in-one file or one file per view
-  if (viewmodels.length === 0) {
-    logger.warn('No views to export')
-    throw new Error(ERR_NO_VIEWS_EXPORTED)
-  }
apps/playground/src/monaco/LanguageClientSync.tsx (1)

357-367: Minor: redundant .catch on line 361.

requestLayoutViewCallback already wraps its body in try/catch (lines 148-159), so the outer .catch on line 361 will never trigger. Not harmful, but slightly misleading.

packages/generators/src/drawio/generate-drawio.spec.ts (1)

9-30: Consider extracting the shared validation helper to avoid duplication.

expectDrawioXmlLoadableInDrawio is duplicated from packages/likec4/src/drawio-test-utils.ts (as the comment on Line 13 notes). If the validation logic changes, both copies must be updated. Consider moving this into a shared test-utils module (e.g., under packages/generators/src/drawio/__test__/ or a common test-utils package) and importing from both test sites.

packages/generators/src/drawio/generate-drawio.ts (1)

1071-1079: Node sort comparator is not a strict total order.

The comparator returns 0 for most non-related node pairs, which means the sort outcome can vary across JS engines (sort stability differs). This is documented as acceptable on Line 1070 ("Order is not critical for correctness"), so the positional layout still works. Just noting for awareness — if deterministic output order ever becomes important, this comparator would need refinement.

packages/generators/src/drawio/parse-drawio.ts (2)

1700-1810: The TODO about repeated block-parsing pattern is worth addressing.

The four section parsers (layout, strokeColor, strokeWidth, waypoints) at Lines 1724–1807 share the same "find start marker → collect lines → find end marker" structure with only the parsing logic differing. A small helper like parseCommentBlock(lines, i, startMarker, endMarker, parseLine) would reduce ~80 lines of near-identical code.

That said, the current implementation is correct and the try/catch guards are properly placed. This can be deferred.

Sketch of a helper to reduce duplication
+/** Parse a comment block between start/end markers, calling parseLine for each "// " prefixed line. */
+function parseCommentBlock(
+  lines: string[],
+  startIndex: number,
+  endMarker: string,
+  parseLine: (content: string) => void,
+): number {
+  let i = startIndex + 1
+  while (i < lines.length && lines[i]?.trim() !== endMarker) {
+    const ln = lines[i]?.trim()
+    if (ln?.startsWith('// ')) parseLine(ln.slice(3))
+    i += 1
+  }
+  return i + 1
+}

Each section would then become a single parseCommentBlock(...) call with a specialized parseLine callback.


547-562: Consider using switch(true) for inferKind.

Per coding guidelines, switch(true) is preferred over if-else chains.

Proposed refactor
 function inferKind(
   style: string | undefined,
   parentCell?: DrawioCell,
 ): 'actor' | 'system' | 'container' | 'component' {
-  if (!style) return parentCell?.style?.toLowerCase().includes('container=1') ? 'component' : 'container'
   const s = style?.toLowerCase() ?? ''
-  if (s.includes('umlactor') || s.includes('shape=person')) return 'actor'
-  if (s.includes('swimlane')) return 'system'
-  if (s.includes('container=1')) return 'system'
-  if (parentCell?.style?.toLowerCase().includes('container=1')) return 'component'
-  return 'container'
+  switch (true) {
+    case !style:
+      return parentCell?.style?.toLowerCase().includes('container=1') ? 'component' : 'container'
+    case s.includes('umlactor') || s.includes('shape=person'):
+      return 'actor'
+    case s.includes('swimlane'):
+    case s.includes('container=1'):
+      return 'system'
+    case !!parentCell?.style?.toLowerCase().includes('container=1'):
+      return 'component'
+    default:
+      return 'container'
+  }
 }

As per coding guidelines, "Favor switch(true) over if-else chains".

packages/likec4/src/drawio-demo-export-import.spec.ts (1)

26-155: Consider sharing workspace loading across tests via beforeAll.

Each test independently calls LikeC4.fromWorkspace(CLOUD_SYSTEM_PATH, ...) and layoutedModel(), which is expensive (hence the 20s timeouts). Since all tests use the same workspace with the same options, hoisting the initialization into a beforeAll block would significantly reduce total test suite time.

Proposed refactor
 describe('DrawIO export/import with cloud-system demo', () => {
+  let drawioXml: string
+  let viewmodels: DrawioViewModelLike[]
+
+  beforeAll(async () => {
+    const likec4 = await LikeC4.fromWorkspace(CLOUD_SYSTEM_PATH, { throwIfInvalid: true })
+    const model = await likec4.layoutedModel()
+    viewmodels = [...model.views()]
+    drawioXml = generateDrawioMulti(viewmodels)
+  }, 30_000)
+
   it(
-    'exports cloud-system to DrawIO and file is loadable in draw.io (no nested Array)',
-    { timeout: 20000 },
-    async () => {
-      const likec4 = await LikeC4.fromWorkspace(CLOUD_SYSTEM_PATH, { throwIfInvalid: true })
-      expect(likec4.hasErrors()).toBe(false)
-      const model = await likec4.layoutedModel()
-      const viewmodels = [...model.views()]
-      expect(viewmodels.length).toBeGreaterThanOrEqual(1)
-      const drawioXml = generateDrawioMulti(viewmodels)
+    'exports cloud-system to DrawIO and file is loadable in draw.io',
+    () => {
       expect(drawioXml).toContain('<?xml version="1.0"')
       expect(drawioXml).toContain('<mxfile ')
       expectDrawioXmlLoadableInDrawio(drawioXml)
     },
   )
   // ... similar simplification for other tests

sraphaz and others added 3 commits February 14, 2026 13:45
…LanguageClientSync void, parse-drawio switch(true), drawio-demo beforeAll, e2e/ci-validate)

Co-authored-by: Cursor <[email protected]>
feat: DrawIO export (CLI, Playground, round-trip)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/playground/src/components/drawio/useDrawioContextMenuActions.ts`:
- Line 275: The computed flag canExportAllViews currently misses the fallback
when layoutViews is present; update its boolean expression to also return true
when layoutViews and likec4model are available (i.e., set canExportAllViews =
allViewModelsFromState.length > 0 || (!!getLayoutedModel && !!likec4model) ||
(!!layoutViews && !!likec4model)) so the export-all button enables when
collectViewModelsForExportAll could succeed via phases 3–4; refer to symbols
canExportAllViews, allViewModelsFromState, getLayoutedModel, layoutViews,
likec4model, and collectViewModelsForExportAll when making the change.

In `@packages/generators/src/drawio/parse-drawio.ts`:
- Around line 680-708: The title-detection compares child cell coordinates
(v.x/v.y) in container-local space to container absolute coordinates
(cont.x/cont.y), so titles inside containers are missed; fix by using the same
coordinate space — either convert child coords to absolute (childAbsX = v.x +
cont.x, childAbsY = v.y + cont.y) and keep the existing bounds check, or
(preferable) treat v.x/v.y as container-local and change the bounds check to
compare against 0..cw and 0..titleAreaHeight (e.g., v.x >=
-CONTAINER_TITLE_AREA_TOLERANCE && v.x <= cw + CONTAINER_TITLE_AREA_TOLERANCE &&
v.y >= -CONTAINER_TITLE_AREA_TOLERANCE && v.y <= titleAreaHeight) inside the
loop over containerCells/vertices (symbols: containerCells,
cont.x/cont.y/width/height, vertices, v.parent, v.x/v.y, titleAreaHeight,
CONTAINER_TITLE_AREA_TOLERANCE), leaving containerIdToTitle and titleCellIds
population unchanged.

In `@packages/likec4/src/cli/export/drawio/handler.ts`:
- Around line 96-107: The walk logic currently skips symlinked source files
because Dirent.isFile() is false for symlinks; update the loop in the walk
function that iterates over entries to add a branch for e.isSymbolicLink():
resolve the symlink target (using fs.stat on the joined path `full`) and treat
it as a file if the target stats indicate a regular file and its name matches
isSourceFile, then read it (using the existing readFile + logger/error handling)
and push content into chunks; keep the existing behavior for symlinked
directories (i.e., do not follow) and reuse symbols like entries, full,
isSourceFile, readFile, logger, and chunks to implement this.
- Around line 188-192: The write path for Draw.io exports can fail when a view
ID contains slashes: before calling writeFile for outfile (constructed from
outdir, viewId and DRAWIO_FILE_EXT in the block that calls generateDrawio),
ensure the containing directory exists by creating it recursively (e.g., call
mkdir(dirname(outfile), { recursive: true }) or equivalent) so nested paths from
viewId are created prior to writeFile; update the handler around
viewId/generateDrawio/outfile to perform this recursive mkdir.
🧹 Nitpick comments (10)
e2e/src/likec4-cli-export-drawio.spec.ts (1)

44-51: Fragile assertion: accepting exit code 0 as a valid failure outcome.

The fallback branch (failedByStderr) means this test will pass even if the CLI silently succeeds with exit code 0 but happens to log something matching "no LikeC4 sources found" as an informational message. Once the CLI quirk is fixed, consider tightening this to only accept exitCode === 1 so the test doesn't accidentally pass for the wrong reason.

apps/playground/src/components/drawio/useDrawioContextMenuActions.ts (2)

1-2: Consolidate value and type imports from @likec4/core.

Lines 1–2 import from the same module separately. These can be merged into a single import statement.

-import { LikeC4Styles } from '@likec4/core'
-import type { LayoutedLikeC4ModelData } from '@likec4/core'
+import { LikeC4Styles, type LayoutedLikeC4ModelData } from '@likec4/core'

249-253: Simplify conditional spread — optional field accepts undefined.

Since CollectViewModelsOptions.onExportError is optional, you can pass it directly without the conditional spread pattern.

-      ...(onExportError != null && { onExportError }),
+      onExportError,
apps/playground/src/monaco/LanguageClientSync.tsx (1)

112-145: Silent failure in layoutViews — consider surfacing partial failures to the caller.

Individual view layout failures are logged but not communicated to the caller. The returned Record will simply be missing entries for failed views, with no way for the consumer (e.g., Draw.io export) to distinguish "view doesn't exist" from "layout failed."

This is acceptable for a first pass, but consider returning a richer result type (e.g., { results: Record<string, DiagramView>; errors: string[] }) in a follow-up so the export UI can report which views failed.

packages/likec4/src/cli/export/drawio/handler.ts (2)

251-261: Inconsistent log level: error vs warn for equally fatal conditions.

Line 252 uses logger.error for an empty model, while line 259 uses logger.warn for no views — yet both throw and abort the export. Since both are hard failures from the user's perspective, consider using the same log level for consistency.

Suggested diff
   if (viewmodels.length === 0) {
-    logger.warn('No views to export')
+    logger.error('No views to export')
     throw new Error(ERR_NO_VIEWS_EXPORTED)
   }

137-145: Inconsistent view ID string conversion: String() vs as string.

Line 142 uses String(vm.$view.id) while line 188 uses vm.$view.id as string. For branded string types these produce the same result, but using a single idiom across the file reduces cognitive overhead and prevents divergence if the ID type ever changes.

packages/generators/src/drawio/generate-drawio.ts (2)

86-133: Consider narrowing ExtendedNode via module-level augmentation or a discriminated union rather than unchecked casts.

The accessor object (nodeOptionalFields) centralises the cast, which is good. However, the ExtendedNode type has no overlap guarantee with the actual Node — if a field is renamed upstream (e.g. navigateTonavigate), these accessors will silently return undefined without a type error. A type predicate or a compile-time check (e.g. satisfies Partial<Node>) would catch drift earlier.

This isn't urgent since the fields are exercised by tests, but worth considering as the surface grows.


773-798: Non-null assertions on data.title!, data.titleCellId!, etc. are safe but fragile.

Lines 789–795 use ! because computeNodeCellExportData conditionally sets these fields only when isContainer is true (line 763–768), and the guard at line 785 ensures we only reach this branch for containers. The logic is correct today, but if the conditional spread is ever refactored, these assertions could become runtime errors.

A minor defensive approach:

Suggested improvement
-  const titleCellXml = buildContainerTitleCellXml(
-    data.title!,
-    data.titleCellId!,
-    data.navTo,
-    data.id,
-    data.fontFamily,
-    data.containerTitleFontSizePx!,
-    data.containerTitleColor!,
-  )
+  const titleCellXml = buildContainerTitleCellXml(
+    data.title ?? '',
+    data.titleCellId ?? data.id,
+    data.navTo,
+    data.id,
+    data.fontFamily,
+    data.containerTitleFontSizePx ?? 12,
+    data.containerTitleColor ?? CONTAINER_TITLE_COLOR,
+  )
packages/generators/src/drawio/parse-drawio.ts (1)

1710-1828: Round-trip comment parsing: the TODO about duplication is fair, but acceptable for now.

The four section parsers (layout, strokeColor, strokeWidth, waypoints) repeat the "find start marker → collect lines → find end marker" pattern. A small parseBlock(lines, i, startMarker, endMarker, lineParser) helper would reduce ~80 lines to ~20, but this is a nice-to-have.

packages/likec4/src/drawio-demo-export-import.spec.ts (1)

27-36: The conditional type for viewmodels is fragile — prefer an explicit type or Awaited<...>[].

Line 28–29 uses a conditional type extraction that resolves to never if the layoutedModel() return shape changes. A simpler approach:

Suggested simplification
-  let viewmodels: Awaited<ReturnType<LikeC4['layoutedModel']>> extends { views(): Iterable<infer V> } ? V[]
-    : never
+  let viewmodels: Array<DrawioViewModelLike>

Since generateDrawioMulti accepts Array<DrawioViewModelLike>, typing viewmodels to that interface directly would be more robust and self-documenting. You'd import DrawioViewModelLike from @likec4/generators.

sraphaz and others added 2 commits February 14, 2026 14:06
…ler symlinks+mkdir, parse-drawio container-local coords, nitpicks

Co-authored-by: Cursor <[email protected]>
fix(vite): remove server.fs.allow override in dev config
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/generators/src/drawio/generate-drawio.ts (1)

1160-1193: ⚠️ Potential issue | 🟠 Major

Consider using explicit ID range separation to prevent potential collisions.

Currently, cellId starts at 2 and increments for each node and edge, while containerTitleCellId starts at CONTAINER_TITLE_CELL_ID_START (10000) and increments per container. While the 10000-unit gap provides substantial implicit protection, a diagram with thousands of nodes and edges could theoretically reach the container ID range. Adding an explicit assertion or using separated ID ranges (e.g., cellId capped at 9999 or containerTitleCellId starting higher) would make this safer and more maintainable than relying on the magic number assumption.

🧹 Nitpick comments (9)
packages/likec4/src/cli/export/drawio/handler.ts (1)

96-116: Consider using switch (true) for the dirent type checks.

Per coding guidelines, switch(true) is preferred over if-else chains.

♻️ Suggested refactor
     for (const e of entries) {
       const full = join(dir, e.name)
-      // Note: symlinks to directories are not followed (isDirectory() is false for symlinks).
-      if (e.isDirectory()) {
-        if (!ROUNDTRIP_IGNORED_DIRS.has(e.name)) await walk(full, depth + 1)
-      } else if (e.isSymbolicLink()) {
-        const st = await stat(full).catch(() => null)
-        if (st?.isFile() && isSourceFile(e.name)) {
-          const content = await readFile(full, 'utf-8').catch(err => {
-            if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
-            return ''
-          })
-          if (content) chunks.push(content)
-        }
-      } else if (e.isFile() && isSourceFile(e.name)) {
-        const content = await readFile(full, 'utf-8').catch(err => {
-          if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
-          return ''
-        })
-        if (content) chunks.push(content)
+      switch (true) {
+        case e.isDirectory():
+          if (!ROUNDTRIP_IGNORED_DIRS.has(e.name)) await walk(full, depth + 1)
+          break
+        case e.isSymbolicLink(): {
+          const st = await stat(full).catch(() => null)
+          if (st?.isFile() && isSourceFile(e.name)) {
+            const content = await readFile(full, 'utf-8').catch(err => {
+              if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
+              return ''
+            })
+            if (content) chunks.push(content)
+          }
+          break
+        }
+        case e.isFile() && isSourceFile(e.name): {
+          const content = await readFile(full, 'utf-8').catch(err => {
+            if (logger?.debug) logger.debug(`${k.dim('Roundtrip:')} readFile failed`, { file: full, err })
+            return ''
+          })
+          if (content) chunks.push(content)
+          break
+        }
       }

As per coding guidelines: "Favor switch(true) over if-else chains".

apps/playground/src/monaco/LanguageClientSync.tsx (1)

128-144: Silent view layout failures may confuse users during export.

The comment on line 128 already acknowledges this, but worth noting: if a subset of views fail to layout, the caller receives a partial Record with no indication of which views were dropped or why. For a Draw.io "Export all" flow, this could produce an incomplete export without any user-visible feedback.

Consider at minimum returning the count of failed views or logging a summary after Promise.all resolves (e.g., logger.warn(\${failedCount}/${viewIds.length} views failed to layout`)`).

💡 Optional: Add a summary log after Promise.all
         await Promise.all(
           viewIds.map(async (viewId) => {
             try {
               const res = await requestLayoutView(c, viewId as ViewId)
               if (res.result?.diagram) out[viewId] = res.result.diagram
             } catch (err) {
               logger.warn(`layout view ${viewId} failed: ${loggable(err)}`)
             }
           }),
         )
+        const failedCount = viewIds.length - Object.keys(out).length
+        if (failedCount > 0) {
+          logger.warn(`${failedCount}/${viewIds.length} views failed to layout`)
+        }
         return out
packages/generators/src/drawio/generate-drawio.ts (2)

260-275: Casting hiContrast and line/labelBg to string assumes theme always provides these fields.

Lines 273, 280, 291–292 use as string casts on theme color values. If the theme contract changes and these become undefined, the cast silently passes "undefined" as a color hex, producing invalid Draw.io output.

Consider adding a fallback:

Safer color resolution
-    font: (elementColors.hiContrast ?? elementColors.stroke) as string,
+    font: String(elementColors.hiContrast ?? elementColors.stroke ?? DEFAULT_NODE_FONT_HEX),

Apply similarly to getEdgeStrokeColor (line 280) and getEdgeLabelColors (lines 291–292).


85-96: ExtendedNode relies on unchecked casts — consider a type predicate or runtime guard.

The JSDoc on line 85 even says "Consider type predicate or satisfies Partial to catch upstream field renames." If the upstream Node type renames navigateTonavigatesTo (for example), all the accessors in nodeOptionalFields would silently return undefined with no compile-time error.

This is low-risk today but worth noting for future maintainability.

apps/playground/src/components/drawio/useDrawioContextMenuActions.ts (2)

241-263: No concurrent-export guard — double-clicks could trigger duplicate downloads.

If the user clicks "Export all views" twice rapidly, two collectViewModelsForExportAll calls run concurrently, potentially producing two file downloads. Consider adding a loading/exporting state to disable the button during export.

Sketch: add an `exporting` guard
+ const [exporting, setExporting] = useState(false)
+
  const handleExportAllViews = useCallback(async () => {
    close()
    if (!likec4model) return
+   if (exporting) return
+   setExporting(true)
+   try {
      // ...existing logic...
+   } finally {
+     setExporting(false)
+   }
  }, [
    close,
+   exporting,
    allViewModelsFromState,
    // ...
  ])

Then expose exporting from the hook for the UI to disable the button.


200-207: allViewModelsFromState memo may recompute on every render if viewStates isn't stabilized.

The comment on line 200 warns callers to stabilize viewStates, but if they don't, this useMemo re-runs on every render because viewStates is a Record (likely a new object reference each time). Consider adding a more robust check (e.g., memoizing the filtered/mapped result based on a serialized key or using a ref comparison).

packages/generators/src/drawio/parse-drawio.ts (3)

1709-1817: Consider extracting a shared "parse block between markers" helper.

The TODO on lines 1712–1713 is valid. The four section parsers (layout, strokeColor, strokeWidth, waypoints) each repeat the same pattern: find start marker → collect lines → parse content → find end marker. This adds ~80 lines that could be reduced to ~20 with a helper like:

function parseBlock(
  lines: string[], start: number, endMarker: string, 
  processLine: (rest: string) => void,
): number { ... }

Since import is deferred and this code isn't yet actively exercised, this is a good candidate for the follow-up import PR.

Would you like me to generate the refactored helper, or open an issue to track this for the import PR?


1636-1657: Redundant XML parsing when diagrams.length === 1.

Line 1649 delegates to parseDrawioToLikeC4(xml), which internally calls getFirstDiagram(xml)getAllDiagrams(xml) again, re-parsing the full XML to find diagrams that were already extracted on line 1637. For very large files this is wasteful.

Sketch: pass already-extracted diagram content directly
- if (diagrams.length === 1) return parseDrawioToLikeC4(xml)
+ if (diagrams.length === 1) {
+   const d = diagrams[0]!
+   const cells = parseDrawioXml(d.content)
+   const state = buildSingleDiagramState(cells, d.name)
+   return emitLikeC4SourceFromSingleState(state)
+ }

182-195: parseStyle silently discards entries with empty values — verify this is intended.

Line 191: if (k && v) map.set(...) means a style entry like dashed=; (empty value) is silently dropped. If Draw.io ever emits a key with an intentionally empty value to indicate "unset" or "default", it won't be captured. This is likely fine for current usage since meaningful style values are always non-empty, but worth documenting.

…e color fallbacks, cellId range assert, exporting guard, parseStyle JSDoc, single-diagram no re-parse

Co-authored-by: Cursor <[email protected]>
sraphaz and others added 2 commits February 14, 2026 14:31
feat: DrawIO export (CLI, Playground, round-trip) + CodeRabbit fixes
@sraphaz sraphaz merged commit 8624e82 into likec4:main Feb 14, 2026
15 checks passed
@likec4-ci likec4-ci bot mentioned this pull request Feb 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant