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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lint-config-wysiwyg-toggle.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/content/advanced/content-rules/markdownlint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof assetCtx> = 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: () => <div data-testid="settings-shell" />,
}));
vi.doMock('@/components/EditorSkeleton', () => ({
EditorSkeleton: () => <div data-testid="editor-skeleton" />,
}));
vi.doMock('@/components/EmptyEditorState', () => ({
EmptyEditorState: () => <div data-testid="empty-editor-state" />,
}));
vi.doMock('./TerminalDock', () => ({
TerminalDock: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.doMock('react-resizable-panels', () => ({
usePanelRef: () => ({ current: { collapse: () => {}, expand: () => {} } }),
useGroupRef: () => ({ current: { getLayout: () => [], setLayout: () => {} } }),
}));
vi.doMock('@/components/ui/resizable', () => ({
ResizablePanelGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ResizablePanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ResizableHandle: () => <div />,
}));
vi.doMock('./EditorActivityPool', () => ({
EditorActivityPool: () => <div data-testid="editor-pool" />,
}));
vi.doMock('@/editor/find-replace/FindReplaceController', () => ({
FindReplaceController: () => null,
}));
vi.doMock('./EditorToolbar', () => ({
EditorToolbar: () => <div data-testid="editor-toolbar" />,
}));
vi.doMock('./EditorFooter', () => ({ EditorFooter: () => <div data-testid="editor-footer" /> }));
vi.doMock('./BottomComposer', () => ({
BottomComposer: () => <div data-testid="bottom-composer" />,
}));
vi.doMock('./editor-area-overlay', () => ({ shouldPaintOverlay: () => false }));
vi.doMock('@/components/DocPanel', () => ({ DocPanel: () => <div data-testid="doc-panel" /> }));

// 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 }) => (
<div data-testid="asset-preview" data-asset-path={assetPath} />
),
}));
vi.doMock('@/components/LintConfigEditor', () => ({
LintConfigEditor: ({ assetPath }: { assetPath: string }) => (
<div data-testid="lint-config-editor" data-asset-path={assetPath} />
),
}));

const { EditorArea } = await import('./EditorArea');

function renderEditorArea() {
return render(
<ConfigProvider collabUrl={null}>
<EditorArea
editorMode="wysiwyg"
onModeChange={() => {}}
activeTab="timeline"
onActiveTabChange={() => {}}
/>
</ConfigProvider>,
);
}

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();
});
});
18 changes: 17 additions & 1 deletion packages/app/src/components/EditorArea.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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 = (
<LintConfigEditor key={activeTarget.assetPath} assetPath={activeTarget.assetPath} />
);
} 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"
Expand Down
Loading