diff --git a/.changeset/lint-config-wysiwyg-toggle.md b/.changeset/lint-config-wysiwyg-toggle.md new file mode 100644 index 000000000..78a0a3c30 --- /dev/null +++ b/.changeset/lint-config-wysiwyg-toggle.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Opening a JSON markdownlint config file (`.markdownlint.json` / `.markdownlint.jsonc`) now offers a toggle between a **Source** view and a **Rules** view. Source is the raw, read-only file (comments, `extends`, and formatting intact); Rules is the same searchable rule browser as Settings — flip rules on or off and edit their options, with changes written back to the file through the format-preserving writer (comments, `extends`, and trailing commas are preserved). Rule editing targets the project's root config, so a nested or not-yet-created config opens in Source with the Rules option disabled and an explanation. Your Source/Rules choice is remembered per user, separately from the markdown editor's own mode. diff --git a/docs/content/advanced/content-rules/markdownlint.mdx b/docs/content/advanced/content-rules/markdownlint.mdx index c703a4ec7..24a2f88c4 100644 --- a/docs/content/advanced/content-rules/markdownlint.mdx +++ b/docs/content/advanced/content-rules/markdownlint.mdx @@ -29,6 +29,8 @@ The rules live in your project's own native markdownlint file — `.markdownlint **Settings ▸ Plugins ▸ markdownlint** (markdownlint appears there once the plugin is enabled) lists the full catalog — all 53 rules, generated from the installed engine's own config schema, so it always matches what actually runs. Search by id, alias, or name; browse by category (Headings, Lists, Whitespace, Code, Links & images, Style); or check **Only modified** to see just the rules your config changes. Expanding a rule reveals a link to its upstream documentation and typed editors for each of its options. +The same browser also opens **directly on the config file**. Open your project's root `.markdownlint.json` or `.markdownlint.jsonc` in the editor (reveal hidden files to find it) and use the **Source / Rules** toggle: **Source** — the default — shows the raw file read-only, and **Rules** is the same no-code browser. Because rule edits target the project's governing root config, **Rules** is enabled only for that root file; open a nested or otherwise non-governing `.markdownlint.*` and **Rules** is disabled with a tooltip while **Source** still shows the file. Your Source/Rules choice is remembered separately from the document editor's own Visual/Markdown mode. + Every edit writes back to your native `.markdownlint.*` file, preserving what you wrote: JSON and JSONC files get minimal text edits (comments and formatting survive), and a rule you configured under an alias stays under that alias. Severity strings are shown as a read-only badge. If the project has no file yet, the first change creates a `.markdownlint.json` seeded with the defaults — from then on that file is the whole story for OK and every other markdownlint tool. ## Auto-fix and severities diff --git a/packages/app/package.json b/packages/app/package.json index 10f4855e2..3517ef553 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,7 @@ "measure:fuzz": "bash scripts/measure-fuzz.sh", "measure:stress": "bash scripts/measure-stress.sh", "perf:compare": "bash scripts/perf-compare.sh", - "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts", + "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts", "test:e2e:install-browsers": "playwright install chromium webkit firefox", "test:visual": "playwright test --config playwright.visual.config.ts", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", @@ -52,7 +52,7 @@ { "name": "all JS chunks combined (gzipped)", "path": "dist/assets/*.js", - "limit": "3.35 MB", + "limit": "3.36 MB", "gzip": true, "running": false }, diff --git a/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx b/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx new file mode 100644 index 000000000..a6d657234 --- /dev/null +++ b/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx @@ -0,0 +1,172 @@ +/** + * Dispatch test for the EditorArea asset branch: a markdownlint JSON config file + * routes to the LintConfigEditor (Source/Rules toggle), while every other asset + * — including a non-config `.json` like package.json — keeps the read-only + * AssetPreview. Both branch targets are stubbed to markers so this test observes + * only WHICH branch EditorArea selects; LintConfigEditor's own toggle behavior + * is covered by its dedicated DOM test. + * + * The heavy EditorArea dependency graph is mocked following the sibling + * `EditorArea.share-receive-miss.dom.test.tsx` harness. The config predicate + * (`isMarkdownlintJsonConfig`) runs for real — it is the seam under test. + */ + +import { cleanup, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ConfigProvider } from '@/lib/config-provider'; + +function assetCtx(assetPath: string) { + return { + activeDocName: null, + activeProvider: {} as never, + activeTarget: { + kind: 'asset' as const, + target: assetPath, + assetPath, + mediaKind: 'text' as string | null, + }, + recycleDocument: () => {}, + docPanelMode: 'timeline', + docPanelAgentId: null, + docPanelExpandSignal: 0, + }; +} +let docCtx: ReturnType = assetCtx('.markdownlint.json'); + +vi.doMock('@/lib/perf', () => ({ + mark: () => {}, + ProfilerBoundary: ({ children }: { children: ReactNode }) => children, +})); +vi.doMock('@/components/PropertyContext', () => ({ + PropertyProvider: ({ children }: { children: ReactNode }) => children, + useProperties: () => ({ requestAddProperty: () => {} }), +})); +vi.doMock('@/editor/DocumentContext', () => ({ + useDocumentContext: () => docCtx, + useDocumentTransition: () => ({ openDocumentTransition: null }), +})); +vi.doMock('@/hooks/use-document-stats', () => ({ useDocumentStats: () => null })); +vi.doMock('@/hooks/use-selection-stats', () => ({ useSelectionStats: () => null })); +vi.doMock('@/hooks/use-lifecycle-status', () => ({ useLifecycleStatus: () => 'ready' })); +vi.doMock('@/presence/use-sync-status', () => ({ useSyncStatus: () => 'synced' })); +vi.doMock('@/lib/use-settings-route', () => ({ + useSettingsRoute: () => ({ open: false, close: () => {} }), + SETTINGS_OPEN_HASH: '#settings', + isSettingsShortcut: () => false, +})); +vi.doMock('@/components/settings/SettingsDialogShell', () => ({ + SettingsDialogShell: () =>
, +})); +vi.doMock('@/components/EditorSkeleton', () => ({ + EditorSkeleton: () =>
, +})); +vi.doMock('@/components/EmptyEditorState', () => ({ + EmptyEditorState: () =>
, +})); +vi.doMock('./TerminalDock', () => ({ + TerminalDock: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.doMock('react-resizable-panels', () => ({ + usePanelRef: () => ({ current: { collapse: () => {}, expand: () => {} } }), + useGroupRef: () => ({ current: { getLayout: () => [], setLayout: () => {} } }), +})); +vi.doMock('@/components/ui/resizable', () => ({ + ResizablePanelGroup: ({ children }: { children: ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); +vi.doMock('./EditorActivityPool', () => ({ + EditorActivityPool: () =>
, +})); +vi.doMock('@/editor/find-replace/FindReplaceController', () => ({ + FindReplaceController: () => null, +})); +vi.doMock('./EditorToolbar', () => ({ + EditorToolbar: () =>
, +})); +vi.doMock('./EditorFooter', () => ({ EditorFooter: () =>
})); +vi.doMock('./BottomComposer', () => ({ + BottomComposer: () =>
, +})); +vi.doMock('./editor-area-overlay', () => ({ shouldPaintOverlay: () => false })); +vi.doMock('@/components/DocPanel', () => ({ DocPanel: () =>
})); + +// The two asset-branch targets, stubbed to markers so the test reads which +// branch EditorArea picked (not the components' own rendering). +vi.doMock('@/components/AssetPreview', () => ({ + AssetPreview: ({ assetPath }: { assetPath: string }) => ( +
+ ), +})); +vi.doMock('@/components/LintConfigEditor', () => ({ + LintConfigEditor: ({ assetPath }: { assetPath: string }) => ( +
+ ), +})); + +const { EditorArea } = await import('./EditorArea'); + +function renderEditorArea() { + return render( + + {}} + activeTab="timeline" + onActiveTabChange={() => {}} + /> + , + ); +} + +describe('EditorArea — markdownlint config dispatch', () => { + beforeEach(() => cleanup()); + afterEach(() => cleanup()); + + test('routes a root .markdownlint.json to the config editor, not the asset preview', () => { + docCtx = assetCtx('.markdownlint.json'); + renderEditorArea(); + + expect(screen.getByTestId('lint-config-editor').getAttribute('data-asset-path')).toBe( + '.markdownlint.json', + ); + expect(screen.queryByTestId('asset-preview')).toBeNull(); + }); + + test('routes a .markdownlint.jsonc to the config editor', () => { + docCtx = assetCtx('.markdownlint.jsonc'); + renderEditorArea(); + + expect(screen.getByTestId('lint-config-editor')).toBeDefined(); + expect(screen.queryByTestId('asset-preview')).toBeNull(); + }); + + test('routes a nested config to the config editor (dispatch is basename-driven)', () => { + docCtx = assetCtx('docs/.markdownlint.json'); + renderEditorArea(); + + expect(screen.getByTestId('lint-config-editor').getAttribute('data-asset-path')).toBe( + 'docs/.markdownlint.json', + ); + expect(screen.queryByTestId('asset-preview')).toBeNull(); + }); + + test('leaves a non-config JSON (package.json) on the asset preview', () => { + docCtx = assetCtx('package.json'); + renderEditorArea(); + + expect(screen.getByTestId('asset-preview').getAttribute('data-asset-path')).toBe( + 'package.json', + ); + expect(screen.queryByTestId('lint-config-editor')).toBeNull(); + }); + + test('leaves an ordinary .json data file on the asset preview', () => { + docCtx = assetCtx('data/settings.json'); + renderEditorArea(); + + expect(screen.getByTestId('asset-preview')).toBeDefined(); + expect(screen.queryByTestId('lint-config-editor')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index 6c03748b8..c1a2cdaf8 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -1,4 +1,7 @@ -import { detectEmbeddedHostFromBrowser } from '@inkeep/open-knowledge-core'; +import { + detectEmbeddedHostFromBrowser, + isMarkdownlintJsonConfig, +} from '@inkeep/open-knowledge-core'; import { Trans, useLingui } from '@lingui/react/macro'; import { useTheme } from 'next-themes'; import { @@ -23,6 +26,7 @@ import { EditorSkeleton } from '@/components/EditorSkeleton'; import { EmptyEditorState } from '@/components/EmptyEditorState'; import { FolderOverview } from '@/components/FolderOverview'; import { LargeFileEditorState } from '@/components/LargeFileEditorState'; +import { LintConfigEditor } from '@/components/LintConfigEditor'; import { MountStalledAffordance } from '@/components/MountStalledAffordance'; import { PropertyProvider, useProperties } from '@/components/PropertyContext'; import { ShareReceiveMissPanel } from '@/components/ShareReceiveMissPanel'; @@ -879,6 +883,18 @@ function EditorAreaInner({ ); } + } else if ( + activeTarget?.kind === 'asset' && + isMarkdownlintJsonConfig(activeTarget.assetPath.split('/').pop() ?? activeTarget.assetPath) + ) { + // A markdownlint JSON config file opens in the dedicated config editor (a + // Source/Rules toggle) instead of the read-only asset preview. Like the + // skill-file branch below it's a plain REST-backed sibling — NOT + // DocumentBoundary-wrapped: the config is served over HTTP, not a pooled + // CRDT doc. Keyed by path so navigating between configs remounts + resets. + viewContent = ( + + ); } else if (activeTarget?.kind === 'asset') { // `key={assetPath}` forces a fresh `AssetPreview` instance on every asset // navigation so the in-pane `forceText` toggle (from the "View as text" diff --git a/packages/app/src/components/EditorModeToggle.tsx b/packages/app/src/components/EditorModeToggle.tsx index b613d5e5b..5b3cd4319 100644 --- a/packages/app/src/components/EditorModeToggle.tsx +++ b/packages/app/src/components/EditorModeToggle.tsx @@ -1,4 +1,5 @@ import { Trans, useLingui } from '@lingui/react/macro'; +import { useId } from 'react'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { EditorModeValue } from '@/editor/use-editor-mode'; @@ -10,19 +11,49 @@ interface EditorModeToggleProps { onModeChange: (mode: EditorModeValue) => void; /** Disables the Markdown (source) option (e.g. doc editor when offline). */ sourceDisabled?: boolean; + /** + * Disables the Visual (wysiwyg) option (e.g. the lint-config editor when the + * opened file is not the governing root config the rule writer can target). + */ + wysiwygDisabled?: boolean; + /** + * Overrides the default segment labels ("Visual" / "Markdown"), applied to + * both the tooltip and the accessible name. The lint-config editor passes + * "Rules" / "Source". Pass an already-translated string so message extraction + * stays at the call site. + */ + wysiwygLabel?: string; + sourceLabel?: string; + /** + * Tooltip shown on the Visual segment while `wysiwygDisabled` — explains why + * the option is unavailable so a pointer user learns the reason. + */ + wysiwygDisabledReason?: string; } /** * Visual ⇄ Markdown editor-mode toggle — the segmented control shared by the - * document editor toolbar (`EditorToolbar`) and the skill editor, so both read - * identically. + * document editor toolbar (`EditorToolbar`), the skill editor, and the + * lint-config editor, so all three read identically. Labels default to + * Visual/Markdown; a caller may relabel the two segments and disable the visual + * one without affecting the others. */ export function EditorModeToggle({ isSourceMode, onModeChange, sourceDisabled = false, + wysiwygDisabled = false, + wysiwygLabel, + sourceLabel, + wysiwygDisabledReason, }: EditorModeToggleProps) { const { t } = useLingui(); + const wysiwygName = wysiwygLabel ?? t`Visual editor`; + const sourceName = sourceLabel ?? t`Markdown source`; + // Stable id so the disabled Visual segment can point `aria-describedby` at a + // visually-hidden element carrying its reason. + const wysiwygReasonId = useId(); + const describeWysiwyg = wysiwygDisabled && wysiwygDisabledReason ? wysiwygDisabledReason : null; return ( - - - - - + + {/* Disabled