From 080a77d2c019719f80baec16c17b876eeafaf367 Mon Sep 17 00:00:00 2001 From: miles-kt-inkeep <135626743+miles-kt-inkeep@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:40:02 -0400 Subject: [PATCH 1/3] Match link-target suggestions by name, not just slash paths (#2903) * [US-001] link suggestions: match bare-name queries and demote dot-dir paths in browse order Core matcher no longer requires a leading slash to produce suggestions, and ties (the empty/browse case) rank real content paths ahead of dot-directory files so .github/.changeset entries no longer monopolize the capped list. * [US-002] link suggestions: open the panel for any path target, suppress URLs The link-target field now opens name suggestions as you type (Cmd+K parity), not only for a leading-slash path. Trigger classification is self-contained in the component via isExternalHref + a leading-# check, so external URLs and bare in-doc anchors do not pop a page list. Shared across the internal-link dialog, the wiki-link panel, and the bubble-menu link popover. * [chore] changeset for link-target name suggestions * [review] gate empty-state to strong path signal; browse-only dot-demotion Local review follow-ups: - Only surface the 'No matching paths' empty-state on an empty browse or an explicit leading slash, so typing/pasting a scheme-less URL (example.com) into the dual-purpose field no longer flashes an empty page list. Bare-name matches still surface. Reuses isSlashPathSuggestionValue (no longer dead). - Only compute suggestions when a panel could show, keeping URL input out of the matcher and off the per-keystroke scoring path. - Apply dot-directory demotion to browse ordering only; on a scored query it could push an exact-matched dotfile past the result cap. - Test: a bare name with no match stays silent (no empty-state). * [review] pin AC2 basename ranking for the bare-name form too * [docs] spec for link-target name suggestions * [polish] show full link path as a hover tooltip when the row is truncated The suggestion row truncates long paths with an ellipsis; add a native title tooltip carrying the full /path so a clipped row is still readable on hover. --------- GitOrigin-RevId: d9d51b4d407486b1770ae249e31653e089699ec5 --- .changeset/link-suggest-by-name.md | 7 +++ knip.config.ts | 4 +- .../src/editor/link-path-suggestions-core.ts | 29 ++++++++--- .../editor/link-path-suggestions.dom.test.tsx | 47 ++++++++++++++++++ .../src/editor/link-path-suggestions.test.ts | 48 +++++++++++++++--- .../app/src/editor/link-path-suggestions.tsx | 49 ++++++++++++++----- .../core/src/bridge/bind-frontmatter-doc.ts | 1 + packages/core/src/bridge/bridge-invariant.ts | 1 + .../core/src/bridge/doc-boundary-space.ts | 1 + packages/core/src/bridge/growth-detect.ts | 1 + packages/core/src/bridge/merge-three-way.ts | 1 + packages/core/src/bridge/normalize.ts | 1 + .../src/bridge/pm-structural-equivalence.ts | 3 ++ packages/core/src/bridge/subsequence.ts | 1 + .../core/src/markdown/callout-transformer.ts | 1 + packages/core/src/markdown/code-fence.ts | 1 + .../core/src/markdown/comment-promoter.ts | 1 + .../src/markdown/dedent-block-jsx-close.ts | 1 + .../markdown/details-accordion-promoter.ts | 1 + packages/core/src/markdown/fence-regions.ts | 1 + packages/core/src/markdown/fixtures/index.ts | 6 +++ .../src/markdown/fixtures/perf/generate.ts | 5 ++ .../src/markdown/guard-flanking-matrix.ts | 1 + .../core/src/markdown/handler-shadow-audit.ts | 1 + .../core/src/markdown/highlight-promoter.ts | 1 + packages/core/src/markdown/html-to-mdast.ts | 4 +- packages/core/src/markdown/image-promoter.ts | 1 + packages/core/src/markdown/index.ts | 9 ++++ .../core/src/markdown/lint/config-files.ts | 1 + .../core/src/markdown/lint/config-schemas.ts | 1 + .../core/src/markdown/lint/default-config.ts | 1 + packages/core/src/markdown/lint/index.ts | 1 + packages/core/src/markdown/lint/plugins.ts | 2 + packages/core/src/markdown/lint/types.ts | 2 + packages/core/src/markdown/math-promoter.ts | 1 + .../src/markdown/mdast-to-hast-handlers.ts | 1 + packages/core/src/markdown/mdast-to-html.ts | 1 + packages/core/src/markdown/merged-walker.ts | 1 + .../core/src/markdown/mermaid-promoter.ts | 1 + .../core/src/markdown/parse-with-fallback.ts | 5 ++ .../core/src/markdown/parser-drop-closure.ts | 1 + packages/core/src/markdown/pipeline.ts | 1 + .../core/src/markdown/position-aware-join.ts | 1 + packages/core/src/markdown/position-slice.ts | 4 +- .../rehype-plugins/skip-notion-whitespace.ts | 1 + .../rehype-plugins/strip-cocoa-meta.ts | 1 + .../rehype-plugins/strip-gdocs-wrapper.ts | 1 + .../rehype-plugins/strip-github-hovercard.ts | 1 + .../rehype-plugins/strip-gmail-classes.ts | 1 + .../rehype-plugins/strip-gsheets-wrapper.ts | 1 + .../rehype-plugins/strip-mso-styles.ts | 1 + .../rehype-plugins/strip-slack-classes.ts | 1 + .../rehype-plugins/strip-vscode-spans.ts | 1 + .../core/src/markdown/resolve-image-url.ts | 1 + packages/core/src/markdown/safe-url.ts | 1 + .../serialize-fidelity.test-helper.ts | 1 + .../core/src/markdown/serialize-helpers.ts | 1 + .../markdown/single-dollar-math-promoter.ts | 1 + .../core/src/markdown/to-markdown-handlers.ts | 1 + .../core/src/markdown/unknown-mdast-guard.ts | 1 + .../core/src/markdown/whitespace-char-ref.ts | 1 + .../core/src/markdown/wiki-link-micromark.ts | 3 ++ 62 files changed, 243 insertions(+), 30 deletions(-) create mode 100644 .changeset/link-suggest-by-name.md diff --git a/.changeset/link-suggest-by-name.md b/.changeset/link-suggest-by-name.md new file mode 100644 index 000000000..6bf400b90 --- /dev/null +++ b/.changeset/link-suggest-by-name.md @@ -0,0 +1,7 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Link-target suggestions now match by name as you type, like the command palette. + +When editing a link target (the "Edit markdown link" dialog, the wiki-link panel, and the bubble-menu link popover all share the same field), typing a bare page name such as `install` now opens the suggestion dropdown and surfaces the matching page — a leading `/` is no longer required. Typing or pasting an external URL, or a bare `#anchor`, still does not pop a page list. The empty/browse list also keeps real content pages ahead of dot-directory files (`.github/`, `.changeset/`) so they no longer fill the list on projects rooted at a repository. diff --git a/knip.config.ts b/knip.config.ts index 296751a02..6e3847861 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -121,7 +121,9 @@ export default { 'packages/server': { entry: ['src/**/*.test.ts', 'src/parse-worker.ts'], project: 'src/**', - ignoreDependencies: ['@types/shell-quote'], + ignoreDependencies: [ + '@types/shell-quote', + ], }, 'packages/cli': { entry: ['src/**/*.test.ts', 'scripts/*.ts', 'tests/**/*.ts', 'src/parse-worker.ts'], diff --git a/packages/app/src/editor/link-path-suggestions-core.ts b/packages/app/src/editor/link-path-suggestions-core.ts index 2a9e3fed2..48216f604 100644 --- a/packages/app/src/editor/link-path-suggestions-core.ts +++ b/packages/app/src/editor/link-path-suggestions-core.ts @@ -29,11 +29,6 @@ function normalizeInputPath(value: string): string { .replace(/\/+$/g, ''); } -function slashPathQuery(value: string): string | null { - if (!isSlashPathSuggestionValue(value)) return null; - return normalizeInputPath(value.slice(1)).toLowerCase(); -} - export function isSlashPathSuggestionValue(value: string): boolean { return value.startsWith('/') && !value.startsWith('//'); } @@ -43,6 +38,10 @@ function basename(path: string): string { return parts.at(-1) ?? path; } +function firstSegmentIsDot(path: string): boolean { + return path.split('/')[0]?.startsWith('.') ?? false; +} + function scorePath(path: string, query: string): number | null { if (!query) return 0; const lowerPath = path.toLowerCase(); @@ -92,8 +91,15 @@ function collectSuggestions(options: BuildLinkPathSuggestionsOptions): LinkPathS export function buildLinkPathSuggestions( options: BuildLinkPathSuggestionsOptions, ): LinkPathSuggestion[] { - const query = slashPathQuery(options.value); - if (query === null) return []; + // Any input is a query, not just a leading-slash path — typing a bare name + // matches by basename like the command palette does. Whether to suggest at + // all (path vs external URL vs anchor) is the caller's decision, not this + // pure matcher's. An empty query browses (every candidate scores 0). + const query = normalizeInputPath(options.value).toLowerCase(); + // Dot-demotion is a browse-ordering concern only. On a real query, relevance + // (score) leads; applying it to scored ties could push an exact-matched + // dotfile past the result cap behind equally-scored siblings. + const browsing = query === ''; const scored: ScoredSuggestion[] = []; for (const suggestion of collectSuggestions(options)) { @@ -106,6 +112,15 @@ export function buildLinkPathSuggestions( if (a.score !== b.score) return a.score - b.score; const kindCompare = kindRank(a.suggestion.kind) - kindRank(b.suggestion.kind); if (kindCompare !== 0) return kindCompare; + if (browsing) { + // Rank real content paths ahead of dot-directory files (.github, + // .changeset) — otherwise the browse list's alphabetical fallback fills + // the cap with dotfiles before any content page appears. + const dotCompare = + (firstSegmentIsDot(a.suggestion.path) ? 1 : 0) - + (firstSegmentIsDot(b.suggestion.path) ? 1 : 0); + if (dotCompare !== 0) return dotCompare; + } return a.suggestion.path.localeCompare(b.suggestion.path); }); diff --git a/packages/app/src/editor/link-path-suggestions.dom.test.tsx b/packages/app/src/editor/link-path-suggestions.dom.test.tsx index 9403f7562..ccfa0c4bc 100644 --- a/packages/app/src/editor/link-path-suggestions.dom.test.tsx +++ b/packages/app/src/editor/link-path-suggestions.dom.test.tsx @@ -63,6 +63,8 @@ describe('LinkPathSuggestionInput', () => { expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined(); expect(screen.getByRole('option', { name: '/docs/install Page' })).toBeDefined(); + // Full path is exposed as a hover tooltip so a truncated row is still readable. + expect(screen.getByTitle('/docs/install')).toBeDefined(); }); test('opens project path suggestions when slash input has focus', () => { @@ -77,6 +79,51 @@ describe('LinkPathSuggestionInput', () => { expect(screen.getByRole('option', { name: '/guides/intro Page' })).toBeDefined(); }); + test('opens suggestions for a bare name and lists the matching page', () => { + render(); + + fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' })); + + expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined(); + expect(screen.getByRole('option', { name: '/docs/install Page' })).toBeDefined(); + }); + + test('does not open the panel while typing an external URL', () => { + render(); + + fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' })); + + expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull(); + }); + + test('does not open the panel for a protocol-relative or scheme URL', () => { + render(); + + fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' })); + + expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull(); + }); + + test('does not open the panel for a bare in-doc anchor', () => { + render(); + + fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' })); + + expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull(); + }); + + test('stays silent for a bare name with no match — no empty-state flash', () => { + // A scheme-less value that matches nothing (e.g. a URL-in-progress like + // example.com) must not pop the "No matching paths" empty-state. Only an + // empty browse or an explicit leading slash surfaces that state. + render(); + + fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' })); + + expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + }); + test('shows an empty state for slash input with no matching paths', () => { render(); diff --git a/packages/app/src/editor/link-path-suggestions.test.ts b/packages/app/src/editor/link-path-suggestions.test.ts index 9c49ac8fe..a3fdf5189 100644 --- a/packages/app/src/editor/link-path-suggestions.test.ts +++ b/packages/app/src/editor/link-path-suggestions.test.ts @@ -15,12 +15,24 @@ describe('buildLinkPathSuggestions', () => { const folderPaths = new Set(['docs', 'guides', 'notes']); const assetPaths = new Set(['assets/logo.png', 'guides/demo.mov']); - test('does not suggest paths until the value starts with a single slash', () => { - expect(buildLinkPathSuggestions({ value: 'guides', pages, folderPaths })).toEqual([]); + test('matches a bare name query without requiring a leading slash', () => { + expect(buildLinkPathSuggestions({ value: 'guides', pages, folderPaths })).toEqual([ + { kind: 'folder', path: 'guides' }, + { kind: 'page', path: 'guides/bun' }, + { kind: 'page', path: 'guides/intro' }, + ]); + expect(buildLinkPathSuggestions({ value: 'install', pages, folderPaths })).toEqual([ + { kind: 'page', path: 'docs/install' }, + ]); + }); + + test('returns nothing for a bare query that matches no path', () => { + expect(buildLinkPathSuggestions({ value: 'zzz-nope', pages, folderPaths })).toEqual([]); + // URL-shaped values match no path; suppression of the panel for real URLs + // is the component's job, not this pure matcher's. expect(buildLinkPathSuggestions({ value: 'https://example.com', pages, folderPaths })).toEqual( [], ); - expect(buildLinkPathSuggestions({ value: '//example.com', pages, folderPaths })).toEqual([]); }); test('suggests matching existing page and folder paths after slash input', () => { @@ -61,14 +73,36 @@ describe('buildLinkPathSuggestions', () => { }); test('ranks basename substring matches before full-path-only substring matches', () => { + const ranked = [ + { kind: 'page', path: 'notes/api' }, + { kind: 'page', path: 'guides/api/reference' }, + ]; + // Same ranking whether the query carries a leading slash or not — the query + // is normalized before scoring, so both forms hit the identical code path. expect( buildLinkPathSuggestions({ value: '/api', pages: new Set(['guides/api/reference', 'notes/api']), }), - ).toEqual([ - { kind: 'page', path: 'notes/api' }, - { kind: 'page', path: 'guides/api/reference' }, - ]); + ).toEqual(ranked); + expect( + buildLinkPathSuggestions({ + value: 'api', + pages: new Set(['guides/api/reference', 'notes/api']), + }), + ).toEqual(ranked); + }); + + test('browse ordering keeps content pages ahead of dot-directory files', () => { + const mixed = new Set(['.github/ci', '.changeset/note', 'docs/install', 'guides/intro']); + const browsed = buildLinkPathSuggestions({ value: '', pages: mixed }).map((s) => s.path); + // Every non-dot page appears before any dot-directory page. + const firstDotIndex = browsed.findIndex((p) => p.split('/')[0]?.startsWith('.')); + const lastNonDotIndex = browsed.reduce( + (last, p, i) => (p.split('/')[0]?.startsWith('.') ? last : i), + -1, + ); + expect(lastNonDotIndex).toBeLessThan(firstDotIndex); + expect(browsed.slice(0, 2)).toEqual(['docs/install', 'guides/intro']); }); }); diff --git a/packages/app/src/editor/link-path-suggestions.tsx b/packages/app/src/editor/link-path-suggestions.tsx index b749281d0..ffa01c483 100644 --- a/packages/app/src/editor/link-path-suggestions.tsx +++ b/packages/app/src/editor/link-path-suggestions.tsx @@ -1,4 +1,5 @@ import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'; +import { isExternalHref } from '@inkeep/open-knowledge-core'; import { useLingui } from '@lingui/react/macro'; import { type ComponentProps, @@ -114,25 +115,45 @@ export function LinkPathSuggestionInput({ const panelRef = useRef(null); const listId = useId(); - const emptyTriggered = value.trim() === ''; - const suggestionValue = emptyTriggered ? '/' : value; - const suggestions = buildLinkPathSuggestions({ - value: suggestionValue, - pages, - folderPaths, - assetPaths, - includeAssets, - }); + const trimmedValue = value.trim(); + const emptyTriggered = trimmedValue === ''; + const slashTriggered = isSlashPathSuggestionValue(value); + // Suggest for any internal path target — a bare name matches by basename like + // the command palette, not just a leading-slash path. Suppress for an + // external URL or a bare in-doc anchor, which are valid targets that must not + // pop a page list. + const pathTargetTriggered = !isExternalHref(trimmedValue) && !trimmedValue.startsWith('#'); + const suggestionTriggered = emptyTriggered || pathTargetTriggered; + const suggestionValue = emptyTriggered ? '' : value; + // Only score the page list when a suggestion could actually show — avoids + // re-scoring the whole KB on every keystroke of a URL, and keeps URL-shaped + // input out of the matcher. + const suggestions = suggestionTriggered + ? buildLinkPathSuggestions({ + value: suggestionValue, + pages, + folderPaths, + assetPaths, + includeAssets, + }) + : []; const suggestionsKey = suggestions .map((suggestion) => `${suggestion.kind}:${suggestion.path}`) .join('\u0000'); - const slashTriggered = isSlashPathSuggestionValue(value); - const suggestionTriggered = emptyTriggered || slashTriggered; const showSuggestionOptions = suggestions.length > 0; const showSuggestions = focused && !dismissed && suggestionTriggered && (showSuggestionOptions || loading); + // The "no matching paths" empty-state is louder than a silent field, so only + // surface it on a strong path signal — an empty browse or an explicit leading + // slash. A bare name that matches nothing stays silent, so typing/pasting a + // scheme-less URL (e.g. example.com) into the dual-purpose field never flashes + // an empty page list. const showNoMatches = - focused && !dismissed && suggestionTriggered && !loading && !showSuggestionOptions; + focused && + !dismissed && + (emptyTriggered || slashTriggered) && + !loading && + !showSuggestionOptions; const showSuggestionPanel = showSuggestions || showNoMatches; const activeIndex = Math.min(highlightedIndex, Math.max(suggestions.length - 1, 0)); const activeId = showSuggestionOptions ? `${listId}-option-${activeIndex}` : undefined; @@ -297,7 +318,9 @@ export function LinkPathSuggestionInput({ onClick={() => selectSuggestion(suggestion)} > {suggestionIcon(suggestion)} - /{suggestion.path} + + /{suggestion.path} + {kindLabel} ); diff --git a/packages/core/src/bridge/bind-frontmatter-doc.ts b/packages/core/src/bridge/bind-frontmatter-doc.ts index c014b1413..8f9d95287 100644 --- a/packages/core/src/bridge/bind-frontmatter-doc.ts +++ b/packages/core/src/bridge/bind-frontmatter-doc.ts @@ -1,3 +1,4 @@ + import type * as Y from 'yjs'; import type { Err, Ok, Result } from '../config/result.ts'; import { type FrontmatterValidationError, toFrontmatterIssue } from '../frontmatter/errors.ts'; diff --git a/packages/core/src/bridge/bridge-invariant.ts b/packages/core/src/bridge/bridge-invariant.ts index 005627195..8bfdb40cd 100644 --- a/packages/core/src/bridge/bridge-invariant.ts +++ b/packages/core/src/bridge/bridge-invariant.ts @@ -1,3 +1,4 @@ + import { fnv1aDigest } from './hash-util.ts'; export type BridgeInvariantSite = 'observer-b' | 'persistence' | 'test-harness'; diff --git a/packages/core/src/bridge/doc-boundary-space.ts b/packages/core/src/bridge/doc-boundary-space.ts index 8890f6739..c18c086d5 100644 --- a/packages/core/src/bridge/doc-boundary-space.ts +++ b/packages/core/src/bridge/doc-boundary-space.ts @@ -1,3 +1,4 @@ + import { FRONTMATTER_RE, stripFrontmatter } from '../extensions/frontmatter.ts'; const LEADING_BOUNDARY_RE = /^(?:\r?\n)+/; diff --git a/packages/core/src/bridge/growth-detect.ts b/packages/core/src/bridge/growth-detect.ts index 9d9c244b0..463e89595 100644 --- a/packages/core/src/bridge/growth-detect.ts +++ b/packages/core/src/bridge/growth-detect.ts @@ -1,3 +1,4 @@ + const DEFAULT_MIN_SUBSTANTIVE_LINE_LENGTH = 16; export const DUPLICATION_GATE_MIN_LINE_LENGTH = 8; diff --git a/packages/core/src/bridge/merge-three-way.ts b/packages/core/src/bridge/merge-three-way.ts index 3ab9cc6ef..3c927eb47 100644 --- a/packages/core/src/bridge/merge-three-way.ts +++ b/packages/core/src/bridge/merge-three-way.ts @@ -38,6 +38,7 @@ function mergeThreeWayImpl(baseline: string, userText: string, agentText: string return parts.join('\n'); } + export type BridgeMergeContentLossSide = 'user' | 'agent'; export type BridgeMergeContentLossWhich = 'substring' | 'order' | 'growth'; diff --git a/packages/core/src/bridge/normalize.ts b/packages/core/src/bridge/normalize.ts index 4b8890476..3f0880aa5 100644 --- a/packages/core/src/bridge/normalize.ts +++ b/packages/core/src/bridge/normalize.ts @@ -1,3 +1,4 @@ + const COMMONMARK_ESCAPE_RE = /\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~])/g; const TABLE_ALIGN_ROW_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/; diff --git a/packages/core/src/bridge/pm-structural-equivalence.ts b/packages/core/src/bridge/pm-structural-equivalence.ts index 754dc5cc2..0be5203e4 100644 --- a/packages/core/src/bridge/pm-structural-equivalence.ts +++ b/packages/core/src/bridge/pm-structural-equivalence.ts @@ -32,6 +32,7 @@ const BR_SENTINEL: PmStructuralNode = { type: '__br__' }; const BR_LITERAL_RE = /^$/i; + interface DegradeEntry { readonly label: string; readonly severity: ToleranceClassSeverity; @@ -127,6 +128,7 @@ export interface ComparePmStructuralOptions { ignoreAttrs?: (attrKey: string) => boolean; } + /** Rebuild a node with `fn` applied to each child; identity when childless. * Shared by the degrade canonicalizers so none re-implements the walk. */ function mapChildren( @@ -248,6 +250,7 @@ function applyDegrades(tree: PmStructuralNode): { return { normalized: current, fired }; } + export function comparePmStructural( expected: PmStructuralNode, actual: PmStructuralNode, diff --git a/packages/core/src/bridge/subsequence.ts b/packages/core/src/bridge/subsequence.ts index af20de1e2..dca644141 100644 --- a/packages/core/src/bridge/subsequence.ts +++ b/packages/core/src/bridge/subsequence.ts @@ -1,3 +1,4 @@ + /** True when `needle` is a subsequence of `haystack` (two-pointer, O(n+m)): * insertions in `haystack` are free; any dropped or substituted `needle` byte * fails. */ diff --git a/packages/core/src/markdown/callout-transformer.ts b/packages/core/src/markdown/callout-transformer.ts index 83d47404a..a363dfd54 100644 --- a/packages/core/src/markdown/callout-transformer.ts +++ b/packages/core/src/markdown/callout-transformer.ts @@ -1,3 +1,4 @@ + import type { Blockquote, Paragraph, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/code-fence.ts b/packages/core/src/markdown/code-fence.ts index 9807a7e82..4499dbc92 100644 --- a/packages/core/src/markdown/code-fence.ts +++ b/packages/core/src/markdown/code-fence.ts @@ -1,3 +1,4 @@ + export function selectFenceChar(info: string): '`' | '~' { return info.includes('`') ? '~' : '`'; } diff --git a/packages/core/src/markdown/comment-promoter.ts b/packages/core/src/markdown/comment-promoter.ts index 193e286b0..47f0d07e4 100644 --- a/packages/core/src/markdown/comment-promoter.ts +++ b/packages/core/src/markdown/comment-promoter.ts @@ -1,3 +1,4 @@ + import type { Nodes, Paragraph, PhrasingContent, Root, RootContent, Text } from 'mdast'; import { SKIP, visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/dedent-block-jsx-close.ts b/packages/core/src/markdown/dedent-block-jsx-close.ts index ece5c02a1..b6299a948 100644 --- a/packages/core/src/markdown/dedent-block-jsx-close.ts +++ b/packages/core/src/markdown/dedent-block-jsx-close.ts @@ -1,3 +1,4 @@ + import { findFencedRegions, isInsideFence } from './fence-regions.ts'; const INDENTED_BLOCK_JSX_CLOSE_RE = /^([ ]{1,3})(<\/[A-Z][A-Za-z0-9_]*\s*>)([ \t]*)$/gm; diff --git a/packages/core/src/markdown/details-accordion-promoter.ts b/packages/core/src/markdown/details-accordion-promoter.ts index 54774764a..acd398aa6 100644 --- a/packages/core/src/markdown/details-accordion-promoter.ts +++ b/packages/core/src/markdown/details-accordion-promoter.ts @@ -1,3 +1,4 @@ + import type { Nodes, Paragraph, Parent, Root, Text } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/fence-regions.ts b/packages/core/src/markdown/fence-regions.ts index c7518d614..fb5ac8e8a 100644 --- a/packages/core/src/markdown/fence-regions.ts +++ b/packages/core/src/markdown/fence-regions.ts @@ -1,3 +1,4 @@ + const FENCE_RE = /^(`{3,}|~{3,})/gm; export function findFencedRegions(src: string): Array<[number, number]> { diff --git a/packages/core/src/markdown/fixtures/index.ts b/packages/core/src/markdown/fixtures/index.ts index 00f5771ee..6931623c7 100644 --- a/packages/core/src/markdown/fixtures/index.ts +++ b/packages/core/src/markdown/fixtures/index.ts @@ -1,3 +1,4 @@ + import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +9,7 @@ function fixturePath(...segments: string[]): string { return resolve(FIXTURES_DIR, ...segments); } + interface GfmExample { section: string; markdown: string; @@ -17,6 +19,7 @@ export function loadGfmExamples(): GfmExample[] { return JSON.parse(readFileSync(fixturePath('gfm', 'examples.json'), 'utf8')) as GfmExample[]; } + interface MdxCrashEntry { id: string; input: string; @@ -68,6 +71,7 @@ export function loadLargeEmbedFixtures(): LargeEmbedFixture[] { ) as LargeEmbedFixture[]; } + export function loadPrd6955Before(): string { return readFileSync(fixturePath('regression', 'prd-6955-before.md'), 'utf8'); } @@ -76,6 +80,7 @@ export function loadPrd6955CorruptedTriplicated(): string { return readFileSync(fixturePath('regression', 'prd-6955-corrupted-triplicated.md'), 'utf8'); } + export interface NgPinnedCase { id: string; name: string; @@ -92,6 +97,7 @@ export function loadNgPinnedCases(): NgPinnedCase[] { ) as NgPinnedCase[]; } + export function loadLargeRealistic(): string { return readFileSync(fixturePath('perf', 'large-realistic.md'), 'utf8'); } diff --git a/packages/core/src/markdown/fixtures/perf/generate.ts b/packages/core/src/markdown/fixtures/perf/generate.ts index 8b1eb8519..daa3ef82b 100644 --- a/packages/core/src/markdown/fixtures/perf/generate.ts +++ b/packages/core/src/markdown/fixtures/perf/generate.ts @@ -1,3 +1,4 @@ + import { writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,6 +11,7 @@ const BASELINE_ONLY_COUNTS = [500, 2500] as const; const SEED = 0xf1de1117; + function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { @@ -36,6 +38,7 @@ function pickWeighted(rand: () => number, items: readonly [T, number][]): T { return items[items.length - 1][0]; } + const WORDS = [ 'lorem', 'ipsum', @@ -153,6 +156,7 @@ function mdxBlock(rand: () => number): string { return `<${name}>\n\n${sentence(rand)}\n\n`; } + type BlockKind = 'paragraph' | 'heading' | 'list' | 'code' | 'table' | 'mdx'; const MIX: readonly [BlockKind, number][] = [ @@ -191,6 +195,7 @@ function generateDocument(blockCount: number, seed: number): string { return `${blocks.join('\n\n')}\n`; } + function main(): void { for (const count of [...BLOCK_COUNTS, ...BASELINE_ONLY_COUNTS]) { const doc = generateDocument(count, SEED); diff --git a/packages/core/src/markdown/guard-flanking-matrix.ts b/packages/core/src/markdown/guard-flanking-matrix.ts index 420dc0fe5..32c055c5b 100644 --- a/packages/core/src/markdown/guard-flanking-matrix.ts +++ b/packages/core/src/markdown/guard-flanking-matrix.ts @@ -7,6 +7,7 @@ import { import { BACKSLASH_GUARD_SUBSTITUTIONS, encodeBackslashEscapes } from './backslash-escape-guard.ts'; import { ENTITY_REF_GUARD_SUBSTITUTIONS, encodeEntityRefs } from './entity-ref-guard.ts'; + export const ATTENTION_DELIMITERS = ['*', '_', '**', '~~', '=='] as const; export type FlankClass = 'whitespace' | 'punctuation' | 'other'; diff --git a/packages/core/src/markdown/handler-shadow-audit.ts b/packages/core/src/markdown/handler-shadow-audit.ts index 2653473da..283913255 100644 --- a/packages/core/src/markdown/handler-shadow-audit.ts +++ b/packages/core/src/markdown/handler-shadow-audit.ts @@ -1,3 +1,4 @@ + export interface HandlerShadowWitness { input: string; expect: 'byte' | 'byte-and-reparse-type'; diff --git a/packages/core/src/markdown/highlight-promoter.ts b/packages/core/src/markdown/highlight-promoter.ts index f55912407..6319f264f 100644 --- a/packages/core/src/markdown/highlight-promoter.ts +++ b/packages/core/src/markdown/highlight-promoter.ts @@ -1,3 +1,4 @@ + import type { Nodes, Parent, PhrasingContent, Root, Text } from 'mdast'; import type { MdxJsxTextElement } from 'mdast-util-mdx'; import { SKIP, visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/html-to-mdast.ts b/packages/core/src/markdown/html-to-mdast.ts index b1f252c31..8dc3a81b5 100644 --- a/packages/core/src/markdown/html-to-mdast.ts +++ b/packages/core/src/markdown/html-to-mdast.ts @@ -1,3 +1,4 @@ + import type { Root as HastRoot } from 'hast'; import type { List, Root as MdastRoot } from 'mdast'; import rehypeParse from 'rehype-parse'; @@ -85,7 +86,8 @@ export function htmlToMdast(html: string, options?: HtmlToMdastOptions): MdastRo throw new HtmlPayloadTooLargeError(html.length, maxBytes); } - const processor = unified().use(rehypeParse, { fragment: true }); + const processor = unified() + .use(rehypeParse, { fragment: true }); for (const plugin of cleanupPlugins) { processor.use(plugin); diff --git a/packages/core/src/markdown/image-promoter.ts b/packages/core/src/markdown/image-promoter.ts index 706fa9afb..994eb6917 100644 --- a/packages/core/src/markdown/image-promoter.ts +++ b/packages/core/src/markdown/image-promoter.ts @@ -1,3 +1,4 @@ + import type { Image, Paragraph, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts index 8b36807fd..fc30bda02 100644 --- a/packages/core/src/markdown/index.ts +++ b/packages/core/src/markdown/index.ts @@ -1,3 +1,4 @@ + import { type FromProseMirrorOptions, fromPmMark, @@ -196,6 +197,7 @@ export class MarkdownManager { } } + const registry = createRegistry(); function destructureAttrs( @@ -244,6 +246,7 @@ function destructureAttrs( return result; } + function hasDirtyDescendant(node: PmNode): boolean { let found = false; node.descendants((child) => { @@ -263,6 +266,7 @@ function effectiveDirty(node: PmNode, freshnessChecker?: StructuralFreshnessChec return freshnessChecker ? freshnessChecker.isDiverged(node.toJSON()) : false; } + function isEmptyMdastParagraph(node: MdastNodes): boolean { if (node.type !== 'paragraph') return false; const children = node.children ?? []; @@ -331,6 +335,7 @@ function extractTextFromMdastNodes(nodes: MdastNodes[]): string { return out; } + import { AUDIO_EXTENSIONS, FILE_ATTACHMENT_EXTENSIONS, @@ -577,6 +582,7 @@ function buildMdastToPmHandlers( })); } + if (m.emphasis) { handlers.emphasis = toPmMark(m.emphasis, (node: Emphasis) => ({ sourceDelimiter: node.data?.sourceDelimiter ?? '*', @@ -657,6 +663,7 @@ function buildMdastToPmHandlers( })); } + if (m.link) { const sourceLiteralMark = m.sourceLiteral; handlers.link = (node: Link, _parent: MdastParent, state: MdastToPmState) => { @@ -935,6 +942,7 @@ function buildMdastToPmHandlers( }; } + const blockUnknownHandler = (node: { type: string; position?: { start: { offset: number }; end: { offset: number } }; @@ -1060,6 +1068,7 @@ function buildMdastToPmHandlers( return handlers as RemarkProseMirrorOptions['handlers']; } + function buildPmToMdastHandlers( schema: Schema, freshness: FreshnessCheckerHolder, diff --git a/packages/core/src/markdown/lint/config-files.ts b/packages/core/src/markdown/lint/config-files.ts index 312c9c74b..94b34dee1 100644 --- a/packages/core/src/markdown/lint/config-files.ts +++ b/packages/core/src/markdown/lint/config-files.ts @@ -1,3 +1,4 @@ + const MARKDOWNLINT_JSON_CONFIG_FILES: ReadonlySet = new Set([ '.markdownlint.json', '.markdownlint.jsonc', diff --git a/packages/core/src/markdown/lint/config-schemas.ts b/packages/core/src/markdown/lint/config-schemas.ts index 9291ee4db..d90a7e5ab 100644 --- a/packages/core/src/markdown/lint/config-schemas.ts +++ b/packages/core/src/markdown/lint/config-schemas.ts @@ -29,6 +29,7 @@ const fullPluginShape = Object.fromEntries( LINT_PLUGINS.map((plugin) => [plugin.id, plugin.sliceSchema]), ) as z.ZodRawShape; + export const LinterConfigSchema = z.object({ enabled: z.boolean(), plugins: z.object(fullPluginShape), diff --git a/packages/core/src/markdown/lint/default-config.ts b/packages/core/src/markdown/lint/default-config.ts index d48eea5e4..a51f2ffb0 100644 --- a/packages/core/src/markdown/lint/default-config.ts +++ b/packages/core/src/markdown/lint/default-config.ts @@ -6,6 +6,7 @@ export const DEFAULT_MARKDOWNLINT_CONFIG: Record | undefined, ): Configuration { diff --git a/packages/core/src/markdown/lint/index.ts b/packages/core/src/markdown/lint/index.ts index 754f5e68e..a445cec4c 100644 --- a/packages/core/src/markdown/lint/index.ts +++ b/packages/core/src/markdown/lint/index.ts @@ -1,3 +1,4 @@ + import { LINT_PLUGINS, type LinterConfig } from './plugins.ts'; import type { LintDiagnostic } from './types.ts'; diff --git a/packages/core/src/markdown/lint/plugins.ts b/packages/core/src/markdown/lint/plugins.ts index 1cd40ef3a..65a1b564c 100644 --- a/packages/core/src/markdown/lint/plugins.ts +++ b/packages/core/src/markdown/lint/plugins.ts @@ -1,3 +1,4 @@ + import { z } from 'zod'; import { DEFAULT_MARKDOWNLINT_CONFIG, resolveMarkdownlintConfig } from './default-config.ts'; import { fixMarkdownText, runMarkdownlint } from './markdownlint-runner.ts'; @@ -8,6 +9,7 @@ import { type MarkdownlintSlice, } from './types.ts'; + const MarkdownlintRuleSettingSchema = z.union([ z.boolean(), z.enum(MARKDOWNLINT_RULE_SEVERITIES), diff --git a/packages/core/src/markdown/lint/types.ts b/packages/core/src/markdown/lint/types.ts index 0f756702b..015a94c1c 100644 --- a/packages/core/src/markdown/lint/types.ts +++ b/packages/core/src/markdown/lint/types.ts @@ -1,3 +1,4 @@ + export const LINT_PLUGIN_IDS = ['markdownlint'] as const; export type LintPluginId = (typeof LINT_PLUGIN_IDS)[number]; @@ -53,6 +54,7 @@ export interface MarkdownlintSlice { rules: Record; } + interface RuleOptionSpecBase { key: string; description: string; diff --git a/packages/core/src/markdown/math-promoter.ts b/packages/core/src/markdown/math-promoter.ts index a5b511f25..bd79424c7 100644 --- a/packages/core/src/markdown/math-promoter.ts +++ b/packages/core/src/markdown/math-promoter.ts @@ -1,3 +1,4 @@ + import type { Code, Root } from 'mdast'; import type { Math as MdastMath } from 'mdast-util-math'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; diff --git a/packages/core/src/markdown/mdast-to-hast-handlers.ts b/packages/core/src/markdown/mdast-to-hast-handlers.ts index 679c72ef7..f84967798 100644 --- a/packages/core/src/markdown/mdast-to-hast-handlers.ts +++ b/packages/core/src/markdown/mdast-to-hast-handlers.ts @@ -1,3 +1,4 @@ + import type { Comment, Element, ElementContent, Properties } from 'hast'; import type { FootnoteDefinition, FootnoteReference } from 'mdast'; import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'; diff --git a/packages/core/src/markdown/mdast-to-html.ts b/packages/core/src/markdown/mdast-to-html.ts index 8ce2b2492..df788a80d 100644 --- a/packages/core/src/markdown/mdast-to-html.ts +++ b/packages/core/src/markdown/mdast-to-html.ts @@ -1,3 +1,4 @@ + import type { Element, Root as HastRoot, Text as HastText } from 'hast'; import type { Root as MdastRoot, Text as MdastText } from 'mdast'; import type { Handler } from 'mdast-util-to-hast'; diff --git a/packages/core/src/markdown/merged-walker.ts b/packages/core/src/markdown/merged-walker.ts index bfd7e3109..fa3cfcd86 100644 --- a/packages/core/src/markdown/merged-walker.ts +++ b/packages/core/src/markdown/merged-walker.ts @@ -1,3 +1,4 @@ + import type { Nodes, Parent, Root } from 'mdast'; import { SKIP, visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/mermaid-promoter.ts b/packages/core/src/markdown/mermaid-promoter.ts index 1a897fca8..17ed2b829 100644 --- a/packages/core/src/markdown/mermaid-promoter.ts +++ b/packages/core/src/markdown/mermaid-promoter.ts @@ -1,3 +1,4 @@ + import type { Code, Root } from 'mdast'; import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import { visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/parse-with-fallback.ts b/packages/core/src/markdown/parse-with-fallback.ts index 9864b3b60..7e4a9a48b 100644 --- a/packages/core/src/markdown/parse-with-fallback.ts +++ b/packages/core/src/markdown/parse-with-fallback.ts @@ -168,6 +168,7 @@ export function parseRecursive( } } + interface VFilePlace { offset?: number; start?: { offset?: number }; @@ -186,6 +187,7 @@ function extractErrorOffset(err: unknown): number | undefined { return undefined; } + interface Region { start: number; end: number; @@ -209,6 +211,7 @@ function nearestBlankLineAfter(src: string, offset: number): number | null { return null; } + export interface TagEvent { kind: 'open' | 'close' | 'self-close'; name: string; @@ -355,6 +358,7 @@ function findFallbackRegion(src: string, errorOffset: number): Region { return { start: blockStart, end: blockEnd }; } + interface SourceBlock { src: string; start: number; @@ -452,6 +456,7 @@ function tryPerBlockFallback( }; } + function wholeDocRawText(source: string): JSONContent { return { type: 'doc', diff --git a/packages/core/src/markdown/parser-drop-closure.ts b/packages/core/src/markdown/parser-drop-closure.ts index e6ab4fd57..43d420187 100644 --- a/packages/core/src/markdown/parser-drop-closure.ts +++ b/packages/core/src/markdown/parser-drop-closure.ts @@ -1,3 +1,4 @@ + export type DroppedTokenAdjudication = | { kind: 'format-dof-axis'; diff --git a/packages/core/src/markdown/pipeline.ts b/packages/core/src/markdown/pipeline.ts index 7f2d5adca..e9e507469 100644 --- a/packages/core/src/markdown/pipeline.ts +++ b/packages/core/src/markdown/pipeline.ts @@ -1,3 +1,4 @@ + import { type FromProseMirrorOptions, fromProseMirror, diff --git a/packages/core/src/markdown/position-aware-join.ts b/packages/core/src/markdown/position-aware-join.ts index 5a5e4c42a..f8f5e8530 100644 --- a/packages/core/src/markdown/position-aware-join.ts +++ b/packages/core/src/markdown/position-aware-join.ts @@ -1,3 +1,4 @@ + import type { Join } from 'mdast-util-to-markdown'; import type { Position } from 'unist'; diff --git a/packages/core/src/markdown/position-slice.ts b/packages/core/src/markdown/position-slice.ts index 0f399d34c..e5b4c9eba 100644 --- a/packages/core/src/markdown/position-slice.ts +++ b/packages/core/src/markdown/position-slice.ts @@ -1,3 +1,4 @@ + import type { Nodes, Root } from 'mdast'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; @@ -476,7 +477,8 @@ export function applyPositionSliceToNode( const first = source[startOff]; if (first !== '[' && first !== '<' && !node.title) { node.data.sourceStyle = 'gfm-autolink'; - } else if (first === '[') { + } + else if (first === '[') { const slice = source.slice(startOff, endOff); const closeBracketIdx = slice.lastIndexOf(']('); if (closeBracketIdx !== -1) { diff --git a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts index db10fa54b..ca225ce9b 100644 --- a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts +++ b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts @@ -1,3 +1,4 @@ + import type { Comment, Element, Root, Text } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts index 34068d103..3a2524a9a 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts @@ -1,3 +1,4 @@ + import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts index 93151d43b..2279a0189 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts index 188f02d83..a0f467c42 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts index 4ba2c3418..4e8542a21 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts index 1a74b1272..a3c96b24a 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts @@ -1,3 +1,4 @@ + import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts index de35e7b79..e95cf43d6 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts @@ -1,3 +1,4 @@ + import type { Comment, Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts index b4be1b2ea..ffdb4d5b5 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts @@ -1,3 +1,4 @@ + import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts index 3c8d88b7c..e2a96d1a7 100644 --- a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts +++ b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts @@ -1,3 +1,4 @@ + import type { Element, Root } from 'hast'; import type { Plugin } from 'unified'; diff --git a/packages/core/src/markdown/resolve-image-url.ts b/packages/core/src/markdown/resolve-image-url.ts index cf2c9da8e..0389575ac 100644 --- a/packages/core/src/markdown/resolve-image-url.ts +++ b/packages/core/src/markdown/resolve-image-url.ts @@ -1,3 +1,4 @@ + import { isRelativeUrl } from './safe-url.ts'; function isDevDiagnosticContext(): boolean { diff --git a/packages/core/src/markdown/safe-url.ts b/packages/core/src/markdown/safe-url.ts index 4d3250d3a..8be632ba4 100644 --- a/packages/core/src/markdown/safe-url.ts +++ b/packages/core/src/markdown/safe-url.ts @@ -1,3 +1,4 @@ + export const SAFE_URL_SCHEMES = ['https', 'http', 'mailto', 'tel', 'ftp', 'sms'] as const; const SCHEME_ALT = SAFE_URL_SCHEMES.map((s) => `${s}:`).join('|'); diff --git a/packages/core/src/markdown/serialize-fidelity.test-helper.ts b/packages/core/src/markdown/serialize-fidelity.test-helper.ts index 0dd54070e..96304e7a5 100644 --- a/packages/core/src/markdown/serialize-fidelity.test-helper.ts +++ b/packages/core/src/markdown/serialize-fidelity.test-helper.ts @@ -1,3 +1,4 @@ + import type { JSONContent } from '@tiptap/core'; import type { Nodes } from 'mdast'; import { expect } from 'vitest'; diff --git a/packages/core/src/markdown/serialize-helpers.ts b/packages/core/src/markdown/serialize-helpers.ts index 46cc6a074..542204403 100644 --- a/packages/core/src/markdown/serialize-helpers.ts +++ b/packages/core/src/markdown/serialize-helpers.ts @@ -1,3 +1,4 @@ + import type { Node as PmNode } from '@tiptap/pm/model'; import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { PropDef, SerializeContext } from '../registry/types.ts'; diff --git a/packages/core/src/markdown/single-dollar-math-promoter.ts b/packages/core/src/markdown/single-dollar-math-promoter.ts index be97d4d62..fe73659e6 100644 --- a/packages/core/src/markdown/single-dollar-math-promoter.ts +++ b/packages/core/src/markdown/single-dollar-math-promoter.ts @@ -1,3 +1,4 @@ + import type { PhrasingContent, Root, Text } from 'mdast'; import type { InlineMath } from 'mdast-util-math'; import { SKIP, visit } from 'unist-util-visit'; diff --git a/packages/core/src/markdown/to-markdown-handlers.ts b/packages/core/src/markdown/to-markdown-handlers.ts index 7f6dfcfc3..fd22d2c7b 100644 --- a/packages/core/src/markdown/to-markdown-handlers.ts +++ b/packages/core/src/markdown/to-markdown-handlers.ts @@ -1,3 +1,4 @@ + import type { Nodes, Parents } from 'mdast'; import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { Handle, Info, State } from 'mdast-util-to-markdown'; diff --git a/packages/core/src/markdown/unknown-mdast-guard.ts b/packages/core/src/markdown/unknown-mdast-guard.ts index cd245c38c..c5bfd2386 100644 --- a/packages/core/src/markdown/unknown-mdast-guard.ts +++ b/packages/core/src/markdown/unknown-mdast-guard.ts @@ -1,3 +1,4 @@ + import type { Root as MdastRoot } from 'mdast'; import type { VFile } from 'vfile'; diff --git a/packages/core/src/markdown/whitespace-char-ref.ts b/packages/core/src/markdown/whitespace-char-ref.ts index d578476a7..ac9f5239b 100644 --- a/packages/core/src/markdown/whitespace-char-ref.ts +++ b/packages/core/src/markdown/whitespace-char-ref.ts @@ -1,3 +1,4 @@ + const INLINE_WHITESPACE_BY_CODE: ReadonlyMap = new Map([ [0x20, ' '], [0x09, '\t'], diff --git a/packages/core/src/markdown/wiki-link-micromark.ts b/packages/core/src/markdown/wiki-link-micromark.ts index 6dad26200..afd9936fe 100644 --- a/packages/core/src/markdown/wiki-link-micromark.ts +++ b/packages/core/src/markdown/wiki-link-micromark.ts @@ -17,6 +17,7 @@ declare module 'micromark-util-types' { } } + const CODE_BANG = 33; // ! const CODE_LBRACKET = 91; // [ const CODE_RBRACKET = 93; // ] @@ -260,6 +261,7 @@ export function wikiLinkSyntax(): Extension { }; } + function enterWikiLink(this: CompileContext, token: Token) { this.enter( { @@ -385,6 +387,7 @@ export const wikiLinkToMarkdown: { unsafe: [{ character: '[', inConstruct: ['phrasing'] }], }; + const MICROMARK_EXT = wikiLinkSyntax(); export function remarkWikiLink(this: Processor) { From f975010a3f8bcdb066194d68b177fc7a323891e5 Mon Sep 17 00:00:00 2001 From: miles-kt-inkeep <135626743+miles-kt-inkeep@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:54:59 -0400 Subject: [PATCH 2/3] feat: pull-only sync mode (one-directional GitHub sync) (#2857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * spec: pull-only sync mode (approved; audit-corrected) Adds the finalized spec, evidence trail, and meta artifacts for the pull-only (one-directional) sync mode: config mode knob, engine mode, B/B1 follower posture, one-shot pull contract, consent surfaces, substrate build items S1-S6. Decision log D1-D15; overnight decision journal included for morning review. * [US-001] Add autoSync.mode single sync knob with legacy-enabled derive Introduce autoSync.mode ('off' | 'pull' | 'full' | null) as the canonical per-machine sync knob and the single source of truth for whether a project pushes (only 'full' pushes). A config with no mode key derives its mode from the legacy enabled boolean (true becomes full, false becomes off), so old and new config shapes coexist with no migration. Widen the committed autoSync.default seed to the same vocabulary while keeping the legacy boolean seed valid. - New packages/core/src/config/auto-sync-mode.ts holds the mode vocabulary and the layer-resolution rules: legacy derive, committed-default translate, per-machine precedence, and effective-mode composition. - schema.ts registers the mode enum leaf and the widened default union with .register before the nullable/default wrappers; the exhaustive field-registry coverage test picks up the new leaf, proving the ordering. - server-factory boot resolution resolves the effective mode and returns mode === 'full', byte-identical for legacy enabled/default configs; pull resolves to off until the engine grows a real mode. - Widen the onboarding-gate input type to accept the mode vocabulary; gate behavior is unchanged. Tests cover parse of mode/default/enabled, the derive and precedence truth tables, the cross-version pin (an old mode-unaware schema reads a mode-only config as sync-off and never pushes), and unknown-key write-back preservation. * [US-002] Engine sync-mode enum, single push gate, mode-aware denied handling Replace the SyncEngine's syncEnabled boolean with a mode enum (off/pull/full) as the single source of truth. setMode is the primary API; setEnabled stays as a back-compat adapter (true maps to full, false to off) with the idempotent same-value early-return preserved. Push is suppressed by one authoritative gate at the runPushCycle entrance, plus an early-return in schedulePush so a pull-only project never even schedules a push cycle. A denied push probe now pauses only full mode; a pull-only follower treats denial as its normal condition and keeps fetching. Add an additive syncMode field to SyncStatusSchema (optional for version-skew safety), the engine status interface, and getStatus, with syncEnabled derived as mode not equal to off. server-factory resolves the effective mode and threads it into the engine constructor and the config-reapply path. The app status hook mirrors the new optional field. * [US-003] Add sync-wired test harness with real origin and engine Give the integration harness a way to boot a server whose SyncEngine is attached to a real remote, so downstream stories can drive the composed fetch, fast-forward, CRDT-import, status chain in-process instead of against a stubbed engine. createSyncWiredTestServer builds a bare origin repo, a persistent author clone that seeds and pushes origin/main, and a follower clone (the server content dir) carrying the origin remote plus a project-local autoSync.mode. The engine picks up that mode through the existing config-to-engine boot path, so the real resolution chain is exercised. The helper returns the attached engine, a pushToOrigin authoring helper, and the origin path, and wraps cleanup to remove every scratch directory. ServerOptions gains optional pullIntervalSeconds and pushIntervalSeconds, forwarded to the SyncEngine constructor. These are additive test-tuning knobs, matching the existing debounce and commitDebounceMs pattern; the engine keeps its 30s and 60s defaults when they are omitted, so no existing consumer changes behavior. The sync-wired harness parks them far out so a background cadence never races the scenario a test drives explicitly. A new smoke test proves the composed chain end to end: an upstream commit fast-forwards onto disk, loads into a connecting client's live document, and settles the engine status. A second test proves pull-only mode attaches to the remote and fast-forwards without a push side. * [US-004] Pull-only B1 fast-forward cycle with document-layer overlay In pull mode the pull cycle now fast-forwards to origin tip without ever committing, merging, stashing, or leaving a MERGE_HEAD. Local uncommitted edits ride as a working-tree overlay: non-overlapping files pass through the FF, byte-identical overlays converge silently, overlapping edits are restored then re-applied keep-mine on the new tip, and an uncommitted deletion the tip modifies is re-applied (not resurrected). Diverged local history pauses instead of merging. A direct-exec fast-forward exposes git's exit code so refusals classify as divergence vs overlay-overlap, pinned against real git. * [US-005] B1 conflict substrate: working-tree variant, auto-combine, no-commit resolution Reconcile a pull-only overlay against the fast-forwarded tip per content file: different-line edits auto-combine into a new overlay, same-line collisions keep the local edit and raise a working-tree conflict the existing resolver serves, and every path never commits on the user's behalf. - core: add tryLineLevelCombine, reusing the diff3Merge primitive but reporting same-line collisions instead of character-merging them. - conflict-storage: add ConflictEntry.variant 'working-tree' and a parallel resolveWorkingTreeConflict that writes the chosen bytes without any git checkout/add/commit; the merge-native path is unchanged. - sync-engine: doPullCycleB1 plans overlap reconciliation (content-gated auto-combine vs collision with pinned theirs/base blobs), stays idle so the rest of the repo keeps fast-forwarding, re-pins or auto-dissolves existing conflicts each pull, and fires a resolved callback on resolution. - api-extension: conflict-content serves theirs/base from the pinned blobs and ours from the live doc or disk for working-tree entries. - server-factory: clear the doc conflict lifecycle when a working-tree conflict resolves or auto-dissolves (keep-mine changes no disk bytes). - boot + engine reconciliation tolerate working-tree entries: a no-MERGE_HEAD reconcile clears only merge-native conflicts and never pauses the engine. * [US-006] One-shot pull with bounded outcome observability Add a one-shot pull (trigger op 'pull') that runs in every mode and reports a bounded outcome, the primitive spec B's receive surfaces consume. - doPullCycle and doPullCycleB1 now return a PullOutcome at every exit (succeeded, up-to-date, conflict, refused, error-class). A single recordPullOutcome writes additive lastPullUtc + lastPullOutcome and fires the sync-status CC1 signal at every pull completion, background or one-shot, so lastPullUtc changes each time (change-detection contract). - New pullOnce() runs in every mode: it refuses (recording 'refused') only for single-flight, a pending conflict, or no remote, and for an off/null project restores its resting state so a one-shot never leaves the project enabled or starts a background loop. trigger('pull') routes through it. The full-mode merge path stays byte-identical. - PullOutcomeSchema in core sync-seed plus additive lastPullUtc and lastPullOutcome on SyncStatusSchema and the app GitSyncStatus mirror, both optional and nullable for version skew. - Tests: core schema coverage, eight engine one-shot scenarios on real bare-origin clones (mode-off no-enable, up-to-date, conflict, single-flight refused, unreachable-remote, change-detection, no-remote), and a composed HTTP test that POSTs trigger op 'pull' and polls status for the outcome. * [US-007] Mode transitions: stranded-commit overlay conversion and upgrades Entering pull-only with local commits ahead of origin now folds those commits into a working-tree overlay and realigns the branch, instead of parking on a diverged-local-commits pause. A --mixed reset to the merge base moves the branch ref and index without touching the working tree, so on-screen docs are byte-identical: ahead-only lands directly on origin's tip, a diverged branch lands on the common ancestor with the local edits as overlay and the next fast-forward cycle carries it the rest of the way per the existing B1 reconciliation. The pre-conversion tree is checkpointed to the recoverable timeline before the ref moves. Full->pull downgrades drain an in-flight push first (soft-disable semantics); full mode keeps stranded commits to push and skips conversion; pull->full needs nothing special (the overlay is uncommitted content the push cycle commits and pushes). Adds a public probeUnpushedCommitCount for the downgrade-confirm stranded count, and wires the checkpoint callback to a shadow-repo safety checkpoint in server-factory. * [US-008] Pull-only consent surfaces: onboarding fork + mode writers Fork the auto-sync onboarding prompt on the push-permission probe: allowed keeps today's full-sync prompt, denied gets a truthful pull-only variant, unknown/pending suppress. The gate now resolves the answered state from autoSync.mode (falling back to the legacy enabled boolean) and returns the prompt variant instead of a boolean. Add useSyncModeWriter that writes autoSync.mode on the project-local binding; the onboarding dialog uses it (pull, full, or off). The legacy useSyncEnabledWriter stays as-is so the share-receive surfaces and their tests are untouched; the engine still honors the legacy key. Give AutoSyncEnableWarning, the onboarding dialog, and the enable-confirm dialog a pull-only variant with truthful copy (updates flow in, edits stay local, overlapping edits pick a side). The confirm dialog gains a stranded-commit disclosure for the full to pull downgrade. Disclose silent enablement: a committed default of pull fires a one-time notice on a never-asked machine (full and off stay silent), and a new worktree inherits the parent's mode with the existing one-time notice. Regenerate the en and pseudo Lingui catalogs. * [US-009] Settings three-way sync mode control and paused-notice switch action * [US-010] Badge: pull-only always visible with distinct following states * [US-011] Auth-conditional pull cadence for pull-only followers Anonymous pull-only followers now schedule background pulls at a gentler floor (>=180s) while signed-in followers keep the responsive interval; full sync cadence is unchanged. The engine resolves its credential tier from the same gh, token-store, anonymous order the push probe uses, refreshed before each pull-mode schedule. Jitter and counted backoff are unchanged in both branches; cadence values stay hard-coded. * [US-012] Bounded telemetry for sync mode, one-shot outcomes, and overlay stock Add structured pino telemetry across the pull-only sync surfaces, all bounded enums or numbers so nothing blows up log cardinality: - setMode logs the mode change with a bounded source (config, committed-default, worktree-inherit). The prompt, Settings, and paused-notice surfaces all write the same per-machine mode, so the server cannot tell them apart and they collapse to config. - pullOnce logs its mode and outcome, so one-shot pulls are observable without the noise of a per-background-cycle log. - The pull-only fast-forward log carries the conflict-lifecycle counts (created, auto-combined, auto-dissolved) plus an overlay-stock gauge (docs carrying a standing overlay), which the per-pull rates miss. - Working-tree conflict resolution logs the chosen strategy. - Boot logs the active mode and its source so committed-default activations show up alongside runtime changes. - The desktop worktree seed logs the inherited mode under the worktree-inherit source, the only site that knows that origin. The overlay-stock read is best-effort so a transient git failure never turns a completed pull into an error. * [US-013] Corrigendum, changeset, and i18n sweep for pull-only sync Append the post-ship corrigendum breadcrumb to every NG3 occurrence in the 2026-05-26 read-only-mode spec, plus its Future Work entry for the same deferral, recording that NG3 read-only-fetch shipped as pull-only sync mode. Add the pull-only sync mode changeset (minor per the pre-1.0 convention). Confirm the en and pseudo Lingui catalogs are current (no diff; pseudo carries the new pull-only strings). Fix two latent defects the first full pnpm check surfaced (prior iterations ran tiers in isolation and never hit md-conformance or the integration meta-tests): - Regenerate the md-audit ng-anchors catalog, whose testFileCount was stale after the feature added a test file under the audit's test globs. - Convert SettingsDialogBody.sync-mode.dom.test.tsx from a dynamic @testing-library/react import to a static one so it satisfies the Tier-3 filename contract; it is a genuine DOM mount test. * test: structural guard confining legacy autoSync.enabled reads to the derive path * qa: keep conflict lifecycle set while the engine still holds the conflict A B1 pull-only conflict pull fast-forwards the working tree via a raw git write (outside writeTracker), which the file-watcher re-emits as an 'update'. That reconcile computes noop (base==ours==theirs, conflict content already applied) and clearLifecycleConflict wiped the lifecycle.status the pull had just set — desyncing the editor->resolver swap from the ConflictStore. The resolver appeared briefly then vanished on the next pull and never returned on reopen, while conflictCount stayed > 0. Guard clearLifecycleConflict so it never clears while the engine still holds a standing conflict for that doc; clearing is correct only once the engine agrees the conflict is gone (resolve / auto-dissolve). No commit is ever created, so D5 holds. Exports entryMatchesDocName from conflict-lifecycle-seed for reuse. Also stabilizes the previously-flaky sync-wired-harness same-line-collision integration test (same root cause). * qa: fix lint in the autoSync.enabled confinement guard test * fixup! local-review: baseline (pre-review state) * fixup! local-review: address findings (pass 1) * fixup! local-review: address findings (pass 1) * spec: overnight ship journal and changelog for pull-only sync mode * spec: close A1 wired-token cell after owner verification of denied-push pull * fix(app): follower sync surfaces state the mode, not the push verdict Two follower-facing UX corrections surfaced in review of the shipped Settings/badge surfaces: - Badge popover: a pull-only project now reads "Pull-only sync — updates flow in from your remote…" instead of "You don't have permission to push to this repo." A follower never pushes, so the push-permission verdict is irrelevant; the signed-out reconnect line is likewise suppressed for followers (push-framed). - Settings mode control: the Full option is disabled for a genuine read-only denial (selecting it would only enable, fail to push, and pause). Signed-out denial keeps Full enabled — that user may have push access once authenticated. Off and Pull-only stay reachable in both. * fix(app): tooltip on the disabled Full sync option explains the push denial A native title on a disabled button is suppressed by most browsers (no pointer events), so hovering the greyed-out Full option showed nothing. Wrap it in the shadcn Tooltip via a wrapper span that still receives hover, surfacing "You don't have permission to push to this repo." Keyboard users get the same reason from the read-only hint text. * feat(app): drop the committed-default activation toast (D15 reopened) Owner reopened D15 and chose symmetric silence: a committed autoSync.default is silently effective for every mode with no one-time notice. The toast disclosed only the safe mode (pull-only, which never egresses local edits) while a committed 'full' default — which auto-commits and pushes local edits — stayed silent, so the disclosure ran the wrong way round. Rather than extend it to full, remove it: the committed default is the maintainer's call and simply takes effect (still suppresses the onboarding prompt; gate unchanged). Deletes use-committed-default-autosync-notice + its test and the EditorPane mount; the committedDefaultNoticeShown loose key is no longer written. Worktree-inheritance notice is untouched. Spec FR9/D15 corrigendum'd; changeset + catalogs updated. * fix(server): checkpoint the overlay before a B1 fast-forward reset Closes the pull-only crash window the spec's failure-mode table calls out as mandatory: when a background pull finds an overlapping uncommitted edit, it resets that path to HEAD before the fast-forward, so between the reset and the overlay re-write the bytes live only in memory. A hard crash there would lose them for a doc with no live client (an open doc is covered by client CRDT persistence). New checkpointBeforeOverlayRestore engine seam fires before the reset, only on overlap pulls, best-effort (mirrors convertStrandedCommitsToOverlay); server-factory wires it to the shadow-repo safetyCheckpoint so the pre-reset bytes survive on the shadow timeline. 3 engine tests incl. an ordering proof that the checkpoint sees the overlay still on disk. * feat(server): pull-only follows upstream on delete/modify In pull-only mode a locally-deleted content doc that origin modified is now restored from origin rather than surfaced as a delete/modify conflict — the follower tracks upstream and a deletion has no authored content to lose, so the deletion intent yields to the remote's change. This deliberately reverses the 'never silently resurrect a deletion' guard for pull-only content docs (owner directive). Scope: the B1 planOverlapReconciliation deletion branch only. Non-content (config/asset) deletions still stay deleted; full-sync's merge-native delete/modify conflict is unchanged. Adds a bounded autoRestored count to the pull telemetry. 2 engine tests flipped, 1 added; SPEC FR3/Q6 corrigendum'd. * feat(app): redesign the sync badge popover — active/paused switch + mode toggle The header Switch now means active-vs-paused, and a separate Full/Follow control (shown only for push-capable users) sets the mode. Turning the Switch off pauses instead of hiding the badge: the popover shows a paused state and the mode stays editable. Pausing persists as mode:'off' plus a new additive autoSync.resumeMode memory, so an older app reads a paused project as not-syncing and never pushes for it. Entering Full always confirms; resuming/staying on Follow never pushes. The manual action is a single 'Sync' label (op pushes only when full+active) and is hidden only for a never-enabled project. Behind-arrow dropped in the paused state (no fetch while paused). Adds core helpers (isSyncPaused/hasEverEnabledSync/displayActiveMode/ resumeModeOf + SYNC_ACTIVE_MODES) and useBadgeSyncControls. 36 badge dom tests (6 rewritten, 3 added); full app dom tier green (2253). * feat(app): rename the user-facing sync mode 'Pull-only' to 'Follow' Non-git users read 'Follow' more clearly than 'Pull-only'. Sweeps the visible strings across Settings, onboarding, the enable/confirm dialogs, and the worktree-inheritance notice; dom-test assertions updated to match. Internal identifiers and the config value are renamed in a follow-up. * refactor: rename the sync mode value 'pull' to 'follow' Completes the Pull-only -> Follow rename by renaming the internal config/wire value and code identifiers to 'follow', so the codebase matches the user-facing label (and the existing 'following' naming). SYNC_MODES is now ['off','follow','full']. The earlier 'pull' value is a permanently-accepted read alias (normalizeStoredMode + STORED_SYNC_MODES), so on-disk configs — including local test projects — keep working with no migration. The git operation 'pull' (trigger op, doPullCycle, git pull, PullOutcome) is deliberately unchanged — it's a git verb, not the mode. Docs, changeset, and configuration reference 'follow'; the spec keeps its 'pull-only' design-record terminology with a top corrigendum. Core config 348, server sync suites, app dom 2253, integration harness all green. * test: wire SyncModeSchema accepts 'follow' (mode value rename) * test: onboarding gate returns the 'follow' variant (mode rename) * fix(desktop): normalize legacy 'pull' mode in worktree autosync inheritance asModeOrBool/resolveRootAutoSyncMode used isSyncMode, which no longer matches the legacy 'pull' value after the mode rename — a root config carrying mode:'pull' would have failed to inherit. Route through normalizeStoredMode so a stored 'pull' resolves to 'follow' (the tests now seed 'pull' and assert 'follow', exercising the alias). * fix(app): add a divider above the sync popover metadata and widen it Matches the mockup: a separator line between the mode description and the Repository/Last update/Mode rows, and w-64 -> w-80 so the description reads on two lines instead of wrapping tight. * fix(ok): address pull-only sync review findings - Surface a conflict-store persist failure after fast-forward as an error outcome instead of a clean conflict (save/addConflict/removeConflict now report success; doPullCycleB1 backs off when a persist fails). - Wrap planOverlapReconciliation so a thrown cat-file failure routes through handleError and backs off instead of re-firing at the base interval. - Re-assert realpath containment before the working-tree 'theirs' write after the cat-file await, and add the realpath guard plus traced write to the merge-native 'content' resolution. - Guard the working-tree conflict invariant: skip a collision whose theirs blob cannot be pinned, and reject a blob-less working-tree entry at addConflict. - Rename the lastPullOutcome value 'error-class' to 'error'. - Broaden the enabled-read confinement regex to catch bracket-indexed access and document the destructuring blind spot. - Add tests for the onContentConflictsResolved auto-dissolve callback and the mineRestore overlay-restore after a fast-forward refusal. * chore(ok): clean up pull->follow rename leftovers Follow-up to the mode rename: update stale JSDoc/comments that still cited the legacy 'pull' value (sync-seed SyncModeSchema + SyncDefaultWriter docs, core barrel comment, useSyncModeWriter doc, EnableSyncConfirmDialog docs) and rename the four settings-sync-*-pull data-testids to -follow so Settings matches the badge popover's 'follow' selectors. Test selectors updated in lock-step. * fix(app): sync badge popover polish and resume-flicker fix - keep the badge mounted while a resume-from-paused is in flight: the local config flips to an active mode before the server status catches up, so the popover no longer closes and the icon no longer flickers on resume - switch the Full/Follow mode toggle to the shared segmented control - move the last-sync timestamp into the footer (bottom-left) beside the Sync button (bottom-right); label it Synced (full) / Updated (follow) - right-align the repository link to match the mode and footer rows * udpate docs * update docs sync section --------- GitOrigin-RevId: 4209e45c1b9773df592d213bc077554970000b80 --- .changeset/pull-only-sync-mode.md | 22 + docs/content/features/github-sync.mdx | 37 +- docs/content/features/share.mdx | 2 +- docs/content/reference/configuration.mdx | 7 +- .../src/components/AutoSyncEnableWarning.tsx | 159 +- .../AutoSyncOnboardingDialog.dom.test.tsx | 83 +- .../components/AutoSyncOnboardingDialog.tsx | 36 +- .../src/components/EditorPane.dom.test.tsx | 66 +- packages/app/src/components/EditorPane.tsx | 12 +- .../EnableSyncConfirmDialog.dom.test.tsx | 73 + .../components/EnableSyncConfirmDialog.tsx | 36 +- .../components/SyncStatusBadge.dom.test.tsx | 252 ++- .../app/src/components/SyncStatusBadge.tsx | 480 ++-- .../auto-sync-onboarding-gate.test.ts | 114 +- .../components/auto-sync-onboarding-gate.ts | 83 +- .../src/components/bottom-composer-gate.ts | 2 +- .../SettingsDialogBody.sections.dom.test.tsx | 140 +- .../SettingsDialogBody.sync-mode.dom.test.tsx | 330 +++ .../src/components/settings/SyncSection.tsx | 345 ++- .../use-enable-sync-with-confirm.dom.test.tsx | 226 +- .../src/hooks/use-enable-sync-with-confirm.ts | 295 ++- packages/app/src/hooks/use-git-sync-status.ts | 23 +- .../use-worktree-autosync-notice.dom.test.tsx | 47 +- .../hooks/use-worktree-autosync-notice.tsx | 44 +- packages/app/src/locales/en/messages.json | 96 +- packages/app/src/locales/en/messages.po | 212 +- packages/app/src/locales/pseudo/messages.json | 96 +- packages/app/src/locales/pseudo/messages.po | 202 +- .../integration/sync-wired-harness.test.ts | 341 +++ .../app/tests/integration/test-harness.ts | 187 +- packages/core/src/bridge/index.ts | 1 + packages/core/src/bridge/merge-three-way.ts | 27 + .../config/auto-sync-mode-confinement.test.ts | 96 + .../core/src/config/auto-sync-mode.test.ts | 124 ++ packages/core/src/config/auto-sync-mode.ts | 176 ++ .../core/src/config/field-registry.test.ts | 18 +- .../core/src/config/merge-layered.test.ts | 2 +- .../core/src/config/schema-jsonschema.test.ts | 25 + packages/core/src/config/schema-leaf.test.ts | 30 + packages/core/src/config/schema.test.ts | 122 ++ packages/core/src/config/schema.ts | 89 +- .../src/config/write-config-patch.test.ts | 28 + packages/core/src/index.ts | 30 + .../core/src/schemas/api/sync-seed.test.ts | 73 + packages/core/src/schemas/api/sync-seed.ts | 46 + .../main/worktree-autosync-inherit.test.ts | 112 +- .../src/main/worktree-autosync-inherit.ts | 98 +- packages/server/src/api-extension.ts | 93 + .../server/src/boot-conflict-restore.test.ts | 75 +- packages/server/src/boot.ts | 42 +- .../server/src/conflict-lifecycle-seed.ts | 2 +- packages/server/src/conflict-storage.test.ts | 98 + packages/server/src/conflict-storage.ts | 134 +- packages/server/src/server-factory.test.ts | 24 +- packages/server/src/server-factory.ts | 132 +- packages/server/src/symlink-guard.test.ts | 80 + packages/server/src/symlink-guard.ts | 115 + packages/server/src/sync-engine.test.ts | 1941 ++++++++++++++++- packages/server/src/sync-engine.ts | 1321 ++++++++++- packages/server/src/sync-timing.test.ts | 24 +- packages/server/src/sync-timing.ts | 30 + 61 files changed, 8254 insertions(+), 1002 deletions(-) create mode 100644 .changeset/pull-only-sync-mode.md create mode 100644 packages/app/src/components/EnableSyncConfirmDialog.dom.test.tsx create mode 100644 packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx create mode 100644 packages/app/tests/integration/sync-wired-harness.test.ts create mode 100644 packages/core/src/config/auto-sync-mode-confinement.test.ts create mode 100644 packages/core/src/config/auto-sync-mode.test.ts create mode 100644 packages/core/src/config/auto-sync-mode.ts create mode 100644 packages/server/src/symlink-guard.test.ts create mode 100644 packages/server/src/symlink-guard.ts diff --git a/.changeset/pull-only-sync-mode.md b/.changeset/pull-only-sync-mode.md new file mode 100644 index 000000000..7effab3a7 --- /dev/null +++ b/.changeset/pull-only-sync-mode.md @@ -0,0 +1,22 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Add Follow — a one-directional sync mode for people who follow a knowledge base they can read but not push to (share-link recipients and read-only followers). + +Previously, opening a repository you lacked push access to left your copy silently frozen: the sync onboarding prompt was suppressed, no sync config was written, and the engine parked itself the moment a push was denied. Follow fixes that dead end. + +What it does: + +- Keeps your local copy current automatically by fast-forwarding to the latest on GitHub, without ever pushing or committing on your behalf. +- Lets your own uncommitted edits ride safely on top of incoming updates. Non-overlapping changes combine automatically; edits to the same lines the upstream changed surface in the familiar conflict view, where you choose keep-mine or take-theirs. Your branch always tracks the upstream tip and never forks. +- Is always opt-in. Push-denied users are now offered Follow at first open with plain-language copy ("updates flow in from GitHub; your edits stay on this computer"), and anyone can choose Off / Follow / Full in Settings. A project owner can commit `autoSync.default: follow` so a fresh clone starts following. + +Also included: + +- Full-sync users who lose push access now get a "Switch to Follow" action on the sync-paused notice instead of a silent stall; switching keeps any unshared local commits as a local overlay. +- The sync status badge stays visible for a followed project and shows a distinct following state (updating / up to date / conflict / offline). +- Anonymous followers of public repositories poll more gently than signed-in followers. +- A one-shot "Sync" pull that reports its outcome, for surfaces that need an on-demand refresh. + +Configuration note: the sync preference is now stored as a single `autoSync.mode` (`off` / `follow` / `full`). Existing `autoSync.enabled` settings keep working, and choosing a mode clears the legacy flag so the two can't disagree across versions. An app that predates `autoSync.mode` then reads the config as unanswered and re-prompts rather than acting on a stale toggle, so it never pushes for a mode you didn't consent to there. diff --git a/docs/content/features/github-sync.mdx b/docs/content/features/github-sync.mdx index 254ba3c1f..09f3648d6 100644 --- a/docs/content/features/github-sync.mdx +++ b/docs/content/features/github-sync.mdx @@ -3,10 +3,18 @@ title: GitHub sync icon: custom/GitHub description: Clone repos from GitHub, auto-sync changes with your team, and resolve conflicts. --- -OpenKnowledge can connect a project to a GitHub remote repository and keep it synced over time. When GitHub sync is enabled, OpenKnowledge actively pulls commits from the remote and pushes commits back. + +import { Cloud } from 'lucide-react'; + +OpenKnowledge can connect a project to a GitHub remote repository and keep it synced over time. When GitHub sync is enabled, OpenKnowledge keeps your copy current with the remote. + +There are two modes for GitHub sync: + +- **Full** pulls commits from the remote and pushes your commits back. +- **Follow** fetches updates from the remote and never pushes or commits on your behalf. - Only enable GitHub sync for repositories where you are comfortable with OpenKnowledge writing commits to the remote history. If you are worried about automated commits cluttering your Git history, do not enable sync for that repository. + Only enable full GitHub sync for repositories where you are comfortable with OpenKnowledge writing commits to the remote history. If you are worried about automated commits cluttering your Git history, do not enable full sync for that repository. ## What GitHub sync is for @@ -27,7 +35,7 @@ These steps use the desktop app; from a terminal, [`ok clone`](/docs/reference/c ### Open the clone dialog - From the Navigator window, click **Clone from GitHub**. You can open the navigator window from the editor by clicking on your project name in the bottom left and then clicking **Switch project**. + From the Navigator window, click **Clone from GitHub**. You can open the Navigator window from the editor by clicking on your project name in the bottom left and then clicking **Switch project**. @@ -57,19 +65,30 @@ This will open a dialog where you can choose an owner and repository name. You c ## Enable GitHub sync -When you open a freshly cloned project, you will be prompted to enable sync. You can also toggle it from the project settings page at any time. +When you open a freshly cloned project, you will be prompted to enable sync. If you have full access to the repository, you will be prompted to enable **Full** sync. If you have read only access, you will be prompted to enable **Follow** sync. You can change the sync mode from the sync popover (the icon) or from **Settings → Sync** at any time. + +If you want to set a default sync setting for future collaborators, go to the project settings page and navigate to the **Sync** → **Shared default** section. This writes [`autoSync.default`](/docs/reference/configuration) — one of `off`, `follow`, or `full` — to the project's committed `.ok/config.yml`, pre-answering the enable-sync prompt for everyone who clones the repo; each machine's own auto-sync choice overrides it. -While sync is active, OpenKnowledge: +### Full + +With full sync active, OpenKnowledge: - Fetches commits from the remote - Commits your edits locally using the configured Git identity - Pushes those commits back to the remote so collaborators see your changes -If your Git identity isn't set, sync still commits under a default "OpenKnowledge" author; the sync status indicator reminds you to set one so teammates see your name. +If your Git identity isn't set, OpenKnowledge commits under a default "OpenKnowledge" author; the sync status indicator reminds you to set one so teammates see your name. Pulls can overwrite uncommitted local file changes, so commit or discard work-in-progress before you enable sync. -If you want to set a default auto-sync setting for future collaborators, go to the project settings page and navigate to the **Sync** -> **Shared default** section. This writes [`autoSync.default`](/docs/reference/configuration) to the project's committed `.ok/config.yml`, pre-answering the enable-sync prompt for everyone who clones the repo; each machine's own auto-sync choice overrides it. +### Follow + +With follow sync active, OpenKnowledge: + +- Fetches commits from the remote +- Never commits or pushes your edits to the remote + +Your own edits stay on this computer. You can keep editing a followed project: when an update arrives, non-overlapping changes combine automatically, and an edit to the same lines the remote changed surfaces in the [conflict view](#resolving-a-conflict) where you choose which side to keep. Your branch always tracks the remote tip and never forks. ## Authentication @@ -104,7 +123,7 @@ A few situations stop sync from completing successfully. In each case the sync s ### Conflicts and pending changes - **Unresolved conflicts.** If even one file has a conflict between your edits and your team's, sync waits. You can keep working on every other doc; only the conflicted files are frozen until you choose a side from the diff view. See [Resolving a conflict](#resolving-a-conflict) above. -- **Local changes would be overwritten by an incoming update.** A teammate's update is ready to pull, but a file it touches has uncommitted local changes. Doc edits are committed automatically before merging, so this usually means other tracked files, like configs. Sync pauses rather than clobber your work; commit, stash, or discard those changes from a terminal and sync resumes. +- **Local changes would be overwritten by an incoming update.** A teammate's update is ready to pull, but a file it touches has uncommitted local changes. Doc edits are committed automatically before merging, so this usually means other tracked files, like configs. Sync pauses rather than clobbering your work; commit, stash, or discard those changes from a terminal and sync resumes. - **Edits made outside the app aren't ready yet.** If you (or another tool) edited a file directly on disk while sync wanted to merge, OpenKnowledge waits until those external edits settle before merging. Usually this clears in a second or two on its own. ### Project isn't on a normal branch @@ -115,7 +134,7 @@ A few situations stop sync from completing successfully. In each case the sync s ### GitHub-side blockers - **Authentication errors.** Your stored credentials expired, were revoked, or don't have access to this repository anymore. Reconnect from the sync indicator (or from **Settings → Account**) to clear the error. -- **You don't have permission to push to this repo.** Someone shared a project backed by a GitHub repo where your account isn't a collaborator (or has read-only access on a private repo). OpenKnowledge checks this up front: instead of prompting you to enable sync that would just fail, the toggle is disabled with an inline "You don't have permission to push to this repo" message in both the sync indicator popover and **Settings → Sync**. If you had sync enabled on a different repo and switch to one you can't push to, OpenKnowledge pauses sync in-memory rather than mutating your preference; your `Auto-sync: on` setting is preserved for the next repo you have full access to. Ask the project owner for write access, then click sync to re-check. +- **You don't have permission to push to this repo.** Someone shared a project backed by a GitHub repo where your account isn't a collaborator (or has read-only access on a private repo). OpenKnowledge checks this up front and offers **Follow** instead of full sync. - **Protected branches and rejected pushes.** If the target branch requires reviews, signed commits, or status checks, GitHub rejects the push. OpenKnowledge turns sync off automatically so it stops retrying, and it stays off until you turn it back on from the sync indicator or **Settings → Sync**. Switch to a branch you can push to, or use a pull request for the protected branch instead. ### Temporary problems diff --git a/docs/content/features/share.mdx b/docs/content/features/share.mdx index 957df9262..a172ad524 100644 --- a/docs/content/features/share.mdx +++ b/docs/content/features/share.mdx @@ -66,6 +66,6 @@ Even on the right branch, a doc or folder can be renamed or deleted after a link ### Receiving without push permission -If the shared repo is one your GitHub account can't push to (you're not a collaborator on a public repo, or you have read-only access on a private one), OpenKnowledge skips the usual "Enable auto-sync?" prompt. Sync is also disabled for the same reason in **Settings → Sync**. If the project owner grants you write access later, push permission is re-checked the next time the project's server starts. See [GitHub sync: GitHub-side blockers](/docs/features/github-sync#github-side-blockers) for the full set of permission shapes the sync UI handles. +If the shared repo is one your GitHub account can't push to (you're not a collaborator on a public repo, or you have read-only access on a private one), OpenKnowledge offers **[Follow](/docs/features/github-sync#follow)** at first open instead of the full "Enable auto-sync?" prompt: your copy keeps up to date by pulling the latest from GitHub automatically, while your own edits stay on your computer. You can keep editing — an incoming update combines with your non-overlapping changes automatically, and an edit to the same lines surfaces as a conflict to resolve. See [GitHub sync](/docs/features/github-sync) for how to automatically sync changes to GitHub, and [Timeline and recovery](/docs/features/timeline-and-recovery) for per-doc history once the doc is open. diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx index 90ede825c..ac31b5871 100644 --- a/docs/content/reference/configuration.mdx +++ b/docs/content/reference/configuration.mdx @@ -8,7 +8,7 @@ OpenKnowledge reads YAML config from three places: - **Project**: `./.ok/config.yml` (this project, committed to git) - **User**: `~/.ok/global.yml` (every project on your machine) -- **Project-local**: `./.ok/local/config.yml` (this project on this machine; gitignored). Holds per-machine-per-project preferences such as `autoSync.enabled`. Maintained by the editor (auto-sync onboarding modal, sync popover, Settings pane Sync section); you don't normally hand-edit it. +- **Project-local**: `./.ok/local/config.yml` (this project on this machine; gitignored). Holds per-machine-per-project preferences such as `autoSync.mode`. Maintained by the editor (auto-sync onboarding modal, sync popover, Settings pane Sync section); you don't normally hand-edit it. All files are optional; defaults cover everything. @@ -36,8 +36,9 @@ A key set in a file more specific than its scope (a user-scope key in `.ok/confi | `contentRules.plugins.markdownlint.enabled` | boolean | `false` | project | Enable the [markdownlint](/docs/advanced/content-rules/markdownlint) content-rules plugin for this project (shared via git). Off by default — turn it on in **Settings → This project → Plugins**. The rules themselves live in your native `.markdownlint.*` file, not here. | | `appearance.theme` | `"light" \| "dark" \| "system"` | (unset) | user | Editor theme. | | `appearance.sidebar.showHiddenFiles` | boolean | `false` | project-local | Show files whose path segments start with `.`. The sidebar otherwise lists every file on disk under the content directory; tooling internals (`.git/`, `.ok/`, `node_modules/`) stay hidden regardless. Toggled from the sidebar's right-click menu or the macOS **View → Show Hidden Files** item. | -| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Per-machine git auto-sync toggle. `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. | -| `autoSync.default` | `boolean \| null` | `null` | project | Committed seed for each machine's first-open auto-sync choice: `true` = on, `false` = off, `null` = ask. Lets a maintainer pre-answer the onboarding prompt for everyone who clones; a per-machine `autoSync.enabled` overrides it. | +| `autoSync.mode` | `"off" \| "follow" \| "full" \| null` | `null` | project-local | Per-machine sync mode for this project: `off` (no sync), `follow` (one-directional — pull remote changes, never push your own; the earlier value `pull` is accepted as an alias), `full` (bidirectional pull and push). `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. Supersedes `autoSync.enabled`. | +| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Legacy per-machine auto-sync toggle, superseded by `autoSync.mode`. Read only when `mode` is absent (`true` = full, `false` = off). `null` means "unanswered". | +| `autoSync.default` | `"off" \| "follow" \| "full" \| boolean \| null` | `null` | project | Committed seed for each machine's first-open sync mode: `off` / `follow` / `full`, or the legacy boolean (`true` = full, `false` = off). `null` = ask. Lets a maintainer pre-answer the onboarding prompt for everyone who clones; a per-machine `autoSync.mode` overrides it. Compatibility note: a committed string value (`off` / `follow` / `full`) is rejected by app versions released before `autoSync.mode` existed, resetting that machine to config defaults until it updates (it never silently syncs). The legacy boolean seed (`true` / `false`) stays readable by older apps — commit `follow` only once collaborators are on a current version. | | `editor.wordWrap` | boolean | `true` | user | Soft-wrap long lines in the source-mode CodeMirror editor. A personal preference, not project-shared. Toggle from the Settings pane. | | `appearance.preview.autoOpen` | boolean | `true` | user | Whether the agent should open or refresh the OpenKnowledge preview when it edits a doc through the MCP. Default `true` lets the agent route the preview by host capability: the host's in-app browser (Cursor preview pane, Codex's built-in browser, Claude Code Desktop) when one exists, the system browser otherwise. Set `false` to keep the agent's hands off your preview window. This is useful when you're already viewing the doc in OK Desktop, a browser tab on a second display, a non-default browser, or any flow where your extensions / accessibility tooling only work in your own browser. The agent then surfaces the URL on request but does not navigate. The change takes effect on the next preview-related tool call. Toggle from **Settings → Preferences → "Open preview when agent edits"**. | | `terminal.enabled` | `boolean \| null` | `null` | project-local | Opt-out for the in-app terminal (a real OS shell at full user privilege). On by default; set `false` to disable it for this project on this machine. | diff --git a/packages/app/src/components/AutoSyncEnableWarning.tsx b/packages/app/src/components/AutoSyncEnableWarning.tsx index 4e854cbdc..988e7f032 100644 --- a/packages/app/src/components/AutoSyncEnableWarning.tsx +++ b/packages/app/src/components/AutoSyncEnableWarning.tsx @@ -1,8 +1,37 @@ import { Trans } from '@lingui/react/macro'; -import { ArrowRightLeft, Eye, GitCommitVertical } from 'lucide-react'; +import { + ArrowDownToLine, + ArrowRightLeft, + Eye, + GitCommitVertical, + GitMerge, + Laptop, +} from 'lucide-react'; +import type { ReactNode } from 'react'; +import type { AutoSyncOnboardingVariant } from '@/components/auto-sync-onboarding-gate'; import { DialogDescription, DialogTitle } from '@/components/ui/dialog'; -export function AutoSyncEnableDialogIntro() { +interface SyncVariantProps { + /** 'full' = bidirectional sync copy (default); 'pull' = one-directional. */ + variant?: AutoSyncOnboardingVariant; +} + +export function AutoSyncEnableDialogIntro({ variant = 'full' }: SyncVariantProps) { + if (variant === 'follow') { + return ( + <> + + Enable Follow? + + + + Follow fetches updates from your remote git repository and fast-forwards your copy + automatically. Your local edits stay on this computer and are never pushed. + + + + ); + } return ( <> @@ -18,7 +47,7 @@ export function AutoSyncEnableDialogIntro() { ); } -export function AutoSyncEnableWarning() { +export function AutoSyncEnableWarning({ variant = 'full' }: SyncVariantProps) { return (

@@ -28,48 +57,104 @@ export function AutoSyncEnableWarning() { Heads up

-
+ {variant === 'follow' ? : } +
+
+ ); +} + +function FullSyncBullets() { + return ( + <> +
-
+ } + title={Uncommitted changes} + body={Pulls may overwrite uncommitted edits in your local files.} + /> +
-
-
+ } + title={Commits happen automatically} + body={ + + OpenKnowledge will create commits and push them to your remote automatically. If you do + not want automatic commits in your git history, you should not enable auto-sync. + + } + /> +