feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io#2630
feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io#2630sraphaz merged 25 commits intolikec4:mainfrom
Conversation
… Draw.io" (likec4#2629) This reverts commit 654240a, reversing changes made to fdd46ff.
… workspace) Co-authored-by: Cursor <[email protected]>
…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]>
…x ESM and duplicate test runs) Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
…lve (avoid CJS .js) Co-authored-by: Cursor <[email protected]>
… .js with JSX Co-authored-by: Cursor <[email protected]>
…uild picking JSX-in-js); ignore diagram/src/**/*.js Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
feat(cli,playground,docs,generators): Export LikeC4 views to Draw.io (reapply + code scanning)
🦋 Changeset detectedLatest 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 |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughIntroduces 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
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
(Note: colored rectangles not used because sequence boxes are standard here.) Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
No actionable comments were generated in the recent review. 🎉 🧹 Recent nitpick comments
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. Comment |
There was a problem hiding this comment.
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 | 🟠 MajorUnsound type predicates —
isLikeC4NonJsonConfigandisLikeC4Confignarrow 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 originalfilenameparameter to literal types like'likec4.config.ts'. If a caller passes a full path like'/foo/likec4.config.ts', the function returnstrueand TypeScript narrowsfilenameto the literal union, even though the runtime value remains the full path. While all current callers already extractbasename()before calling these functions (avoiding the issue in practice), the type signature is misleading and could be misused.Change both functions to return plain
booleaninstead 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: ConsiderWrite-InformationorWrite-Outputinstead ofWrite-Host.Static analysis (PSAvoidUsingWriteHost) flags this. For a developer-facing CI script it's largely harmless, but
Write-OutputorWrite-Informationwould 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:parseDeploymentNodeandparseDeployedInstancearen't null-checked unlikeparseElement.On line 64,
parseElementhas an explicit null guard, butparseDeploymentNode(line 46) andparseDeployedInstance(line 50) do not. If those parsers can returnnull/undefined, the resulting NPE would be silently swallowed by thecatchblock, returningundefinedwithout clear context. If they can't return null, theparseElementguard 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
startLanguageServerfunction 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 frompackageManagerin package.json.Consider reading the version from
package.json'spackageManagerfield 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 theexpectfails — which meansfailedByExitCodeisfalseandresult.exitCode !== 1. Thetruebranch ('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 = falseis already the default forzx.
zxthrows on non-zero exit codes by default, so$.nothrow = falseis 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.jsand*.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, andpr-*-to-origin.mdlook like individual developer workflow artifacts. These are better placed in each developer's~/.gitignore_globalor.git/info/excludeto keep the shared.gitignorefocused 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
forloop is safe and gives predictable CLI output ordering. If throughput ever becomes an issue with many views, a boundedPromise.allcould 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 ofonExportError.Line 251 uses
...(onExportError != null && { onExportError })— sinceCollectViewModelsOptions.onExportErroris 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:useMemoforallViewModelsFromStatedepends onviewStatesobject reference.If the parent re-creates the
viewStatesobject on every render (e.g., from a state machine snapshot), this memo won't memoize effectively. Ensure the caller stabilizesviewStatesviauseMemoor 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. SinceLanguageClientSyncalready typessetLayoutedModelApias optional, passingundefinedis 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 inregisterLayoutedModelApifor debuggability.The empty
catchblocks at Lines 123 and 136 silently discard errors fromFetchLayoutedModelandLayoutViewrequests. While returningnull/skipping is the right fallback, alogger.warnwould 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: DuplicatedexpectDrawioXmlLoadableInDrawiohelper.This function duplicates the one in
packages/likec4/src/drawio-test-utils.tswith 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.fnwrapper is unused for its mocking capabilities.
mockViewModelis wrapped invi.fnbut 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 forDownloadreturn 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: Duplicatehtml=1;in vertex style string.
vertexTextStylefor non-container nodes (line 723) already includeshtml=1;, and line 728 appends anotherhtml=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:getDefaultStrokeWidthternary returns''for non-container leaf nodes without explicit border.return isContainer ? '1' : borderVal ? '1' : ''When
borderValisundefinedandisContainerisfalse, this returns'', which means nostrokeWidthis 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, ifa.id = "x.y.z"andb.id = "x.y", thena > b. But for two siblings like"x.y"and"x.z"(neither is a prefix of the other), the comparator returns0. Combined with theparentchecks, this generally works becauseArray.sortdoesn'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 bexml[endOpen - 1] === '/'after trimming whitespace.
1707-1818: Round-trip comment parser is correct but has significant code duplication.The
parseDrawioRoundtripCommentsfunction 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.
- 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]>
…es/pako Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
…I typecheck Co-authored-by: Cursor <[email protected]>
…portError, setLayoutedModelApi) Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
feat: DrawIO export (CLI, Playground, round-trip) + cleanup
There was a problem hiding this comment.
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/pakois out of alphabetical order in the catalog.The default catalog groups
@-scoped packages first (lines 9–13), sorted alphabetically, then unscoped packages.@types/pakoon line 20 should be placed between@types/nodeand@typescript/native-previewto 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.1Based on learnings: "Configure workspace packages and maintain dependency catalog in
pnpm-workspace.yamlfor 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 bothpackages/log/src/*.jsandpackages/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 creatingemptyWorkspaceDirfor test isolation.The skipped test above does
rmSyncbeforemkdirSyncto ensure a clean slate. This test should do the same — if a previous run (or manual debugging) left stale files inemptyWorkspaceDir, 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: falseon'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 keepingexact: falsefor 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 reusingMENU_SELECTORvia the imported constant instead of re-callingpage.locatordirectly.Line 84 creates a locator with
page.locator(MENU_SELECTOR).first()inline. This is functionally fine, but it duplicates the pattern used insideopenDrawioContextMenu. 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-lockfilefor reproducible CI validation.
pnpm install --prefer-offlinewithout--frozen-lockfileallows lockfile mutations. For a CI validation script,--frozen-lockfileis the standard practice to catch lockfile drift.Suggested change
-pnpm install --prefer-offline +pnpm install --frozen-lockfile --prefer-offlinescripts/ci-validate-in-docker.ps1 (1)
17-17: Piping throughbashloses script identity in error traces.
sed ... | bashmeansset -efailures will referencebash(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
catchblock silently discards the error. The fallback behavior (using the current diagram) is fine, but aDEV-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.
onExportErroris already optional inCollectViewModelsOptions, so...(onExportError != null && { onExportError })can be simplified to justonExportError. Passingundefinedfor 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()returnsfalsefor symlinks pointing to directories — onlyisSymbolicLink()would match them. If a workspace uses symlinked source folders, those.c4/.likec4files 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:mkdiris 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
mkdircall 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.catchon line 361.
requestLayoutViewCallbackalready wraps its body in try/catch (lines 148-159), so the outer.catchon 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.
expectDrawioXmlLoadableInDrawiois duplicated frompackages/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., underpackages/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
0for 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/catchguards 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 specializedparseLinecallback.
547-562: Consider usingswitch(true)forinferKind.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 viabeforeAll.Each test independently calls
LikeC4.fromWorkspace(CLOUD_SYSTEM_PATH, ...)andlayoutedModel(), which is expensive (hence the 20s timeouts). Since all tests use the same workspace with the same options, hoisting the initialization into abeforeAllblock 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
…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)
Co-authored-by: Cursor <[email protected]>
There was a problem hiding this comment.
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 acceptexitCode === 1so 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 acceptsundefined.Since
CollectViewModelsOptions.onExportErroris 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 inlayoutViews— consider surfacing partial failures to the caller.Individual view layout failures are logged but not communicated to the caller. The returned
Recordwill 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:errorvswarnfor equally fatal conditions.Line 252 uses
logger.errorfor an empty model, while line 259 useslogger.warnfor 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()vsas string.Line 142 uses
String(vm.$view.id)while line 188 usesvm.$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 narrowingExtendedNodevia module-level augmentation or a discriminated union rather than unchecked casts.The accessor object (
nodeOptionalFields) centralises the cast, which is good. However, theExtendedNodetype has no overlap guarantee with the actualNode— if a field is renamed upstream (e.g.navigateTo→navigate), these accessors will silently returnundefinedwithout 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 ondata.title!,data.titleCellId!, etc. are safe but fragile.Lines 789–795 use
!becausecomputeNodeCellExportDataconditionally sets these fields only whenisContaineris 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 forviewmodelsis fragile — prefer an explicit type orAwaited<...>[].Line 28–29 uses a conditional type extraction that resolves to
neverif thelayoutedModel()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
generateDrawioMultiacceptsArray<DrawioViewModelLike>, typingviewmodelsto that interface directly would be more robust and self-documenting. You'd importDrawioViewModelLikefrom@likec4/generators.
apps/playground/src/components/drawio/useDrawioContextMenuActions.ts
Outdated
Show resolved
Hide resolved
…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
There was a problem hiding this comment.
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 | 🟠 MajorConsider using explicit ID range separation to prevent potential collisions.
Currently,
cellIdstarts at 2 and increments for each node and edge, whilecontainerTitleCellIdstarts atCONTAINER_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.,cellIdcapped 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 usingswitch (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
Recordwith 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.allresolves (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 outpackages/generators/src/drawio/generate-drawio.ts (2)
260-275: CastinghiContrastandline/labelBgtostringassumes theme always provides these fields.Lines 273, 280, 291–292 use
as stringcasts on theme color values. If the theme contract changes and these becomeundefined, 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) andgetEdgeLabelColors(lines 291–292).
85-96:ExtendedNoderelies 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
Nodetype renamesnavigateTo→navigatesTo(for example), all the accessors innodeOptionalFieldswould silently returnundefinedwith 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
collectViewModelsForExportAllcalls 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
exportingfrom the hook for the UI to disable the button.
200-207:allViewModelsFromStatememo may recompute on every render ifviewStatesisn't stabilized.The comment on line 200 warns callers to stabilize
viewStates, but if they don't, thisuseMemore-runs on every render becauseviewStatesis aRecord(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 whendiagrams.length === 1.Line 1649 delegates to
parseDrawioToLikeC4(xml), which internally callsgetFirstDiagram(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:parseStylesilently discards entries with empty values — verify this is intended.Line 191:
if (k && v) map.set(...)means a style entry likedashed=;(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]>
Co-authored-by: Cursor <[email protected]>
feat: DrawIO export (CLI, Playground, round-trip) + CodeRabbit fixes
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)computeDiagramLayoutsplit into smaller helpers;getViewDescriptionStringextracted;buildNodeCellXml/buildEdgeCellXmland constants (SOLID/DRY/KISS); exported typeDrawioViewModelLike.parseDrawioToLikeC4Multisplit intomergeDiagramStatesIntoMaps,buildRootsFromFqnToCell,emitMultiDiagramModel(orchestrator ~50–60 lines);buildViewBlockLines/escapeLikec4Quotes; O(n²) deduplication replaced withparsedIdsSet; UserObjectfullTagusesinnerXmlso sibling<data>is preserved.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)likec4 export drawiowith--outdir/-o,--all-in-one,--roundtrip,--uncompressed,--project,--use-dot. UsesDEFAULT_DRAWIO_ALL_FILENAMEfrom@likec4/generators(DRY). Phase comments and thin handler pattern aligned with other export commands.PngExportArgs,runExportPng(args, logger); PNG export supports--outdir/-o(docs updated).likec4 import drawioin this PR (nopackages/likec4/src/cli/import/).3. Playground
generateDrawio/generateDrawioMultiandparseDrawioRoundtripComments. No Import menu item or file input.4. Documentation
--outdir; no Import section.5. E2E & tests
playwright.playground.config.ts.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
likec4 import drawiocommand.Refactor summary (response to PR #2614 feedback)
After the initial submission (PR #2614), we applied a structured clean-code pass:
DrawioViewModelLike); constants; phase comments; DRY (e.g.DEFAULT_DRAWIO_ALL_FILENAME, shared helpers).runExport*(args, logger)pattern for drawio/PNG/JSON; consistent error handling and JSDoc.e2e/helpers/.We believe this version is in better shape for review and long-term maintenance.
Checklist
main(merge/rebase as appropriate).feat:,refactor:,test:).pnpm ci:test(Vitest) passes.Verification
pnpm build(filter!./apps/*),pnpm typecheck,pnpm test(orpnpm ci:test) — pass.Notes for reviewers
DrawioViewModelLikeis the public type for view models passed togenerateDrawio/generateDrawioMulti.runExport*+ thin handler).Reference
Summary by CodeRabbit
New Features
Changes
Documentation
Tests
Bug Fixes / Chores