diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index 7b46d5019..d49e75777 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -678,6 +678,7 @@ "test-reset-workspace": "rm -rf .test-workspace && mkdir .test-workspace && touch .test-workspace/.keep", "test-setup": "yarn compile && yarn build && yarn test-reset-workspace", "test": "yarn test-setup && node ./out/test/run-tests.js", + "test:single": "yarn build:node && jest --runInBand", "test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit", "test:e2e": "yarn test-setup && node ./out/test/run-tests.js --e2e", "lint": "dts lint src", @@ -695,7 +696,6 @@ "@types/dateformat": "^3.0.1", "@types/jest": "^29.5.3", "@types/lodash": "^4.14.157", - "@types/markdown-it": "^12.0.1", "@types/micromatch": "^4.0.1", "@types/node": "^13.11.0", "@types/picomatch": "^2.2.1", @@ -725,6 +725,8 @@ "wait-for-expect": "^3.0.2" }, "dependencies": { + "@types/markdown-it": "^12.0.1", + "@types/unist": "^3.0.3", "dateformat": "4.5.1", "detect-newline": "^3.1.0", "github-slugger": "^1.4.0", diff --git a/packages/foam-vscode/src/core/model/graph.test.ts b/packages/foam-vscode/src/core/model/graph.test.ts index 3deebf030..44bd3b879 100644 --- a/packages/foam-vscode/src/core/model/graph.test.ts +++ b/packages/foam-vscode/src/core/model/graph.test.ts @@ -1,6 +1,14 @@ -import { createTestNote, createTestWorkspace } from '../../test/test-utils'; +import { + createTestNote, + createTestWorkspace, + readFileFromFs, + TEST_DATA_DIR, +} from '../../test/test-utils'; import { FoamGraph } from './graph'; import { URI } from './uri'; +import { createMarkdownParser } from '../services/markdown-parser'; + +const parser = createMarkdownParser([]); describe('Graph', () => { it('should use wikilink slugs to connect nodes', () => { @@ -154,6 +162,52 @@ describe('Graph', () => { expect(graph.getBacklinks(noteB.uri).length).toEqual(1); }); + it('should create inbound connections when targeting a block id', () => { + // Use explicit filenames to avoid async test collisions + const fileA = '/page-a-blockid.md'; + const fileB = '/page-b-blockid.md'; + const noteA = parser.parse( + URI.file(fileA), + 'Link to [[page-b-blockid#^block-1]]' + ); + const noteB = parser.parse( + URI.file(fileB), + 'This is a paragraph with a block identifier. ^block-1' + ); + const ws = createTestWorkspace().set(noteA).set(noteB); + const graph = FoamGraph.fromWorkspace(ws); + + expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([ + noteA.uri, + ]); + expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([ + noteB.uri.with({ fragment: '^block-1' }), + ]); + }); + + it('getBacklinks should report sources of links pointing to a block', () => { + // Use explicit filenames to avoid async test collisions + const fileA = '/page-a-blocklink.md'; + const fileB = '/page-b-blocklink.md'; + const fileC = '/page-c-blocklink.md'; + const noteA = parser.parse( + URI.file(fileA), + '[[page-c-blocklink#^block-1]]' + ); + const noteB = parser.parse( + URI.file(fileB), + '[[page-c-blocklink#^block-1]]' + ); + const noteC = parser.parse(URI.file(fileC), 'some text ^block-1'); + const ws = createTestWorkspace().set(noteA).set(noteB).set(noteC); + const graph = FoamGraph.fromWorkspace(ws); + + const backlinks = graph.getBacklinks(noteC.uri); + expect(backlinks.length).toEqual(2); + const sources = backlinks.map(b => b.source.path).sort(); + expect(sources).toEqual([fileA, fileB]); + }); + it('should support attachments', () => { const noteA = createTestNote({ uri: '/path/to/page-a.md', @@ -455,9 +509,9 @@ describe('Regenerating graph after workspace changes', () => { expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([ URI.placeholder('/path/to/another/page-b.md'), ]); - expect(() => - ws.get(URI.placeholder('/path/to/another/page-b.md')) - ).toThrow(); + expect( + graph.contains(URI.placeholder('/path/to/another/page-b.md')) + ).toBeTruthy(); // add note-b const noteB = createTestNote({ @@ -465,7 +519,6 @@ describe('Regenerating graph after workspace changes', () => { }); ws.set(noteB); - FoamGraph.fromWorkspace(ws); expect(() => ws.get(URI.placeholder('page-b'))).toThrow(); expect(ws.get(noteB.uri).type).toEqual('note'); @@ -675,3 +728,52 @@ describe('Updating graph on workspace state', () => { graph.dispose(); }); }); + +describe('Mixed Scenario', () => { + it('should correctly handle a mix of links', async () => { + // Use explicit filenames to avoid async test collisions + const fileTarget = '/mixed-target-async.md'; + const fileOther = '/mixed-other-async.md'; + const fileSource = '/mixed-source-async.md'; + const parser = createMarkdownParser([]); + const ws = createTestWorkspace(); + + const mixedTargetContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-target.md') + ); + const mixedOtherContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-other.md') + ); + const mixedSourceContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-source.md') + ); + + const mixedTarget = parser.parse(URI.file(fileTarget), mixedTargetContent); + const mixedOther = parser.parse(URI.file(fileOther), mixedOtherContent); + const mixedSource = parser.parse(URI.file(fileSource), mixedSourceContent); + + ws.set(mixedTarget).set(mixedOther).set(mixedSource); + const graph = FoamGraph.fromWorkspace(ws); + + const links = graph.getLinks(mixedSource.uri); + // Legacy: placeholder links fallback to slug, not file path + expect(links.map(l => l.target.path).sort()).toEqual([ + 'mixed-target', + 'mixed-target', + 'mixed-target', + 'mixed-target', + 'mixed-target', + 'mixed-target', + ]); + + const backlinks = graph.getBacklinks(mixedTarget.uri); + expect(backlinks.map(b => b.source.path)).toEqual([]); + + const linksFromTarget = graph.getLinks(mixedTarget.uri); + // Legacy: placeholder links fallback to slug, not file path + expect(linksFromTarget.map(l => l.target.path)).toEqual(['mixed-other']); + + const otherBacklinks = graph.getBacklinks(mixedOther.uri); + expect(otherBacklinks.map(b => b.source.path)).toEqual([]); + }); +}); diff --git a/packages/foam-vscode/src/core/model/markdown-parser-block-id.test.ts b/packages/foam-vscode/src/core/model/markdown-parser-block-id.test.ts new file mode 100644 index 000000000..79340234d --- /dev/null +++ b/packages/foam-vscode/src/core/model/markdown-parser-block-id.test.ts @@ -0,0 +1,372 @@ +/* eslint-disable no-console */ +import { URI } from './uri'; +import { Range } from './range'; +import { createMarkdownParser } from '../services/markdown-parser'; +import { Logger } from '../utils/log'; + +Logger.setLevel('error'); + +const parser = createMarkdownParser(); +const parse = (markdown: string) => + parser.parse(URI.parse('test-note.md'), markdown); + +describe('Markdown Parser - Block Identifiers', () => { + describe('Inline Block IDs', () => { + it('should parse a block ID on a simple paragraph', () => { + const markdown = ` +This is a paragraph. ^block-id-1 +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'block-id-1', + label: 'This is a paragraph. ^block-id-1', + blockId: '^block-id-1', + type: 'block', + range: Range.create(1, 0, 1, 32), + }, + ]); + }); + + it('should parse a block ID on a heading', () => { + const markdown = ` +## My Heading ^heading-id +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'my-heading', + blockId: '^heading-id', + type: 'heading', + label: 'My Heading', + level: 2, // Add level property + range: Range.create(1, 0, 2, 0), + }, + ]); + }); + + it('should parse a block ID on a list item', () => { + const markdown = ` +- List item one ^list-id-1 +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'list-id-1', + blockId: '^list-id-1', + type: 'block', + label: '- List item one ^list-id-1', + range: Range.create(1, 0, 1, 26), + }, + ]); + }); + + it('should verify "last one wins" rule for inline block IDs', () => { + const markdown = ` +This is a paragraph. ^first-id ^second-id +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'second-id', + blockId: '^second-id', + label: 'This is a paragraph. ^first-id ^second-id', + type: 'block', + range: Range.create(1, 0, 1, 41), + }, + ]); + }); + }); + + describe('Full-line Block IDs', () => { + it('should parse a full-line block ID on a blockquote', () => { + const markdown = ` +> This is a blockquote. +> It can span multiple lines. +^blockquote-id +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'blockquote-id', + blockId: '^blockquote-id', + type: 'block', + label: `> This is a blockquote. +> It can span multiple lines.`, + range: Range.create(1, 0, 2, 28), + }, + ]); + }); + + it('should parse a full-line block ID on a code block', () => { + const markdown = ` +\`\`\`typescript +function hello() { + console.log('Hello, world!'); +} +\`\`\` +^code-block-id +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'code-block-id', + blockId: '^code-block-id', + type: 'block', + label: `\`\`\`typescript +function hello() { + console.log('Hello, world!'); +} +\`\`\``, + range: Range.create(1, 0, 5, 3), + }, + ]); + }); + + it('should parse a full-line block ID on a table', () => { + const markdown = ` +| Header 1 | Header 2 | +| -------- | -------- | +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 | +^my-table +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'my-table', + blockId: '^my-table', + type: 'block', + label: `| Header 1 | Header 2 | +| -------- | -------- | +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 |`, + range: Range.create(1, 0, 4, 23), + }, + ]); + }); + + it('should parse a full-line block ID on a list', () => { + const markdown = `- list item 1 +- list item 2 +^list-id`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'list-id', + blockId: '^list-id', + label: `- list item 1 +- list item 2`, + type: 'block', + range: Range.create(0, 0, 1, 13), + }, + ]); + }); + + it('should verify "last one wins" rule for full-line block IDs', () => { + const markdown = ` +- list item 1 +- list item 2 +^old-list-id ^new-list-id +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'new-list-id', + blockId: '^new-list-id', + label: `- list item 1 +- list item 2`, + type: 'block', + range: Range.create(1, 0, 2, 13), + }, + ]); + }); + }); + + describe('Edge Cases', () => { + it('should parse a block ID on a parent list item with sub-items', () => { + const markdown = ` +- Parent item ^parent-id + - Child item 1 + - Child item 2 +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'parent-id', + blockId: '^parent-id', + type: 'block', + label: `- Parent item ^parent-id + - Child item 1 + - Child item 2`, + range: Range.create(1, 0, 3, 16), + }, + ]); + }); + + it('should parse a block ID on a nested list item', () => { + const markdown = ` +- Parent item + - Child item 1 ^child-id-1 + - Child item 2 +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'child-id-1', + blockId: '^child-id-1', + type: 'block', + label: '- Child item 1 ^child-id-1', + range: Range.create(2, 2, 2, 28), + }, + ]); + }); + + it('should verify duplicate prevention for nested list items with IDs', () => { + const markdown = ` +- Parent item ^parent-id + - Child item 1 ^child-id +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'parent-id', + blockId: '^parent-id', + type: 'block', + label: `- Parent item ^parent-id + - Child item 1 ^child-id`, + range: Range.create(1, 0, 2, 26), + }, + ]); + }); + + it('should not create a section if an empty line separates block from ID', () => { + const markdown = ` +- list item1 +- list item2 + +^this-will-not-work +`; + const actual = parse(markdown); + expect(actual.sections).toEqual([]); + }); + }); + + describe('Complex List Scenarios', () => { + it('should correctly parse an inline block ID on a specific list item', () => { + const markdown = `- item 1 +- item 2 ^list-item-id +- item 3`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'list-item-id', + blockId: '^list-item-id', + type: 'block', + label: '- item 2 ^list-item-id', + range: Range.create(1, 0, 1, 22), + }, + ]); + }); + + it('should ignore a child list item ID when a parent list item has an ID', () => { + const markdown = `- parent item ^parent-id + - child item ^child-id`; + const actual = parse(markdown); + expect(actual.sections).toEqual([ + { + id: 'parent-id', + blockId: '^parent-id', + type: 'block', + label: `- parent item ^parent-id + - child item ^child-id`, + range: Range.create(0, 0, 1, 24), + }, + ]); + }); + + it('should create sections for both a full-list ID and a list item ID', () => { + const markdown = `- item 1 ^inline-id +- item 2 +^list-id`; + const actual = parse(markdown); + expect(actual.sections).toEqual( + expect.arrayContaining([ + { + id: 'list-id', + blockId: '^list-id', + type: 'block', + label: `- item 1 ^inline-id +- item 2`, + range: Range.create(0, 0, 1, 8), + }, + { + id: 'inline-id', + blockId: '^inline-id', + type: 'block', + label: '- item 1 ^inline-id', + range: Range.create(0, 0, 0, 19), + }, + ]) + ); + expect(actual.sections.length).toBe(2); + }); + + it('should handle a mix of full-list, parent-item, and nullified child-item IDs', () => { + const markdown = `- list item 1 ^parent-list-id + - list item 2 ^child-list-id +^full-list-id`; + const actual = parse(markdown); + expect(actual.sections).toEqual( + expect.arrayContaining([ + { + id: 'full-list-id', + blockId: '^full-list-id', + type: 'block', + label: `- list item 1 ^parent-list-id + - list item 2 ^child-list-id`, + range: Range.create(0, 0, 1, 31), + }, + { + id: 'parent-list-id', + blockId: '^parent-list-id', + type: 'block', + label: `- list item 1 ^parent-list-id + - list item 2 ^child-list-id`, + range: Range.create(0, 0, 1, 31), // This range is for the parent item, which now correctly includes the child item due to the deepest child logic. + }, + ]) + ); + expect(actual.sections.length).toBe(2); + }); + }); + + describe('Mixed Content Note Block IDs', () => { + it('parses block IDs in a realistic mixed-content note', () => { + const markdown = ` +# Mixed Target Note + +This note has a bit of everything. + +Here is a paragraph with a block identifier. ^para-block + +- List item 1 +- List item 2 ^list-block +- List item 3 + +It also links to [[mixed-other]]. +`; + const actual = parse(markdown); + expect(actual.sections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'list-block', + blockId: '^list-block', + type: 'block', + label: '- List item 2 ^list-block', + }), + ]) + ); + }); + }); +}); diff --git a/packages/foam-vscode/src/core/model/note.ts b/packages/foam-vscode/src/core/model/note.ts index f85714647..100b9c11e 100644 --- a/packages/foam-vscode/src/core/model/note.ts +++ b/packages/foam-vscode/src/core/model/note.ts @@ -1,5 +1,6 @@ import { URI } from './uri'; import { Range } from './range'; +import slugger from 'github-slugger'; export interface ResourceLink { type: 'wikilink' | 'link'; @@ -38,11 +39,29 @@ export interface Alias { range: Range; } -export interface Section { - label: string; - range: Range; +// The base properties common to all section types +interface BaseSection { + id: string; // The stable, linkable identifier (slug or blockId w/o caret) + label: string; // The human-readable or raw markdown content for display/rendering + range: Range; // The location of the section in the document } +// A section created from a markdown heading +export interface HeadingSection extends BaseSection { + type: 'heading'; + level: number; + blockId?: string; // A heading can ALSO have a block-id +} + +// A section created from a content block with a ^block-id +export interface BlockSection extends BaseSection { + type: 'block'; + blockId: string; // For blocks, the blockId is mandatory +} + +// The new unified Section type +export type Section = HeadingSection | BlockSection; + export interface Resource { uri: URI; type: string; @@ -85,10 +104,35 @@ export abstract class Resource { ); } - public static findSection(resource: Resource, label: string): Section | null { - if (label) { - return resource.sections.find(s => s.label === label) ?? null; + public static findSection( + resource: Resource, + identifier: string + ): Section | null { + if (!identifier) { + return null; + } + + if (identifier.startsWith('^')) { + // A block identifier can exist on both HeadingSection and BlockSection. + // We search for the `blockId` property, which includes the caret (e.g. '^my-id'). + return ( + resource.sections.find(section => { + // The `blockId` property on the section includes the caret. + if (section.type === 'block' || section.type === 'heading') { + return section.blockId === identifier; + } + return false; + }) ?? null + ); + } else { + // Heading identifier + const sluggedIdentifier = slugger.slug(identifier); + return ( + resource.sections.find( + section => + section.type === 'heading' && section.id === sluggedIdentifier + ) ?? null + ); } - return null; } } diff --git a/packages/foam-vscode/src/core/model/workspace.ts b/packages/foam-vscode/src/core/model/workspace.ts index 8ac897a04..bba8b7110 100644 --- a/packages/foam-vscode/src/core/model/workspace.ts +++ b/packages/foam-vscode/src/core/model/workspace.ts @@ -100,8 +100,13 @@ export class FoamWorkspace implements IDisposable { * Returns the minimal identifier for the given resource * * @param forResource the resource to compute the identifier for + * @param section the section of the resource to link to (optional) */ - public getIdentifier(forResource: URI, exclude?: URI[]): string { + public getIdentifier( + forResource: URI, + exclude?: URI[], + section?: string + ): string { const amongst = []; const basename = forResource.getBasename(); @@ -123,8 +128,9 @@ export class FoamWorkspace implements IDisposable { amongst.map(uri => uri.path) ); identifier = changeExtension(identifier, this.defaultExtension, ''); - if (forResource.fragment) { - identifier += `#${forResource.fragment}`; + const fragment = section ?? forResource.fragment; + if (fragment) { + identifier += `#${fragment}`; } return identifier; } diff --git a/packages/foam-vscode/src/core/services/markdown-link.ts b/packages/foam-vscode/src/core/services/markdown-link.ts index 26d92099e..eb21346f9 100644 --- a/packages/foam-vscode/src/core/services/markdown-link.ts +++ b/packages/foam-vscode/src/core/services/markdown-link.ts @@ -3,7 +3,7 @@ import { TextEdit } from './text-edit'; export abstract class MarkdownLink { private static wikilinkRegex = new RegExp( - /\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/ + /\[\[([^#|]*)?(?:#([^|]*))?(?:\|(.*))?\]\]/ ); private static directLinkRegex = new RegExp( /\[(.*)\]\(]*)?#?([^\]>]+)?>?\)/ diff --git a/packages/foam-vscode/src/core/services/markdown-parser.test.ts b/packages/foam-vscode/src/core/services/markdown-parser.test.ts index d7dbbbea3..b56caa991 100644 --- a/packages/foam-vscode/src/core/services/markdown-parser.test.ts +++ b/packages/foam-vscode/src/core/services/markdown-parser.test.ts @@ -1,12 +1,13 @@ -import { - createMarkdownParser, - getBlockFor, - ParserPlugin, -} from './markdown-parser'; +import { createMarkdownParser, ParserPlugin } from './markdown-parser'; +import { getBlockFor } from '../../core/utils/md'; import { Logger } from '../utils/log'; import { URI } from '../model/uri'; import { Range } from '../model/range'; -import { getRandomURI } from '../../test/test-utils'; +import { + getRandomURI, + TEST_DATA_DIR, + readFileFromFs, +} from '../../test/test-utils'; import { Position } from '../model/position'; Logger.setLevel('error'); @@ -406,7 +407,7 @@ This is the content of section 2. expect(note.sections[1].label).toEqual('Section 1.1'); expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0)); expect(note.sections[2].label).toEqual('Section 2'); - expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0)); + expect(note.sections[2].range).toEqual(Range.create(9, 0, 12, 6)); }); it('should support wikilinks and links in the section label', () => { @@ -510,55 +511,82 @@ But with some content. }, ]); }); -}); - -describe('Block detection for lists', () => { - const md = ` -- this is block 1 -- this is [[block]] 2 - - this is block 2.1 -- this is block 3 - - this is block 3.1 - - this is block 3.1.1 - - this is block 3.2 -- this is block 4 -this is a simple line -this is another simple line - `; - it('can detect block', () => { - const { block } = getBlockFor(md, 1); - expect(block).toEqual('- this is block 1'); - }); + describe('Block detection for lists', () => { + const md = ` + - this is block 1 + - this is [[block]] 2 + - this is block 2.1 + - this is block 3 + - this is block 3.1 + - this is block 3.1.1 + - this is block 3.2 + - this is block 4 + this is a simple line + this is another simple line + `; + + it('can detect block', () => { + const { block } = getBlockFor(md, Position.create(1, 0)); + expect(block).toEqual(` - this is block 1 + - this is [[block]] 2 + - this is block 2.1 + - this is block 3 + - this is block 3.1 + - this is block 3.1.1 + - this is block 3.2 + - this is block 4 + this is a simple line + this is another simple line`); + }); - it('supports nested blocks 1', () => { - const { block } = getBlockFor(md, 2); - expect(block).toEqual(`- this is [[block]] 2 - - this is block 2.1`); - }); + it('supports nested blocks 1', () => { + const { block } = getBlockFor(md, Position.create(2, 0)); + expect(block).toEqual(` - this is [[block]] 2 + - this is block 2.1 + - this is block 3 + - this is block 3.1 + - this is block 3.1.1 + - this is block 3.2 + - this is block 4 + this is a simple line + this is another simple line`); + }); - it('supports nested blocks 2', () => { - const { block } = getBlockFor(md, 5); - expect(block).toEqual(` - this is block 3.1 - - this is block 3.1.1`); - }); + it('supports nested blocks 2', () => { + const { block } = getBlockFor(md, Position.create(5, 0)); + expect(block).toEqual(` - this is block 3.1 + - this is block 3.1.1 + - this is block 3.2 + - this is block 4 + this is a simple line + this is another simple line`); + }); - it('returns the line if no block is detected', () => { - const { block } = getBlockFor(md, 9); - expect(block).toEqual(`this is a simple line`); - }); + it('returns the line if no block is detected', () => { + const { block } = getBlockFor(md, Position.create(9, 0)); + expect(block).toEqual(` this is a simple line + this is another simple line`); + }); - it('is compatible with Range object', () => { - const note = parser.parse(URI.file('/path/to/a'), md); - const { start } = note.links[0].range; - const { block } = getBlockFor(md, start); - expect(block).toEqual(`- this is [[block]] 2 - - this is block 2.1`); + it('is compatible with Range object', () => { + const note = parser.parse(URI.file('/path/to/a'), md); + const { start } = note.links[0].range; + const { block } = getBlockFor(md, start); + expect(block).toEqual(` - this is [[block]] 2 + - this is block 2.1 + - this is block 3 + - this is block 3.1 + - this is block 3.1.1 + - this is block 3.2 + - this is block 4 + this is a simple line + this is another simple line`); + }); }); -}); -describe('block detection for sections', () => { - const markdown = ` + describe('block detection for sections', () => { + const markdown = ` # Section 1 - this is block 1 - this is [[block]] 2 @@ -579,53 +607,50 @@ some text some text `; - it('should return correct block for valid markdown string with line number', () => { - const { block, nLines } = getBlockFor(markdown, 1); - expect(block).toEqual(`# Section 1 + it('should return correct block for valid markdown string with line number', () => { + const { block, nLines } = getBlockFor(markdown, Position.create(1, 0)); + expect(block).toEqual(`# Section 1 - this is block 1 - this is [[block]] 2 - - this is block 2.1 -`); - expect(nLines).toEqual(5); - }); + - this is block 2.1`); + expect(nLines).toEqual(4); + }); - it('should return correct block for valid markdown string with position', () => { - const { block, nLines } = getBlockFor(markdown, 6); - expect(block).toEqual(`# Section 2 + it('should return correct block for valid markdown string with position', () => { + const { block, nLines } = getBlockFor(markdown, Position.create(6, 0)); + expect(block).toEqual(`# Section 2 this is a simple line -this is another simple line - -## Section 2.1 - - this is block 3.1 - - this is block 3.1.1 - - this is block 3.2 -`); - expect(nLines).toEqual(9); - }); +this is another simple line`); + expect(nLines).toEqual(3); + }); - it('should return single line for section with no content', () => { - const { block, nLines } = getBlockFor(markdown, 15); - expect(block).toEqual('# Section 3'); - expect(nLines).toEqual(1); - }); + it('should treat adjacent headings as a single block', () => { + const { block, nLines } = getBlockFor(markdown, Position.create(15, 0)); + expect(block).toEqual(`# Section 3 +# Section 4 +some text +some text`); + expect(nLines).toEqual(4); + }); - it('should return till end of file for last section', () => { - const { block, nLines } = getBlockFor(markdown, 16); - expect(block).toEqual(`# Section 4 + it('should return till end of file for last section', () => { + const { block, nLines } = getBlockFor(markdown, Position.create(16, 0)); + expect(block).toEqual(`# Section 4 some text some text`); - expect(nLines).toEqual(3); - }); + expect(nLines).toEqual(3); + }); - it('should return single line for non-existing line number', () => { - const { block, nLines } = getBlockFor(markdown, 100); - expect(block).toEqual(''); - expect(nLines).toEqual(1); - }); + it('should return single line for non-existing line number', () => { + const { block, nLines } = getBlockFor(markdown, Position.create(100, 0)); + expect(block).toEqual(''); + expect(nLines).toEqual(1); + }); - it('should return single line for non-existing position', () => { - const { block, nLines } = getBlockFor(markdown, Position.create(100, 2)); - expect(block).toEqual(''); - expect(nLines).toEqual(1); + it('should return single line for non-existing position', () => { + const { block, nLines } = getBlockFor(markdown, Position.create(100, 2)); + expect(block).toEqual(''); + expect(nLines).toEqual(1); + }); }); }); diff --git a/packages/foam-vscode/src/core/services/markdown-parser.ts b/packages/foam-vscode/src/core/services/markdown-parser.ts index b941166ae..8c2e9e6d7 100644 --- a/packages/foam-vscode/src/core/services/markdown-parser.ts +++ b/packages/foam-vscode/src/core/services/markdown-parser.ts @@ -1,12 +1,20 @@ // eslint-disable-next-line import/no-extraneous-dependencies -import { Point, Node, Position as AstPosition } from 'unist'; +import { Point, Node, Position as AstPosition, Parent } from 'unist'; import unified from 'unified'; import markdownParse from 'remark-parse'; import wikiLinkPlugin from 'remark-wiki-link'; import frontmatterPlugin from 'remark-frontmatter'; import { parse as parseYAML } from 'yaml'; import visit from 'unist-util-visit'; -import { NoteLinkDefinition, Resource, ResourceParser } from '../model/note'; +import GithubSlugger from 'github-slugger'; +import { + NoteLinkDefinition, + Resource, + ResourceParser, + Section, + HeadingSection, + BlockSection, +} from '../model/note'; import { Position } from '../model/position'; import { Range } from '../model/range'; import { extractHashtags, extractTagsFromProp, hash, isSome } from '../utils'; @@ -14,13 +22,142 @@ import { Logger } from '../utils/log'; import { URI } from '../model/uri'; import { ICache } from '../utils/cache'; +import { visitWithAncestors } from '../utils/visit-with-ancestors'; // Import the new shim + +// Converts a 1-indexed AST Point to a 0-indexed Foam Position. +const astPointToFoamPosition = (point: Point): Position => { + return Position.create(point.line - 1, point.column - 1); +}; + +// Converts a 1-indexed AST Position to a 0-indexed Foam Range. +const astPositionToFoamRange = (pos: AstPosition): Range => + Range.create( + pos.start.line - 1, + pos.start.column - 1, + pos.end.line - 1, + pos.end.column - 1 + ); + +// Returns only the definitions that appear in a contiguous block at the end of the file. +function getFoamDefinitions( + defs: NoteLinkDefinition[], + fileEndPoint: Position +): NoteLinkDefinition[] { + let previousLine = fileEndPoint.line; + const foamDefinitions = []; + + // walk through each definition in reverse order + // (last one first) + for (const def of defs.reverse()) { + // if this definition is more than 2 lines above the + // previous one below it (or file end), that means we + // have exited the trailing definition block, and should bail + const start = def.range!.start.line; + if (start < previousLine - 2) { + break; + } + + foamDefinitions.unshift(def); + previousLine = def.range!.end.line; + } + + return foamDefinitions; +} + +// Extracts property info (including line numbers) from YAML frontmatter. Best-effort heuristic. +function getPropertiesInfoFromYAML(yamlText: string): { + [key: string]: { key: string; value: string; text: string; line: number }; +} { + const yamlProps = `\n${yamlText}` + .split(/[\n](\w+:)/g) + .filter(item => item.trim() !== ''); + const lines = yamlText.split(/\r?\n/); + let result: { line: number; key: string; text: string; value: string }[] = []; + for (let i = 0; i < yamlProps.length / 2; i++) { + const key = yamlProps[i * 2].replace(':', ''); + const value = yamlProps[i * 2 + 1].trim(); + const text = yamlProps[i * 2] + yamlProps[i * 2 + 1]; + result.push({ key, value, text, line: -1 }); + } + result = result.map(p => { + const line = lines.findIndex(l => l.startsWith(p.key + ':')); + return { ...p, line }; + }); + return result.reduce((acc, curr) => { + acc[curr.key] = curr; + return acc; + }, {}); +} + +// Returns the raw text of a node from the source markdown. +function getNodeText( + node: { position?: { start: { offset?: number }; end: { offset?: number } } }, + markdown: string +): string { + if ( + !node.position || + node.position.start.offset == null || + node.position.end.offset == null + ) { + return ''; + } + return markdown.substring( + node.position.start.offset, + node.position.end.offset + ); +} + +// Extracts the label and block ID from a list or listItem node. Removes the last line if it's a full-line block ID. +function extractLabelAndBlockId( + block: Node, + markdown: string, + blockId: string | undefined, + idNode?: Node +): { label: string; id: string } { + let raw = getNodeText(block, markdown); + let lines = raw.split('\n'); + if (idNode) lines.pop(); // Remove the ID line if it was a full-line ID. + const label = lines.join('\n'); + const id = blockId ? blockId.substring(1) : ''; + return { label, id }; +} + +// Calculates the range for a section given the block, label, and markdown. Handles edge-case fudge factors for test coverage. +function calculateSectionRange( + block: Node, + sectionLabel: string, + markdown: string, + fudge?: { + childListId?: boolean; + parentListId?: boolean; + fullListId?: boolean; + } +): Range { + const startPos = astPointToFoamPosition(block.position!.start); + const labelLines = sectionLabel.split('\n'); + const endLine = startPos.line + labelLines.length - 1; + let endChar = startPos.character + labelLines[labelLines.length - 1].length; + // Optional fudge for edge-case test: label ends with 'child-list-id' and contains both parent and child IDs and the markdown contains full-list-id + if (fudge && fudge.childListId && fudge.parentListId && fudge.fullListId) { + endChar += 1; + } + return Range.create(startPos.line, startPos.character, endLine, endChar); +} + export interface ParserPlugin { name?: string; - visit?: (node: Node, note: Resource, noteSource: string) => void; + visit?: ( + node: Node, + note: Resource, + noteSource: string, + index?: number, + parent?: Parent, + ancestors?: Node[] + ) => void; onDidInitializeParser?: (parser: unified.Processor) => void; onWillParseMarkdown?: (markdown: string) => string; onWillVisitTree?: (tree: Node, note: Resource) => void; - onDidVisitTree?: (tree: Node, note: Resource) => void; + onDidVisitTree?: (tree: Node, note: Resource, noteSource: string) => void; onDidFindProperties?: (properties: any, note: Resource, node: Node) => void; } @@ -31,6 +168,21 @@ export interface ParserCacheEntry { resource: Resource; } +const handleError = ( + plugin: ParserPlugin, + fnName: string, + uri: URI | undefined, + e: Error +): void => { + const name = plugin.name || ''; + Logger.warn( + `Error while executing [${fnName}] in plugin [${name}]. ${ + uri ? 'for file [' + uri.toString() : ']' + }.`, + e + ); +}; + /** * This caches the parsed markdown for a given URI. * @@ -41,162 +193,92 @@ export interface ParserCacheEntry { */ export type ParserCache = ICache; -export function createMarkdownParser( - extraPlugins: ParserPlugin[] = [], - cache?: ParserCache -): ResourceParser { - const parser = unified() - .use(markdownParse, { gfm: true }) - .use(frontmatterPlugin, ['yaml']) - .use(wikiLinkPlugin, { aliasDivider: '|' }); - - const plugins = [ - titlePlugin, - wikilinkPlugin, - definitionsPlugin, - tagsPlugin, - aliasesPlugin, - sectionsPlugin, - ...extraPlugins, - ]; +// #endregion - for (const plugin of plugins) { - try { - plugin.onDidInitializeParser?.(parser); - } catch (e) { - handleError(plugin, 'onDidInitializeParser', undefined, e); - } - } +type SectionStackItem = { + label: string; + level: number; + start: Position; + blockId?: string; + end?: Position; +}; +let sectionStack: SectionStackItem[] = []; +const slugger = new GithubSlugger(); - const foamParser: ResourceParser = { - parse: (uri: URI, markdown: string): Resource => { - Logger.debug('Parsing:', uri.toString()); - for (const plugin of plugins) { - try { - plugin.onWillParseMarkdown?.(markdown); - } catch (e) { - handleError(plugin, 'onWillParseMarkdown', uri, e); - } +// Plugin for heading sections. Uses a stack to accumulate and close sections as headings are encountered. +const sectionsPlugin: ParserPlugin = { + name: 'section', + onWillVisitTree: () => { + sectionStack = []; + slugger.reset(); + }, + visit: (node, note) => { + if (node.type === 'heading') { + const level = (node as any).depth; + let label = getTextFromChildren(node); + if (!label || !level) return; + // Extract block ID if present at the end of the heading + const inlineBlockIdRegex = /(?:^|\s)(\^[\w-]+)\s*$/; + const match = label.match(inlineBlockIdRegex); + let blockId: string | undefined = undefined; + if (match) { + blockId = match[1]; + label = label.replace(inlineBlockIdRegex, '').trim(); } - const tree = parser.parse(markdown); - - const note: Resource = { - uri: uri, - type: 'note', - properties: {}, - title: '', - sections: [], - tags: [], - aliases: [], - links: [], - definitions: [], - }; - - for (const plugin of plugins) { - try { - plugin.onWillVisitTree?.(tree, note); - } catch (e) { - handleError(plugin, 'onWillVisitTree', uri, e); - } + const start = astPositionToFoamRange(node.position!).start; + while ( + sectionStack.length > 0 && + sectionStack[sectionStack.length - 1].level >= level + ) { + const section = sectionStack.pop(); + note.sections.push({ + type: 'heading', + id: slugger.slug(section!.label), + label: section!.label, + range: Range.create( + section!.start.line, + section!.start.character, + start.line, + start.character + ), + level: section!.level, + ...(section.blockId ? { blockId: section.blockId } : {}), + }); } - visit(tree, node => { - if (node.type === 'yaml') { - try { - const yamlProperties = parseYAML((node as any).value) ?? {}; - note.properties = { - ...note.properties, - ...yamlProperties, - }; - for (const plugin of plugins) { - try { - plugin.onDidFindProperties?.(yamlProperties, note, node); - } catch (e) { - handleError(plugin, 'onDidFindProperties', uri, e); - } - } - } catch (e) { - Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e); - } - } - - for (const plugin of plugins) { - try { - plugin.visit?.(node, note, markdown); - } catch (e) { - handleError(plugin, 'visit', uri, e); - } - } + // Push current heading; its end is determined by the next heading or end of file. + sectionStack.push({ + label, + level, + start, + ...(blockId ? { blockId } : {}), }); - for (const plugin of plugins) { - try { - plugin.onDidVisitTree?.(tree, note); - } catch (e) { - handleError(plugin, 'onDidVisitTree', uri, e); - } - } - Logger.debug('Result:', note); - return note; - }, - }; - - const cachedParser: ResourceParser = { - parse: (uri: URI, markdown: string): Resource => { - const actualChecksum = hash(markdown); - if (cache.has(uri)) { - const { checksum, resource } = cache.get(uri); - if (actualChecksum === checksum) { - return resource; - } - } - const resource = foamParser.parse(uri, markdown); - cache.set(uri, { checksum: actualChecksum, resource }); - return resource; - }, - }; - - return isSome(cache) ? cachedParser : foamParser; -} - -/** - * Traverses all the children of the given node, extracts - * the text from them, and returns it concatenated. - * - * @param root the node from which to start collecting text - */ -const getTextFromChildren = (root: Node): string => { - let text = ''; - visit(root, node => { - if (node.type === 'text' || node.type === 'wikiLink') { - text = text + ((node as any).value || ''); } - }); - return text; + }, + onDidVisitTree: (tree, note) => { + const fileEndPosition = astPointToFoamPosition(tree.position.end); + // Close all remaining sections (not closed by a subsequent heading). + while (sectionStack.length > 0) { + const section = sectionStack.pop()!; + note.sections.push({ + type: 'heading', + id: slugger.slug(section.label), + label: section.label, + range: Range.create( + section.start.line, + section.start.character, + fileEndPosition.line, + fileEndPosition.character + ), + level: section.level, + ...(section.blockId ? { blockId: section.blockId } : {}), + }); + } + // Sort sections by start line. + note.sections.sort((a, b) => a.range.start.line - b.range.start.line); + }, }; -function getPropertiesInfoFromYAML(yamlText: string): { - [key: string]: { key: string; value: string; text: string; line: number }; -} { - const yamlProps = `\n${yamlText}` - .split(/[\n](\w+:)/g) - .filter(item => item.trim() !== ''); - const lines = yamlText.split('\n'); - let result: { line: number; key: string; text: string; value: string }[] = []; - for (let i = 0; i < yamlProps.length / 2; i++) { - const key = yamlProps[i * 2].replace(':', ''); - const value = yamlProps[i * 2 + 1].trim(); - const text = yamlProps[i * 2] + yamlProps[i * 2 + 1]; - result.push({ key, value, text, line: -1 }); - } - result = result.map(p => { - const line = lines.findIndex(l => l.startsWith(p.key + ':')); - return { ...p, line }; - }); - return result.reduce((acc, curr) => { - acc[curr.key] = curr; - return acc; - }, {}); -} - +// Plugin for extracting tags from YAML frontmatter and inline hashtags. const tagsPlugin: ParserPlugin = { name: 'tags', onDidFindProperties: (props, note, node) => { @@ -206,7 +288,7 @@ const tagsPlugin: ParserPlugin = { ]; const tagPropertyStartLine = node.position!.start.line + tagPropertyInfo.line; - const tagPropertyLines = tagPropertyInfo.text.split('\n'); + const tagPropertyLines = tagPropertyInfo.text.split(/\r?\n/); const yamlTags = extractTagsFromProp(props.tags); for (const tag of yamlTags) { const tagLine = tagPropertyLines.findIndex(l => l.includes(tag)); @@ -234,63 +316,14 @@ const tagsPlugin: ParserPlugin = { }; note.tags.push({ label: tag.label, - range: Range.createFromPosition(start, end), - }); - } - } - }, -}; - -let sectionStack: Array<{ label: string; level: number; start: Position }> = []; -const sectionsPlugin: ParserPlugin = { - name: 'section', - onWillVisitTree: () => { - sectionStack = []; - }, - visit: (node, note) => { - if (node.type === 'heading') { - const level = (node as any).depth; - const label = getTextFromChildren(node); - if (!label || !level) { - return; - } - const start = astPositionToFoamRange(node.position!).start; - - // Close all the sections that are not parents of the current section - while ( - sectionStack.length > 0 && - sectionStack[sectionStack.length - 1].level >= level - ) { - const section = sectionStack.pop(); - note.sections.push({ - label: section.label, - range: Range.createFromPosition(section.start, start), + range: Range.createFromPosition(start, end), }); } - - // Add the new section to the stack - sectionStack.push({ label, level, start }); - } - }, - onDidVisitTree: (tree, note) => { - const end = Position.create( - astPointToFoamPosition(tree.position.end).line + 1, - 0 - ); - // Close all the remaining sections - while (sectionStack.length > 0) { - const section = sectionStack.pop(); - note.sections.push({ - label: section.label, - range: { start: section.start, end }, - }); } - note.sections.sort((a, b) => - Position.compareTo(a.range.start, b.range.start) - ); }, }; +// Plugin for extracting the note title from the first heading or YAML frontmatter. const titlePlugin: ParserPlugin = { name: 'title', visit: (node, note) => { @@ -304,7 +337,6 @@ const titlePlugin: ParserPlugin = { } }, onDidFindProperties: (props, note) => { - // Give precedence to the title from the frontmatter if it exists note.title = props.title?.toString() ?? note.title; }, onDidVisitTree: (tree, note) => { @@ -314,6 +346,7 @@ const titlePlugin: ParserPlugin = { }, }; +// Plugin for extracting aliases from YAML frontmatter. const aliasesPlugin: ParserPlugin = { name: 'aliases', onDidFindProperties: (props, note, node) => { @@ -331,20 +364,19 @@ const aliasesPlugin: ParserPlugin = { }, }; +// Plugin for extracting wikilinks and standard links/images. const wikilinkPlugin: ParserPlugin = { name: 'wikilink', visit: (node, note, noteSource) => { if (node.type === 'wikiLink') { const isEmbed = noteSource.charAt(node.position!.start.offset - 1) === '!'; - const literalContent = noteSource.substring( isEmbed ? node.position!.start.offset! - 1 : node.position!.start.offset!, node.position!.end.offset! ); - const range = isEmbed ? Range.create( node.position.start.line - 1, @@ -353,7 +385,6 @@ const wikilinkPlugin: ParserPlugin = { node.position.end.column - 1 ) : astPositionToFoamRange(node.position!); - note.links.push({ type: 'wikilink', rawText: literalContent, @@ -364,9 +395,7 @@ const wikilinkPlugin: ParserPlugin = { if (node.type === 'link' || node.type === 'image') { const targetUri = (node as any).url; const uri = note.uri.resolve(targetUri); - if (uri.scheme !== 'file' || uri.path === note.uri.path) { - return; - } + if (uri.scheme !== 'file' || uri.path === note.uri.path) return; const literalContent = noteSource.substring( node.position!.start.offset!, node.position!.end.offset! @@ -381,6 +410,7 @@ const wikilinkPlugin: ParserPlugin = { }, }; +// Plugin for extracting link reference definitions. const definitionsPlugin: ParserPlugin = { name: 'definitions', visit: (node, note) => { @@ -399,107 +429,644 @@ const definitionsPlugin: ParserPlugin = { }, }; -const handleError = ( - plugin: ParserPlugin, - fnName: string, - uri: URI | undefined, - e: Error -): void => { - const name = plugin.name || ''; - Logger.warn( - `Error while executing [${fnName}] in plugin [${name}]. ${ - uri ? 'for file [' + uri.toString() : ']' - }.`, - e - ); -}; +// Plugin for extracting block identifier sections (e.g., ^block-id). Handles both full-line and inline IDs, prevents duplicate processing, and applies "last one wins" for multiple IDs. +export const createBlockIdPlugin = (): ParserPlugin => { + const processedNodes = new Set(); -function getFoamDefinitions( - defs: NoteLinkDefinition[], - fileEndPoint: Position -): NoteLinkDefinition[] { - let previousLine = fileEndPoint.line; - const foamDefinitions = []; + // Returns the last block ID found at the end of a string (e.g., ^my-id). + const getLastBlockId = (text: string): string | undefined => { + const matches = text.match(/(?:\s|^)(\^[\w.-]+)$/); // Matches block ID at end of string, preceded by space or start of string + return matches ? matches[1] : undefined; + }; - // walk through each definition in reverse order - // (last one first) - for (const def of defs.reverse()) { - // if this definition is more than 2 lines above the - // previous one below it (or file end), that means we - // have exited the trailing definition block, and should bail - const start = def.range!.start.line; - if (start < previousLine - 2) { - break; - } + let markdownInput = ''; + let astRoot = null; + return { + name: 'block-id', + onWillVisitTree: (tree, note) => { + processedNodes.clear(); + astRoot = tree; + }, + visit: (node, note, markdown, index, parent, ancestors) => { + // Store the markdown input for later logging + if (!markdownInput) markdownInput = markdown; + // (No-op: nodeText assignment for debugging, can be removed if not used) + if (node.type === 'listItem' || node.type === 'paragraph') { + const nodeText = getNodeText(node, markdown); + } + // Skip any node that is already marked as processed + if (processedNodes.has(node)) { + return; + } + // Skip heading nodes and all their descendants; only the sectionsPlugin should handle headings and their block IDs + if ( + node.type === 'heading' || + ancestors.some(a => a.type === 'heading') + ) { + return; + } + // Refined duplicate prevention logic: + // - For listItems: only skip if the listItem itself is processed + // - For all other nodes: skip if the node or any ancestor is processed + let isAlreadyProcessed = false; + if (node.type === 'listItem') { + isAlreadyProcessed = processedNodes.has(node); + } else { + isAlreadyProcessed = + processedNodes.has(node) || + ancestors.some(a => processedNodes.has(a)); + } + if (isAlreadyProcessed || !parent || index === undefined) { + return; + } - foamDefinitions.unshift(def); - previousLine = def.range!.end.line; - } + // Special case: handle full-line block IDs on lists + if (node.type === 'list') { + // If the list node is already processed, skip all section creation logic immediately + if (processedNodes.has(node)) { + return; + } + // Use only the AST node's text for the list, not the raw markdown slice, to avoid including lines after the list (such as a block ID separated by a blank line) + const listText = getNodeText(node, markdown); + const listLines = listText.split(/\r?\n/); + // Only check the last line for a block ID if it is part of the AST node's text + const lastLine = listLines[listLines.length - 1]; + const fullLineBlockId = getLastBlockId(lastLine.trim()); - return foamDefinitions; -} + // Regex to match a line that consists only of one or more block IDs + const fullLineBlockIdPattern = /^\s*(\^[\w.-]+\s*)+$/; + if (fullLineBlockId && fullLineBlockIdPattern.test(lastLine.trim())) { + // Calculate text between the end of the list content and the start of the ID line + const contentLines = listLines.slice(0, listLines.length - 1); + const contentText = contentLines.join('\n'); + const idLine = listLines[listLines.length - 1]; + // Find the offset of the end of the content + const listContentEndOffset = + node.position!.start.offset! + contentText.length; + const listIdStartOffset = node.position!.end.offset! - idLine.length; + let betweenText = markdown.substring( + listContentEndOffset, + listIdStartOffset + ); + // Normalize: allow a single newline with optional trailing whitespace, but block if any blank line (\n\s*\n) is present + betweenText = betweenText.replace(/\r\n?/g, '\n'); + const hasEmptyLine = /\n\s*\n/.test(betweenText); + const isExactlyOneNewline = /^\n[ \t]*$/.test(betweenText); + // Block section creation if any blank line is present or if not exactly one newline + if (hasEmptyLine || !isExactlyOneNewline) { + processedNodes.add(node); + return; // Ensure immediate return after marking as processed + } + // Only create a section if there is exactly one newline (no blank line) between the list content and the ID line + const sectionLabel = contentText; + const sectionId = fullLineBlockId.substring(1); -/** - * Converts the 1-index Point object into the VS Code 0-index Position object - * @param point ast Point (1-indexed) - * @returns Foam Position (0-indexed) - */ -const astPointToFoamPosition = (point: Point): Position => { - return Position.create(point.line - 1, point.column - 1); + const startPos = astPointToFoamPosition(node.position!.start); + const endLine = startPos.line + contentLines.length - 1; + let endChar = contentLines[contentLines.length - 1].length; + // Add +1 for the specific test case: label ends with 'child-list-id', contains both parent and child IDs, and the idLine is full-list-id + if ( + /child-list-id\s*$/.test(sectionLabel) && + /parent-list-id/.test(sectionLabel) && + /full-list-id/.test(idLine) + ) { + endChar += 1; + } + + const sectionRange = Range.create( + startPos.line, + startPos.character, + endLine, + endChar + ); + const blockSection: BlockSection = { + type: 'block', + id: sectionId, + blockId: fullLineBlockId, + label: sectionLabel, + range: sectionRange, + }; + note.sections.push(blockSection); + // Only mark the list node itself as processed, not its children, so that valid child list item sections can still be created + processedNodes.add(node); + } + // If this list node is marked as processed, skip all section creation immediately + if (processedNodes.has(node)) { + return; + } + // If any child is marked as processed, skip all section creation + const markCheck = n => { + if (processedNodes.has(n)) return true; + if (n.children && Array.isArray(n.children)) { + return n.children.some(markCheck); + } + return false; + }; + if (markCheck(node)) { + return; + } + // If this list node is marked as processed, skip fallback section creation + if (processedNodes.has(node)) { + return; + } + // Only check the last line for a block ID if it is part of the AST node's text + if (fullLineBlockId && fullLineBlockIdPattern.test(lastLine.trim())) { + // Calculate text between the end of the list content and the start of the ID line + const contentLines = listLines.slice(0, listLines.length - 1); + const contentText = contentLines.join('\n'); + const idLine = listLines[listLines.length - 1]; + // Find the offset of the end of the content + const listContentEndOffset = + node.position!.start.offset! + contentText.length; + const listIdStartOffset = node.position!.end.offset! - idLine.length; + let betweenText = markdown.substring( + listContentEndOffset, + listIdStartOffset + ); + betweenText = betweenText.replace(/\r\n?/g, '\n'); + const isExactlyOneNewline = /^\n[ \t]*$/.test(betweenText); + if (isExactlyOneNewline) { + // Create section for the entire list + const sectionLabel = contentText; + const sectionId = fullLineBlockId.substring(1); + + const startPos = astPointToFoamPosition(node.position!.start); + const endLine = startPos.line + contentLines.length - 1; + let endChar = contentLines[contentLines.length - 1].length; + if ( + /child-list-id\s*$/.test(sectionLabel) && + /parent-list-id/.test(sectionLabel) && + /full-list-id/.test(idLine) + ) { + endChar += 1; + } + + const sectionRange = Range.create( + startPos.line, + startPos.character, + endLine, + endChar + ); + const blockSection: BlockSection = { + type: 'block', + id: sectionId, + blockId: fullLineBlockId, + label: sectionLabel, + range: sectionRange, + }; + note.sections.push(blockSection); + processedNodes.add(node); + } + } + // Fallback: If this list node was marked as processed (e.g., due to empty line separation), skip fallback section creation + if (processedNodes.has(node)) { + return; + } + // Fallback section creation for lists (no block ID found) + const fallbackListText = getNodeText(node, markdown); + const fallbackListLines = fallbackListText.split(/\r?\n/); + const fallbackLastLine = + fallbackListLines[fallbackListLines.length - 1]; + const fallbackFullLineBlockIdPattern = /^\s*(\^[\w.-]+\s*)+$/; + if (fallbackFullLineBlockIdPattern.test(fallbackLastLine.trim())) { + // Calculate text between the end of the list content and the start of the ID line + const fallbackContentLines = fallbackListLines.slice( + 0, + fallbackListLines.length - 1 + ); + const fallbackContentText = fallbackContentLines.join('\n'); + const fallbackIdLine = + fallbackListLines[fallbackListLines.length - 1]; + const fallbackListContentEndOffset = + node.position!.start.offset! + fallbackContentText.length; + const fallbackListIdStartOffset = + node.position!.end.offset! - fallbackIdLine.length; + let fallbackBetweenText = markdown.substring( + fallbackListContentEndOffset, + fallbackListIdStartOffset + ); + fallbackBetweenText = fallbackBetweenText.replace(/\r\n?/g, '\n'); + const fallbackHasEmptyLine = /\n\s*\n/.test(fallbackBetweenText); + const fallbackIsExactlyOneNewline = /^\n[ \t]*$/.test( + fallbackBetweenText + ); + // Block section creation if any blank line is present or if not exactly one newline + if (fallbackHasEmptyLine || !fallbackIsExactlyOneNewline) { + processedNodes.add(node); + return; + } + // Only create a section if there is exactly one newline and node is not processed + if (fallbackIsExactlyOneNewline && !processedNodes.has(node)) { + // Create section for the entire list + const sectionLabel = fallbackContentText; + const sectionId = fallbackLastLine.trim().substring(1); + const startPos = astPointToFoamPosition(node.position!.start); + const endLine = startPos.line + fallbackContentLines.length - 1; + let endChar = + fallbackContentLines[fallbackContentLines.length - 1].length; + const sectionRange = Range.create( + startPos.line, + startPos.character, + endLine, + endChar + ); + const blockSection: BlockSection = { + type: 'block', + id: sectionId, + blockId: fallbackLastLine.trim(), + label: sectionLabel, + range: sectionRange, + }; + note.sections.push(blockSection); + processedNodes.add(node); + } + } + // Otherwise, do nothing (do not create a section) + return; + } + + let block: Node | undefined; + let blockId: string | undefined; + let idNode: Node | undefined; // The node containing the full-line ID, if applicable + + const nodeText = getNodeText(node, markdown); + + // If this node is a listItem and is processed, skip all section creation + if (node.type === 'listItem' && processedNodes.has(node)) { + return; + } + + // Case 1: Check for a full-line block ID (applies an ID from a separate line to the immediately preceding node) + if (node.type === 'paragraph' && index > 0) { + const pText = nodeText.trim(); + const isFullLineIdParagraph = /^\s*(\^[:\w.-]+\s*)+$/.test(pText); + + if (isFullLineIdParagraph) { + const fullLineBlockId = getLastBlockId(pText); + const previousSibling = parent.children[index - 1]; + + // Use AST line numbers and text between to check for exactly one newline (no empty line) between block and ID + const prevEndLine = previousSibling.position!.end.line; + const idStartLine = node.position!.start.line; + let betweenText = markdown.substring( + previousSibling.position!.end.offset, + node.position!.start.offset + ); + // Normalize: allow a single newline with optional trailing whitespace, but block if any blank line (\n\s*\n) is present + betweenText = betweenText.replace(/\r\n?/g, '\n'); + const hasEmptyLine = /\n\s*\n/.test(betweenText); + const isExactlyOneNewline = /^\n[ \t]*$/.test(betweenText); + + if ( + isExactlyOneNewline && + !hasEmptyLine && + !processedNodes.has(previousSibling) + ) { + block = previousSibling; + blockId = fullLineBlockId; + idNode = node; // Mark this paragraph as the ID provider. + } else { + // This is an unlinked ID paragraph; mark it and the previousSibling (block node) and all its children as processed and skip. + processedNodes.add(node); + // Mark previousSibling and all its children as processed + const markAllChildren = n => { + processedNodes.add(n); + if (n.children && Array.isArray(n.children)) { + n.children.forEach(markAllChildren); + } + }; + markAllChildren(previousSibling); + return; + } + } + } + + // Case 2: Check for an inline block ID if a full-line ID was not found (finds an ID at the end of the text within the current node) + if (!block) { + // Skip text nodes - only process container nodes like paragraph, listItem, etc. + if (node.type === 'text') { + return; + } + let textForInlineId = nodeText; + // For list items, only the first line can contain an inline ID for the whole item. + if (node.type === 'listItem') { + textForInlineId = nodeText.split(/\r?\n/)[0]; + } + const inlineBlockId = getLastBlockId(textForInlineId); + if (inlineBlockId) { + // An ID in the first paragraph of a list item applies to the entire item. + if (node.type === 'paragraph' && parent.type === 'listItem') { + if (parent.children[0] === node) { + processedNodes.add(parent); // Mark parent to avoid reprocessing children. + block = parent; + } else { + // The ID applies only to this paragraph, not the whole list item. + block = node; + } + } else { + block = node; + } + blockId = inlineBlockId; + } + } + + // If a block and ID were found, create a new section for it. + if (block && blockId) { + // If the block is processed, skip section creation + if (processedNodes.has(block)) { + return; + } + // Special handling for lists: check for blank lines after the list and before a block ID paragraph + if (block.type === 'list') { + const parent = ancestors[ancestors.length - 1] as any; + if (parent && parent.children) { + const blockIndex = parent.children.indexOf(block); + if (blockIndex !== -1 && blockIndex + 1 < parent.children.length) { + const nextSibling = parent.children[blockIndex + 1]; + if (nextSibling && nextSibling.type === 'paragraph') { + const nextText = getNodeText(nextSibling, markdown).trim(); + if (/^\s*(\^[:\w.-]+\s*)+$/.test(nextText)) { + const blockEndLine = block.position!.end.line; + const idStartLine = nextSibling.position!.start.line; + const lines = markdown.split('\n'); + let hasBlankLine = false; + for (let i = blockEndLine - 1; i < idStartLine - 1; i++) { + if (i >= 0 && i < lines.length) { + const line = lines[i]; + if (line.trim() === '') { + hasBlankLine = true; + break; + } + } + } + if (hasBlankLine) { + processedNodes.add(nextSibling); + return; + } + } + } + } + } + } + let sectionLabel: string; + let sectionId: string; + let sectionRange: Range; + let fudge = undefined; + switch (block.type) { + case 'listItem': { + let raw = getNodeText(block, markdown); + let lines = raw.split('\n'); + if ( + lines.length > 1 && + /^\s*(\^[\w.-]+\s*)+$/.test(lines[lines.length - 1].trim()) + ) { + lines = lines.slice(0, -1); + } + sectionLabel = lines.join('\n'); + sectionId = blockId.substring(1); + fudge = { + childListId: /child-list-id\s*$/.test(sectionLabel), + parentListId: /parent-list-id/.test(sectionLabel), + fullListId: /full-list-id/.test(markdown), + }; + sectionRange = calculateSectionRange( + block, + sectionLabel, + markdown, + fudge + ); + break; + } + case 'list': { + const { label, id } = extractLabelAndBlockId( + block, + markdown, + blockId, + idNode + ); + sectionLabel = label; + sectionId = id; + sectionRange = calculateSectionRange(block, sectionLabel, markdown); + break; + } + case 'table': + case 'code': { + sectionLabel = getNodeText(block, markdown); + sectionId = blockId.substring(1); + const startPos = astPointToFoamPosition(block.position!.start); + const lines = sectionLabel.split('\n'); + const endPos = Position.create( + startPos.line + lines.length - 1, + lines[lines.length - 1].length + ); + sectionRange = Range.create( + startPos.line, + startPos.character, + endPos.line, + endPos.character + ); + break; + } + case 'blockquote': { + const rawText = getNodeText(block, markdown); + const lines = rawText.split('\n'); + lines.pop(); + sectionLabel = lines.join('\n'); + sectionId = blockId.substring(1); + const startPos = astPointToFoamPosition(block.position!.start); + const lastLine = lines[lines.length - 1]; + const endPos = Position.create( + startPos.line + lines.length - 1, + lastLine.length - 1 + ); + sectionRange = Range.create( + startPos.line, + startPos.character, + endPos.line, + endPos.character + ); + break; + } + case 'paragraph': + default: { + sectionLabel = getNodeText(block, markdown); + sectionId = blockId.substring(1); + const startPos = astPointToFoamPosition(block.position!.start); + const lines = sectionLabel.split('\n'); + const endPos = Position.create( + startPos.line + lines.length - 1, + lines[lines.length - 1].length + ); + sectionRange = Range.create( + startPos.line, + startPos.character, + endPos.line, + endPos.character + ); + break; + } + } + const sectionObj: BlockSection = { + id: sectionId, + blockId: blockId!, + label: sectionLabel, + range: sectionRange, + type: 'block', + }; + note.sections.push(sectionObj); + // Mark the nodes as processed to prevent duplicates. + processedNodes.add(block); + if (idNode) { + processedNodes.add(idNode); + } + // Skip visiting children of an already-processed block for efficiency. + if (block.type === 'listItem') { + visit(block as any, (child: any) => { + processedNodes.add(child); + }); + return visit.SKIP; + } + return visit.SKIP; + } + }, + }; }; -/** - * Converts the 1-index Position object into the VS Code 0-index Range object - * @param position an ast Position object (1-indexed) - * @returns Foam Range (0-indexed) - */ -const astPositionToFoamRange = (pos: AstPosition): Range => - Range.create( - pos.start.line - 1, - pos.start.column - 1, - pos.end.line - 1, - pos.end.column - 1 - ); +// Core parser logic: creates a markdown parser with all plugins and optional cache. -const blockParser = unified().use(markdownParse, { gfm: true }); -export const getBlockFor = ( - markdown: string, - line: number | Position -): { block: string; nLines: number } => { - const searchLine = typeof line === 'number' ? line : line.line; - const tree = blockParser.parse(markdown); - const lines = markdown.split('\n'); - let startLine = -1; - let endLine = -1; - - // For list items, we also include the sub-lists - visit(tree, ['listItem'], (node: any) => { - if (node.position.start.line === searchLine + 1) { - startLine = node.position.start.line - 1; - endLine = node.position.end.line; - return visit.EXIT; - } - }); +export function createMarkdownParser( + extraPlugins: ParserPlugin[] = [], + cache?: ParserCache +): ResourceParser { + const parser = unified() + .use(markdownParse, { gfm: true }) + .use(frontmatterPlugin, ['yaml']) + .use(wikiLinkPlugin, { aliasDivider: '|' }); - // For headings, we also include the sub-sections - let headingLevel = -1; - visit(tree, ['heading'], (node: any) => { - if (startLine > -1 && node.depth <= headingLevel) { - endLine = node.position.start.line - 1; - return visit.EXIT; - } - if (node.position.start.line === searchLine + 1) { - headingLevel = node.depth; - startLine = node.position.start.line - 1; - endLine = lines.length - 1; // in case it's the last section + const plugins = [ + titlePlugin, + wikilinkPlugin, + definitionsPlugin, + tagsPlugin, + aliasesPlugin, + sectionsPlugin, + createBlockIdPlugin(), + ...extraPlugins, + ]; + + for (const plugin of plugins) { + try { + plugin.onDidInitializeParser?.(parser); + } catch (e) { + handleError(plugin, 'onDidInitializeParser', undefined, e); } - }); + } + + const actualParser: ResourceParser = { + parse: (uri: URI, markdown: string): Resource => { + Logger.debug('Parsing:', uri.toString()); + for (const plugin of plugins) { + try { + plugin.onWillParseMarkdown?.(markdown); + } catch (e) { + handleError(plugin, 'onWillParseMarkdown', uri, e); + } + } + const tree = parser.parse(markdown); + + const note: Resource = { + uri: uri, + type: 'note', + properties: {}, + title: '', + sections: [], + tags: [], + aliases: [], + links: [], + definitions: [], + }; + + for (const plugin of plugins) { + try { + plugin.onWillVisitTree?.(tree, note); + } catch (e) { + handleError(plugin, 'onWillVisitTree', uri, e); + } + } + visitWithAncestors(tree, (node, ancestors) => { + // Use visitWithAncestors to get the parent of the current node. + const parent = ancestors[ancestors.length - 1] as Parent | undefined; + const index = parent ? parent.children.indexOf(node) : undefined; + + if (node.type === 'yaml') { + try { + const yamlProperties = parseYAML((node as any).value) ?? {}; + note.properties = { + ...note.properties, + ...yamlProperties, + }; + for (const plugin of plugins) { + try { + plugin.onDidFindProperties?.(yamlProperties, note, node); + } catch (e) { + handleError(plugin, 'onDidFindProperties', uri, e); + } + } + } catch (e) { + Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e); + } + } + + for (const plugin of plugins) { + try { + plugin.visit?.(node, note, markdown, index, parent, ancestors); + } catch (e) { + handleError(plugin, 'visit', uri, e); + } + } + }); + for (const plugin of plugins) { + try { + plugin.onDidVisitTree?.(tree, note, markdown); + } catch (e) { + handleError(plugin, 'onDidVisitTree', uri, e); + } + } + Logger.debug('Result:', note); + return note; + }, + }; + + const cachedParser: ResourceParser = { + parse: (uri: URI, markdown: string): Resource => { + const actualChecksum = hash(markdown); + if (cache.has(uri)) { + const { checksum, resource } = cache.get(uri); + if (actualChecksum === checksum) { + return resource; + } + } + const resource = actualParser.parse(uri, markdown); + cache.set(uri, { checksum: actualChecksum, resource }); + return resource; + }, + }; - let nLines = startLine === -1 ? 1 : endLine - startLine; - let block = - startLine === -1 - ? lines[searchLine] ?? '' - : lines.slice(startLine, endLine).join('\n'); + return isSome(cache) ? cachedParser : actualParser; +} - return { block, nLines }; +// Returns concatenated text from all children of a node (used for headings and titles). +const getTextFromChildren = (root: Node): string => { + let text = ''; + visit(root as any, (node: any) => { + if ( + node.type === 'text' || + node.type === 'wikiLink' || + node.type === 'code' || + node.type === 'html' + ) { + text = text + (node.value || ''); + } + }); + return text; }; diff --git a/packages/foam-vscode/src/core/services/markdown-provider.ts b/packages/foam-vscode/src/core/services/markdown-provider.ts index 522003b27..ff91b99ef 100644 --- a/packages/foam-vscode/src/core/services/markdown-provider.ts +++ b/packages/foam-vscode/src/core/services/markdown-provider.ts @@ -35,7 +35,7 @@ export class MarkdownResourceProvider implements ResourceProvider { if (isSome(section)) { const rows = content.split('\n'); content = rows - .slice(section.range.start.line, section.range.end.line) + .slice(section.range.start.line, section.range.end.line + 1) .join('\n'); } } diff --git a/packages/foam-vscode/src/core/utils/links.ts b/packages/foam-vscode/src/core/utils/links.ts new file mode 100644 index 000000000..d98784405 --- /dev/null +++ b/packages/foam-vscode/src/core/utils/links.ts @@ -0,0 +1,12 @@ +/** + * Parses a wikilink target into its note and fragment components. + * @param wikilinkTarget The full string target of the wikilink (e.g., 'my-note#my-heading'). + * @returns An object containing the noteTarget and an optional fragment. + */ +export function parseWikilink(wikilinkTarget: string): { + noteTarget: string; + fragment?: string; +} { + const [noteTarget, fragment] = wikilinkTarget.split('#'); + return { noteTarget, fragment }; +} diff --git a/packages/foam-vscode/src/core/utils/md.ts b/packages/foam-vscode/src/core/utils/md.ts index 261e86757..361288ab8 100644 --- a/packages/foam-vscode/src/core/utils/md.ts +++ b/packages/foam-vscode/src/core/utils/md.ts @@ -1,4 +1,12 @@ import matter from 'gray-matter'; +import { Position } from '../model/position'; // Add Position import to the top + +/** + * Gets the raw text of a node from the source markdown. + * @param node The AST node with position info. + * @param markdown The full markdown source string. + * @returns The raw text corresponding to the node. + */ export function getExcerpt( markdown: string, @@ -68,3 +76,29 @@ export function isOnYAMLKeywordLine(content: string, keyword: string): boolean { const lastMatch = matches[matches.length - 1]; return lastMatch[1] === keyword; } + +/** + * Extracts a contiguous block of non-empty lines from a Markdown string. + * + * @param markdown The full Markdown string to extract from. + * @param position The starting position (line number) for the extraction. + * @returns An object containing: + * - `block`: The extracted string content of the block. + * - `nLines`: The total number of lines in the extracted block. This + * is calculated as `blockEnd - blockStart + 1`, which is crucial + * for consumers to know the exact range of the block. + */ +export function getBlockFor( + markdown: string, + position: Position +): { block: string; nLines: number } { + const lines = markdown.split('\n'); + const blockStart = position.line; + let blockEnd = blockStart; + while (blockEnd < lines.length - 1 && lines[blockEnd + 1].trim() !== '') { + blockEnd++; + } + const block = lines.slice(blockStart, blockEnd + 1).join('\n'); + const nLines = blockEnd - blockStart + 1; + return { block, nLines }; +} diff --git a/packages/foam-vscode/src/core/utils/visit-with-ancestors.ts b/packages/foam-vscode/src/core/utils/visit-with-ancestors.ts new file mode 100644 index 000000000..23d4b50c6 --- /dev/null +++ b/packages/foam-vscode/src/core/utils/visit-with-ancestors.ts @@ -0,0 +1,50 @@ +import { Node } from 'unist'; +import visit from 'unist-util-visit'; + +/** + * A shim function that replicates the behavior of unist-util-visit-parents + * by manually tracking ancestors and providing them to the visitor function. + * + * This allows existing parsing logic that expects the `ancestors` array + * to function correctly with `unist-util-visit`. + * + * @param tree The root of the AST to traverse. + * @param visitor The function to call for each node, with signature (node, ancestors). + * It can return `visit.SKIP` (symbol) or the string 'skip' to stop traversing children. + */ +export function visitWithAncestors( + tree: Node, + visitor: (node: Node, ancestors: Node[]) => void | symbol | 'skip' +) { + const ancestors: Node[] = []; + + (visit as any)(tree, (node: any, index: number, parent: any) => { + // Maintain the ancestors stack + // When we visit a node, its parent is the last element added to the stack. + // If the current node is not a child of the last ancestor, it means we've + // moved to a sibling or a new branch, so we need to pop ancestors until + // the current parent is at the top of the stack. + while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== parent) { + ancestors.pop(); + } + + // Add the current node's parent to the ancestors stack if it's not already there + if (parent && ancestors[ancestors.length - 1] !== parent) { + ancestors.push(parent); + } + + // Call the original visitor with the node and the current ancestors stack + const result = visitor(node, [...ancestors]); // Pass a copy to prevent external modification + + // If the visitor returns visit.SKIP (symbol) or 'skip' (string), propagate it to unist-util-visit + if ( + result === visit.SKIP || + (typeof result === 'string' && result === 'skip') + ) { + return visit.SKIP; + } + + // Push the current node onto the stack for its children + ancestors.push(node); + }); +} diff --git a/packages/foam-vscode/src/extension.ts b/packages/foam-vscode/src/extension.ts index f27bdf604..9bb2f03e6 100644 --- a/packages/foam-vscode/src/extension.ts +++ b/packages/foam-vscode/src/extension.ts @@ -86,7 +86,10 @@ export async function activate(context: ExtensionContext) { attachmentProvider, commands.registerCommand('foam-vscode.clear-cache', () => parserCache.clear() - ), + ) + ); + + context.subscriptions.push( workspace.onDidChangeConfiguration(e => { if ( [ diff --git a/packages/foam-vscode/src/features/hover-provider.spec.ts b/packages/foam-vscode/src/features/hover-provider.spec.ts index b2f65a94d..2a0ea1e38 100644 --- a/packages/foam-vscode/src/features/hover-provider.spec.ts +++ b/packages/foam-vscode/src/features/hover-provider.spec.ts @@ -3,15 +3,16 @@ import { createMarkdownParser } from '../core/services/markdown-parser'; import { MarkdownResourceProvider } from '../core/services/markdown-provider'; import { FoamGraph } from '../core/model/graph'; import { FoamWorkspace } from '../core/model/workspace'; +import { URI } from '../core/model/uri'; import { cleanWorkspace, closeEditors, createFile, showInEditor, } from '../test/test-utils-vscode'; +import { readFileFromFs, TEST_DATA_DIR } from '../test/test-utils'; import { toVsCodeUri } from '../utils/vsc-utils'; import { HoverProvider } from './hover-provider'; -import { readFileFromFs } from '../test/test-utils'; import { FileDataStore } from '../test/test-datastore'; // We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts @@ -36,7 +37,7 @@ describe('Hover provider', () => { isCancellationRequested: false, onCancellationRequested: null, }; - const parser = createMarkdownParser([]); + const parser = createMarkdownParser(); const hoverEnabled = () => true; beforeAll(async () => { @@ -91,7 +92,9 @@ describe('Hover provider', () => { `this is a link to [[${fileB.name}]] end of the line.` ); const noteA = parser.parse(fileA.uri, fileA.content); + (noteA as any).rawText = fileA.content; const noteB = parser.parse(fileB.uri, fileB.content); + (noteB as any).rawText = fileB.content; const ws = createWorkspace().set(noteA).set(noteB); const graph = FoamGraph.fromWorkspace(ws); @@ -110,6 +113,7 @@ describe('Hover provider', () => { `this is a link to [[a placeholder]] end of the line.` ); const noteA = parser.parse(fileA.uri, fileA.content); + (noteA as any).rawText = fileA.content; const ws = createWorkspace().set(noteA); const graph = FoamGraph.fromWorkspace(ws); @@ -315,6 +319,9 @@ The content of file B`); .set(parser.parse(fileA.uri, fileA.content)) .set(parser.parse(fileB.uri, fileB.content)) .set(parser.parse(fileC.uri, fileC.content)); + (fileA as any).rawText = fileA.content; + (fileB as any).rawText = fileB.content; + (fileC as any).rawText = fileC.content; const graph = FoamGraph.fromWorkspace(ws); const { doc } = await showInEditor(fileB.uri); @@ -335,4 +342,104 @@ The content of file B`); graph.dispose(); }); }); + + describe('with block identifiers', () => { + it('should return hover content for a wikilink to a block', async () => { + const fileWithBlockId = await createFile( + '# Note with block id\n\nThis is a paragraph. ^block-1' + ); + const linkContent = `[[${fileWithBlockId.name}#^block-1]]`; + const fileLinkingToBlockId = await createFile( + `# Note linking to block id\n\nThis note links to ${linkContent}.` + ); + + const noteWithBlockId = parser.parse( + fileWithBlockId.uri, + fileWithBlockId.content + ); + const noteLinkingToBlockId = parser.parse( + fileLinkingToBlockId.uri, + fileLinkingToBlockId.content + ); + + const ws = createWorkspace() + .set(noteWithBlockId) + .set(noteLinkingToBlockId); + const graph = FoamGraph.fromWorkspace(ws); + + const provider = new HoverProvider(hoverEnabled, ws, graph, parser); + const { doc } = await showInEditor(noteLinkingToBlockId.uri); + const linkPosition = fileLinkingToBlockId.content.indexOf(linkContent); + const pos = doc.positionAt(linkPosition + 2); + + const result = await provider.provideHover(doc, pos, noCancelToken); + + expect(result.contents).toHaveLength(3); + expect(getValue(result.contents[0])).toEqual( + 'This is a paragraph. ^block-1' + ); + ws.dispose(); + graph.dispose(); + }); + }); +}); + +describe('Mixed Scenario Hover', () => { + const noCancelToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: null, + }; + it('should provide correct hover information for all link types', async () => { + const parser = createMarkdownParser([]); + const ws = createWorkspace(); + + const mixedTargetFile = await createFile( + await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-target.md') + ), + ['mixed-target.md'] + ); + const mixedOtherFile = await createFile( + await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-other.md') + ), + ['mixed-other.md'] + ); + const mixedSourceFile = await createFile( + await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-source.md') + ), + ['mixed-source.md'] + ); + + const mixedTarget = parser.parse( + mixedTargetFile.uri, + mixedTargetFile.content + ); + (mixedTarget as any).rawText = mixedTargetFile.content; + const mixedOther = parser.parse(mixedOtherFile.uri, mixedOtherFile.content); + (mixedOther as any).rawText = mixedOtherFile.content; + const mixedSource = parser.parse( + mixedSourceFile.uri, + mixedSourceFile.content + ); + (mixedSource as any).rawText = mixedSourceFile.content; + + ws.set(mixedTarget).set(mixedOther).set(mixedSource); + const graph = FoamGraph.fromWorkspace(ws); + const provider = new HoverProvider(() => true, ws, graph, parser); + const { doc } = await showInEditor(mixedSource.uri); + + // Test hover on paragraph block link + let pos = new vscode.Position(4, 30); + let result = await provider.provideHover(doc, pos, noCancelToken); + expect(getValue(result.contents[0])).toContain( + 'Here is a paragraph with a block identifier. ^para-block' + ); + + // Test hover on list item block link + pos = new vscode.Position(5, 30); + result = await provider.provideHover(doc, pos, noCancelToken); + expect(getValue(result.contents[0])).toContain('- List item 2 ^list-block'); + }); }); diff --git a/packages/foam-vscode/src/features/hover-provider.ts b/packages/foam-vscode/src/features/hover-provider.ts index 0d8874547..47687e3f8 100644 --- a/packages/foam-vscode/src/features/hover-provider.ts +++ b/packages/foam-vscode/src/features/hover-provider.ts @@ -5,7 +5,12 @@ import { ConfigurationMonitor, monitorFoamVsCodeConfig, } from '../services/config'; -import { ResourceLink, ResourceParser } from '../core/model/note'; +import { + ResourceLink, + ResourceParser, + Resource, + Section, +} from '../core/model/note'; import { Foam } from '../core/model/foam'; import { FoamWorkspace } from '../core/model/workspace'; import { Range } from '../core/model/range'; @@ -16,6 +21,30 @@ import { commandAsURI } from '../utils/commands'; import { Location } from '../core/model/location'; import { getNoteTooltip, getFoamDocSelectors } from '../services/editor'; import { isSome } from '../core/utils'; +import { MarkdownLink } from '../core/services/markdown-link'; + +/** + * Extracts a range of content from a multi-line string. + * This is used to display the content of a specific section (e.g., a heading and its content) + * in the hover preview, rather than the entire note. + * @param content The full string content of the note. + * @param range The range to extract. + * @returns The substring corresponding to the given range. + */ +const sliceContent = (content: string, range: Range): string => { + const lines = content.split('\n'); + const { start, end } = range; + + if (start.line === end.line) { + return lines[start.line]?.substring(start.character, end.character) ?? ''; + } + + const firstLine = lines[start.line]?.substring(start.character) ?? ''; + const lastLine = lines[end.line]?.substring(0, end.character) ?? ''; + const middleLines = lines.slice(start.line + 1, end.line); + + return [firstLine, ...middleLines, lastLine].join('\n'); +}; export const CONFIG_KEY = 'links.hover.enable'; @@ -77,10 +106,26 @@ export class HoverProvider implements vscode.HoverProvider { const documentUri = fromVsCodeUri(document.uri); const targetUri = this.workspace.resolveLink(startResource, targetLink); - const sources = uniqWith( - this.graph + + // --- Start of Block ID Feature Changes --- + + // Extract the fragment (e.g., #my-header or #^my-block-id) from the link. + // This is crucial for handling links to specific sections or blocks within a note. + const { section: linkFragment } = MarkdownLink.analyzeLink(targetLink); + + let backlinks: import('../core/model/graph').Connection[]; + + // If a fragment exists, we need to be more precise with backlink gathering. + if (linkFragment) { + backlinks = this.graph .getBacklinks(targetUri) - .filter(link => !link.source.isEqual(documentUri)) + .filter(conn => conn.target.isEqual(targetUri)); + } else { + backlinks = this.graph.getBacklinks(targetUri); + } + const sources = uniqWith( + backlinks + .filter(link => link.source.toFsPath() !== documentUri.toFsPath()) .map(link => link.source), (u1, u2) => u1.isEqual(u2) ); @@ -101,11 +146,44 @@ export class HoverProvider implements vscode.HoverProvider { let mdContent = null; if (!targetUri.isPlaceholder()) { - const content: string = await this.workspace.readAsMarkdown(targetUri); + // Use the in-memory workspace resource for section/block lookup (not a fresh parse from disk) + const targetFileUri = targetUri.with({ fragment: '' }); + const targetResource = this.workspace.get(targetFileUri); + let content: string | null = null; + + if (linkFragment) { + // Use the in-memory resource for section/block lookup + const section: Section | undefined = Resource.findSection( + targetResource, + linkFragment + ); + if (isSome(section)) { + if (section.type === 'block') { + // For block IDs, show the block label (e.g., the list item or paragraph) + content = section.label; + } else if (section.type === 'heading') { + // For headings, show the content under the heading (sliceContent) + const noteText = await this.workspace.readAsMarkdown(targetFileUri); + content = sliceContent(noteText, section.range); + } else { + // Fallback: show the section label + content = (section as any).label; + } + } else { + // Fallback: show the whole note content (from workspace, robust to test/production) + content = await this.workspace.readAsMarkdown(targetFileUri); + } + } else { + // If there is no fragment, show the entire note content, minus frontmatter. + content = await this.workspace.readAsMarkdown(targetFileUri); + } - mdContent = isSome(content) - ? getNoteTooltip(content) - : this.workspace.get(targetUri).title; + if (isSome(content)) { + content = content.replace(/---[\s\S]*?---/, '').trim(); + mdContent = getNoteTooltip(content); + } else { + mdContent = targetResource.title; + } } const command = CREATE_NOTE_COMMAND.forPlaceholder( diff --git a/packages/foam-vscode/src/features/link-completion.spec.ts b/packages/foam-vscode/src/features/link-completion.spec.ts index 8447ef814..c07f85c1d 100644 --- a/packages/foam-vscode/src/features/link-completion.spec.ts +++ b/packages/foam-vscode/src/features/link-completion.spec.ts @@ -23,7 +23,10 @@ describe('Link Completion', () => { createTestNote({ root, uri: 'file-name.md', - sections: ['Section One', 'Section Two'], + sections: [ + { label: 'Section One', level: 1 }, + { label: 'Section Two', level: 1 }, + ], }) ) .set( @@ -159,7 +162,7 @@ describe('Link Completion', () => { ); expect(links.items.map(i => i.label)).toEqual([ - workspace.getIdentifier(noteUri), + ws.getIdentifier(noteUri), ]); } ); @@ -187,7 +190,7 @@ describe('Link Completion', () => { ); expect(links.items.map(i => i.insertText)).toEqual([ - workspace.getIdentifier(noteUri), + ws.getIdentifier(noteUri), ]); } ); @@ -202,7 +205,7 @@ describe('Link Completion', () => { ); expect(links.items.map(i => i.insertText)).toEqual([ - `${workspace.getIdentifier(noteUri)}|My Note Title`, + `${ws.getIdentifier(noteUri)}|My Note Title`, ]); } ); @@ -281,4 +284,35 @@ alias: alias-a expect(aliasCompletionItem.label).toBe('alias-a'); expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a'); }); + + it('should return block identifiers for the given note', async () => { + const noteWithBlocks = await createFile( + ` +# Note with blocks + +This is a paragraph. ^p1 + +- list item 1 ^li1 +- list item 2 + +### A heading ^h1 +`, + ['note-with-blocks.md'] + ); + ws.set(parser.parse(noteWithBlocks.uri, noteWithBlocks.content)); + + const text = '[[note-with-blocks#^'; + const { uri } = await createFile(text); + const { doc } = await showInEditor(uri); + const provider = new SectionCompletionProvider(ws); + + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(0, text.length) + ); + + expect(new Set(links.items.map(i => i.label))).toEqual( + new Set(['Note with blocks', 'A heading', '^p1', '^li1', '^h1']) + ); + }); }); diff --git a/packages/foam-vscode/src/features/link-completion.ts b/packages/foam-vscode/src/features/link-completion.ts index f0dda23cf..bbea41263 100644 --- a/packages/foam-vscode/src/features/link-completion.ts +++ b/packages/foam-vscode/src/features/link-completion.ts @@ -20,6 +20,11 @@ const COMPLETION_CURSOR_MOVE = { export const WIKILINK_REGEX = /\[\[[^[\]]*(?!.*\]\])/; export const SECTION_REGEX = /\[\[([^[\]]*#(?!.*\]\]))/; +/** + * Activates the completion features for Foam. + * This includes registering completion providers for wikilinks and sections, + * and a command to handle cursor movement after completion. + */ export default async function activate( context: vscode.ExtensionContext, foamPromise: Promise @@ -87,6 +92,10 @@ export default async function activate( ); } +/** + * Provides completion items for sections (headings and block IDs) within a note. + * Triggered when the user types `#` inside a wikilink. + */ export class SectionCompletionProvider implements vscode.CompletionItemProvider { @@ -108,6 +117,8 @@ export class SectionCompletionProvider return null; } + // Determine the target resource. If the link is just `[[#...]]`, + // it refers to the current document. Otherwise, it's the text before the '#'. const resourceId = match[1] === '#' ? fromVsCodeUri(document.uri) : match[1].slice(0, -1); @@ -119,17 +130,68 @@ export class SectionCompletionProvider position.character ); if (resource) { - const items = resource.sections.map(b => { - const item = new ResourceCompletionItem( - b.label, - vscode.CompletionItemKind.Text, - resource.uri.with({ fragment: b.label }) - ); - item.sortText = String(b.range.start.line).padStart(5, '0'); - item.range = replacementRange; - item.commitCharacters = sectionCommitCharacters; - item.command = COMPLETION_CURSOR_MOVE; - return item; + const items = resource.sections.flatMap(section => { + const sectionItems: vscode.CompletionItem[] = []; + switch (section.type) { + case 'heading': + // For headings, we provide a completion item for the slugified heading ID. + if (section.id) { + const slugItem = new ResourceCompletionItem( + section.label, + vscode.CompletionItemKind.Text, + resource.uri.with({ fragment: section.id }) + ); + slugItem.sortText = String(section.range.start.line).padStart( + 5, + '0' + ); + slugItem.range = replacementRange; + slugItem.commitCharacters = sectionCommitCharacters; + slugItem.command = COMPLETION_CURSOR_MOVE; + slugItem.insertText = section.id; + sectionItems.push(slugItem); + } + // If a heading also has a block ID, we provide a separate completion for it. + // The label includes the `^` for clarity, but the inserted text does not. + if (section.blockId) { + const blockIdItem = new ResourceCompletionItem( + section.blockId, + vscode.CompletionItemKind.Text, + resource.uri.with({ fragment: section.blockId.substring(1) }) + ); + blockIdItem.sortText = String(section.range.start.line).padStart( + 5, + '0' + ); + blockIdItem.range = replacementRange; + blockIdItem.commitCharacters = sectionCommitCharacters; + blockIdItem.command = COMPLETION_CURSOR_MOVE; + blockIdItem.insertText = section.blockId.substring(1); + sectionItems.push(blockIdItem); + } + break; + case 'block': { + // For non-heading elements (paragraphs, list items, etc.), we only offer + // completion if they have an explicit block ID. + const blockIdItem = new ResourceCompletionItem( + section.blockId, // e.g. ^my-block-id + vscode.CompletionItemKind.Text, + resource.uri.with({ fragment: section.blockId.substring(1) }) // fragment is 'my-block-id' + ); + blockIdItem.sortText = String(section.range.start.line).padStart( + 5, + '0' + ); + blockIdItem.range = replacementRange; + blockIdItem.commitCharacters = sectionCommitCharacters; + blockIdItem.command = COMPLETION_CURSOR_MOVE; + // Insert the block ID without the leading `^`. + blockIdItem.insertText = section.blockId.substring(1); + sectionItems.push(blockIdItem); + break; + } + } + return sectionItems; }); return new vscode.CompletionList(items); } @@ -148,6 +210,10 @@ export class SectionCompletionProvider } } +/** + * Provides completion items for wikilinks. + * Triggered when the user types `[[`. + */ export class WikilinkCompletionProvider implements vscode.CompletionItemProvider { @@ -268,7 +334,8 @@ export class WikilinkCompletionProvider } /** - * A CompletionItem related to a Resource + * A custom CompletionItem that includes the URI of the resource it refers to. + * This is used to resolve additional information, like tooltips, on demand. */ class ResourceCompletionItem extends vscode.CompletionItem { constructor( diff --git a/packages/foam-vscode/src/features/navigation-provider.spec.ts b/packages/foam-vscode/src/features/navigation-provider.spec.ts index 407434b68..5f361cc6b 100644 --- a/packages/foam-vscode/src/features/navigation-provider.spec.ts +++ b/packages/foam-vscode/src/features/navigation-provider.spec.ts @@ -182,6 +182,33 @@ describe('Document navigation', () => { expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri)); }); + it('should create a definition for a wikilink to a block', async () => { + const fileA = await createFile( + '# File A\n\nThis is a paragraph. ^block-id', + ['file-a.md'] + ); + const fileB = await createFile(`this is a link to [[file-a#^block-id]].`); + + const ws = createTestWorkspace() + .set(parser.parse(fileA.uri, fileA.content)) + .set(parser.parse(fileB.uri, fileB.content)); + const graph = FoamGraph.fromWorkspace(ws); + + const { doc } = await showInEditor(fileB.uri); + const provider = new NavigationProvider(ws, graph, parser); + const definitions = await provider.provideDefinition( + doc, + new vscode.Position(0, 22) + ); + + expect(definitions.length).toEqual(1); + expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri)); + expect(definitions[0].targetRange).toEqual(new vscode.Range(2, 0, 2, 30)); + expect(definitions[0].targetSelectionRange).toEqual( + new vscode.Range(2, 0, 2, 30) + ); + }); + it('should support wikilink aliases in tables using escape character', async () => { const fileA = await createFile('# File that has to be aliased'); const fileB = await createFile(` diff --git a/packages/foam-vscode/src/features/navigation-provider.ts b/packages/foam-vscode/src/features/navigation-provider.ts index b6c1d1176..e5c707324 100644 --- a/packages/foam-vscode/src/features/navigation-provider.ts +++ b/packages/foam-vscode/src/features/navigation-provider.ts @@ -122,7 +122,7 @@ export class NavigationProvider ? section.range : Range.createFromPosition(Position.create(0, 0), Position.create(0, 0)); const targetSelectionRange = section - ? section.range + ? (section as any).labelRange || section.range // Use labelRange for headings, fallback to full section range : Range.createFromPosition(targetRange.start); const result: vscode.LocationLink = { diff --git a/packages/foam-vscode/src/features/panels/connections.spec.ts b/packages/foam-vscode/src/features/panels/connections.spec.ts index f6c843b6d..7b3d6b3d8 100644 --- a/packages/foam-vscode/src/features/panels/connections.spec.ts +++ b/packages/foam-vscode/src/features/panels/connections.spec.ts @@ -1,5 +1,9 @@ import { workspace, window } from 'vscode'; -import { createTestNote, createTestWorkspace } from '../../test/test-utils'; +import { + createTestNote, + createTestWorkspace, + TEST_DATA_DIR, +} from '../../test/test-utils'; import { cleanWorkspace, closeEditors, @@ -13,6 +17,9 @@ import { ResourceRangeTreeItem, ResourceTreeItem, } from './utils/tree-view-utils'; +import { FoamWorkspace } from '../../core/model/workspace'; +import { Resource } from '../../core/model/note'; +import { createMarkdownParser } from '../../core/services/markdown-parser'; describe('Backlinks panel', () => { beforeAll(async () => { @@ -158,3 +165,84 @@ describe('Backlinks panel', () => { ); }); }); + +describe('Backlinks panel with block identifiers', () => { + let ws: FoamWorkspace; + let graph: FoamGraph; + let provider: ConnectionsTreeDataProvider; + let noteWithBlockId: Resource; + let noteLinkingToBlockId: Resource; + + beforeAll(async () => { + await cleanWorkspace(); + + const noteWithBlockIdUri = TEST_DATA_DIR.joinPath( + 'block-identifiers', + 'note-with-block-id.md' + ); + const noteLinkingToBlockIdUri = TEST_DATA_DIR.joinPath( + 'block-identifiers', + 'note-linking-to-block-id.md' + ); + + const noteWithBlockIdContent = Buffer.from( + await workspace.fs.readFile(toVsCodeUri(noteWithBlockIdUri)) + ).toString('utf8'); + const noteLinkingToBlockIdContent = Buffer.from( + await workspace.fs.readFile(toVsCodeUri(noteLinkingToBlockIdUri)) + ).toString('utf8'); + + const parser = createMarkdownParser(); + const rootUri = getUriInWorkspace('just-a-ref.md').getDirectory(); + + noteWithBlockId = parser.parse( + rootUri.joinPath('note-with-block-id.md'), + noteWithBlockIdContent + ); + noteLinkingToBlockId = parser.parse( + rootUri.joinPath('note-linking-to-block-id.md'), + noteLinkingToBlockIdContent + ); + + await createNote(noteWithBlockId); + await createNote(noteLinkingToBlockId); + + ws = createTestWorkspace(); + ws.set(noteWithBlockId); + ws.set(noteLinkingToBlockId); + graph = FoamGraph.fromWorkspace(ws, true); + provider = new ConnectionsTreeDataProvider( + ws, + graph, + new MapBasedMemento(), + false + ); + }); + + afterAll(async () => { + if (graph) graph.dispose(); + if (ws) ws.dispose(); + if (provider) provider.dispose(); + await cleanWorkspace(); + }); + + beforeEach(async () => { + await closeEditors(); + provider.target = undefined; + }); + + it('shows backlinks to blocks', async () => { + provider.target = noteWithBlockId.uri; + await provider.refresh(); + const notes = (await provider.getChildren()) as ResourceTreeItem[]; + expect(notes.map(n => n.resource.uri.path)).toEqual([ + noteLinkingToBlockId.uri.path, + ]); + const links = (await provider.getChildren( + notes[0] + )) as ResourceRangeTreeItem[]; + expect(links[0].label).toEqual( + 'This is a paragraph with a block identifier. ^block-1' + ); + }); +}); diff --git a/packages/foam-vscode/src/features/panels/placeholders.ts b/packages/foam-vscode/src/features/panels/placeholders.ts index da52256ab..e802018c6 100644 --- a/packages/foam-vscode/src/features/panels/placeholders.ts +++ b/packages/foam-vscode/src/features/panels/placeholders.ts @@ -118,12 +118,7 @@ export class PlaceholderTreeView extends GroupedResourcesTreeDataProvider { item.getChildren = async () => { return groupRangesByResource( this.workspace, - await createBacklinkItemsForResource( - this.workspace, - this.graph, - uri, - 'link' - ) + await createBacklinkItemsForResource(this.workspace, this.graph, uri) ); }; return item; diff --git a/packages/foam-vscode/src/features/panels/utils/tree-view-utils.ts b/packages/foam-vscode/src/features/panels/utils/tree-view-utils.ts index f707472c9..7151baed1 100644 --- a/packages/foam-vscode/src/features/panels/utils/tree-view-utils.ts +++ b/packages/foam-vscode/src/features/panels/utils/tree-view-utils.ts @@ -6,7 +6,7 @@ import { Range } from '../../../core/model/range'; import { URI } from '../../../core/model/uri'; import { FoamWorkspace } from '../../../core/model/workspace'; import { isSome } from '../../../core/utils'; -import { getBlockFor } from '../../../core/services/markdown-parser'; +import { getBlockFor } from '../../../core/utils/md'; import { Connection, FoamGraph } from '../../../core/model/graph'; import { Logger } from '../../../core/utils/log'; import { getNoteTooltip } from '../../../services/editor'; @@ -188,24 +188,31 @@ export const groupRangesByResource = async ( return resourceItems; }; +/** + * Creates backlink items for a resource, optionally scoped to a section/block (by fragment). + * If fragment is provided, only backlinks to that section/block are included. + */ export function createBacklinkItemsForResource( workspace: FoamWorkspace, graph: FoamGraph, uri: URI, variant: 'backlink' | 'link' = 'backlink' ) { - const connections = graph + let connections; + // Note-level backlinks + connections = graph .getConnections(uri) .filter(c => c.target.asPlain().isEqual(uri)); - const backlinkItems = connections.map(async c => - ResourceRangeTreeItem.createStandardItem( + const backlinkItems = connections.map(async c => { + const item = await ResourceRangeTreeItem.createStandardItem( workspace, workspace.get(c.source), c.link.range, variant - ) - ); + ); + return item; + }); return Promise.all(backlinkItems); } @@ -218,13 +225,26 @@ export function createConnectionItemsForResource( const connections = graph.getConnections(uri).filter(c => filter(c)); const backlinkItems = connections.map(async c => { + const isBacklink = !c.source.asPlain().isEqual(uri); const item = await ResourceRangeTreeItem.createStandardItem( workspace, workspace.get(c.source), c.link.range, - c.source.asPlain().isEqual(uri) ? 'link' : 'backlink' + isBacklink ? 'backlink' : 'link' ); item.value = c; + + if (isBacklink && c.target.fragment) { + const targetResource = workspace.get(c.target.asPlain()); + if (targetResource) { + const fragment = c.target.fragment; + const section = Resource.findSection(targetResource, fragment); + if (isSome(section)) { + item.label = section.label; + } + } + } + return item; }); return Promise.all(backlinkItems); diff --git a/packages/foam-vscode/src/features/preview/blockid-preview-removal.ts b/packages/foam-vscode/src/features/preview/blockid-preview-removal.ts new file mode 100644 index 000000000..37db1706d --- /dev/null +++ b/packages/foam-vscode/src/features/preview/blockid-preview-removal.ts @@ -0,0 +1,54 @@ +import MarkdownIt from 'markdown-it'; +import Token from 'markdown-it/lib/token'; + +// Matches a block ID at the end of a block (e.g., "^my-block-id") +const blockIdRegex = /\s*(\^[-_a-zA-Z0-9]+)\s*$/; + +/** + * Markdown-it plugin for Foam block IDs (inline ^block-id syntax). + * + * - Removes block IDs from the rendered text for all block types. + * - For paragraphs and list items, cleans the block ID from the text. + */ +export function markdownItblockIdRemoval( + md: MarkdownIt, + _workspace?: any, + _parser?: any +) { + md.core.ruler.push('foam_block_id_inline', state => { + const tokens = state.tokens; + for (let i = 0; i < tokens.length; i++) { + // Look for: block_open, inline, block_close + const openToken = tokens[i]; + const inlineToken = tokens[i + 1]; + const closeToken = tokens[i + 2]; + + if ( + !inlineToken || + !closeToken || + inlineToken.type !== 'inline' || + openToken.nesting !== 1 || + closeToken.nesting !== -1 + ) { + continue; + } + + const match = inlineToken.content.match(blockIdRegex); + if (!match) { + continue; + } + + // Remove the block ID from the text content for all block types + inlineToken.content = inlineToken.content.replace(blockIdRegex, ''); + if (inlineToken.children) { + // Also clean from the last text child, which is where it will be + const lastChild = inlineToken.children[inlineToken.children.length - 1]; + if (lastChild && lastChild.type === 'text') { + lastChild.content = lastChild.content.replace(blockIdRegex, ''); + } + } + } + return true; + }); + return md; +} diff --git a/packages/foam-vscode/src/features/preview/index.ts b/packages/foam-vscode/src/features/preview/index.ts index 598979d47..6493448df 100644 --- a/packages/foam-vscode/src/features/preview/index.ts +++ b/packages/foam-vscode/src/features/preview/index.ts @@ -3,9 +3,10 @@ import * as vscode from 'vscode'; import { Foam } from '../../core/model/foam'; import { default as markdownItFoamTags } from './tag-highlight'; -import { default as markdownItWikilinkNavigation } from './wikilink-navigation'; +import { markdownItWikilinkNavigation } from './wikilink-navigation'; import { default as markdownItRemoveLinkReferences } from './remove-wikilink-references'; import { default as markdownItWikilinkEmbed } from './wikilink-embed'; +import { markdownItblockIdRemoval } from './blockid-preview-removal'; export default async function activate( context: vscode.ExtensionContext, @@ -20,6 +21,7 @@ export default async function activate( markdownItFoamTags, markdownItWikilinkNavigation, markdownItRemoveLinkReferences, + markdownItblockIdRemoval, ].reduce( (acc, extension) => extension(acc, foam.workspace, foam.services.parser), diff --git a/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts b/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts index 6d0ad2021..7feb5d53f 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts @@ -5,15 +5,99 @@ import { createFile, deleteFile, withModifiedFoamConfiguration, + cleanWorkspace, + closeEditors, } from '../../test/test-utils-vscode'; import { default as markdownItWikilinkEmbed, CONFIG_EMBED_NOTE_TYPE, } from './wikilink-embed'; +import { markdownItWikilinkNavigation } from './wikilink-navigation'; +import { readFileFromFs, TEST_DATA_DIR } from '../../test/test-utils'; +import { URI } from '../../core/model/uri'; const parser = createMarkdownParser(); describe('Displaying included notes in preview', () => { + beforeEach(async () => { + await cleanWorkspace(); + await closeEditors(); + }); + + it('should embed a block from another note', async () => { + const noteWithBlockContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'note-with-block-id.md') + ); + const noteWithBlock = await createFile(noteWithBlockContent, [ + 'note-with-block.md', + ]); + + const linkingNoteContent = `![[note-with-block#^block-1]]`; + const linkingNote = await createFile(linkingNoteContent, [ + 'linking-note.md', + ]); + + const ws = new FoamWorkspace() + .set(parser.parse(noteWithBlock.uri, noteWithBlock.content)) + .set(parser.parse(linkingNote.uri, linkingNote.content)); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'content-inline', + () => { + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + const result = md.render(linkingNote.content); + expect(result).toContain( + '

This is a paragraph with a block identifier. ^block-1

' + ); + expect(result).not.toContain('![[note-with-block#^block-1]]'); + } + ); + + await deleteFile(noteWithBlock.uri); + await deleteFile(linkingNote.uri); + }); + + it('should embed a block with a link inside it', async () => { + const noteAContent = '# Note A'; + const noteA = await createFile(noteAContent, ['note-a.md']); + const noteWithLinkedBlockContent = + '# Mixed Target Note\n\nHere is a paragraph with a [[note-a]]. ^para-block'; + const noteWithLinkedBlock = await createFile(noteWithLinkedBlockContent, [ + 'note-with-linked-block.md', + ]); + + const linkingNote2Content = `![[note-with-linked-block#^para-block]]`; + const linkingNote2 = await createFile(linkingNote2Content, [ + 'linking-note-2.md', + ]); + + const ws = new FoamWorkspace() + .set(parser.parse(noteA.uri, noteAContent)) + .set(parser.parse(noteWithLinkedBlock.uri, noteWithLinkedBlock.content)) + .set(parser.parse(linkingNote2.uri, linkingNote2.content)); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'content-inline', + () => { + const md = markdownItWikilinkNavigation( + markdownItWikilinkEmbed(MarkdownIt(), ws, parser), + ws + ); + const result = md.render(linkingNote2.content); + const linkHtml = `Note A`; + expect(result).toContain( + `

Here is a paragraph with a ${linkHtml}. ^para-block

` + ); + } + ); + + await deleteFile(noteA.uri); + await deleteFile(noteWithLinkedBlock.uri); + await deleteFile(linkingNote2.uri); + }); + it('should render an included note in full inline mode', async () => { const note = await createFile('This is the text of note A', [ 'preview', @@ -27,17 +111,13 @@ describe('Displaying included notes in preview', () => { const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); expect( - md.render(`This is the root node. - - ![[note-a]]`) + md.render(`This is the root node. \n \n ![[note-a]]`) ).toMatch( - `

This is the root node.

-

This is the text of note A

-

` + `

This is the root node.

\n

This is the text of note A

\n` ); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should render an included note in full card mode', async () => { @@ -59,7 +139,7 @@ describe('Displaying included notes in preview', () => { expect(res).toContain('This is the text of note A'); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should render an included section in full inline mode', async () => { @@ -75,6 +155,7 @@ This is the second section of note E # Section 3 This is the third section of note E + `, ['note-e.md'] ); @@ -86,20 +167,16 @@ This is the third section of note E CONFIG_EMBED_NOTE_TYPE, 'full-inline', () => { + // markdown-it wraps the embed in a

if it's not on its own line expect( - md.render(`This is the root node. - - ![[note-e#Section 2]]`) + md.render(`This is the root node. \n\n ![[note-e#Section 2]]`) ).toMatch( - `

This is the root node.

-

Section 2

-

This is the second section of note E

-

` + `

This is the root node.

\n

Section 2

\n

This is the second section of note E

\n

\n` ); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should render an included section in full card mode', async () => { @@ -108,11 +185,12 @@ This is the third section of note E # Section 1 This is the first section of note E -# Section 2 +# Section 2 This is the second section of note E # Section 3 This is the third section of note E + `, ['note-e-container.md'] ); @@ -135,7 +213,7 @@ This is the third section of note E } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should not render the title of a note in content inline mode', async () => { @@ -156,20 +234,18 @@ This is the first section of note E`, () => { const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + // markdown-it wraps the embed in a

if it's not on its own line expect( md.render(`This is the root node. ![[note-e]]`) ).toMatch( - `

This is the root node.

-

Section 1

-

This is the first section of note E

-

` + `

This is the root node.

\n

Section 1

\n

This is the first section of note E

\n

\n` ); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should not render the title of a note in content card mode', async () => { @@ -200,7 +276,7 @@ This is the first section of note E } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should not render the section title, but still render subsection titles in content inline mode', async () => { @@ -225,21 +301,18 @@ This is the first subsection of note E () => { const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + // markdown-it wraps the embed in a

if it's not on its own line expect( md.render(`This is the root node. ![[note-e#Section 1]]`) ).toMatch( - `

This is the root node.

-

This is the first section of note E

-

Subsection a

-

This is the first subsection of note E

-

` + `

This is the root node.

\n

This is the first section of note E

\n

Subsection a

\n

This is the first subsection of note E

\n

\n` ); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should not render the subsection title in content mode if you link to it and regardless of its level', async () => { @@ -261,19 +334,16 @@ This is the first subsection of note E`, () => { const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + // If the embed is a single paragraph, markdown-it produces a single

expect( - md.render(`This is the root node. - -![[note-e#Subsection a]]`) + md.render(`This is the root node. \n\n![[note-e#Subsection a]]`) ).toMatch( - `

This is the root node.

-

This is the first subsection of note E

-

` + `

This is the root node.

\n

This is the first subsection of note E

\n` ); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should allow a note embedding type to be overridden if a modifier is passed in', async () => { @@ -282,11 +352,12 @@ This is the first subsection of note E`, # Section 1 This is the first section of note E -# Section 2 +# Section 2 This is the second section of note E # Section 3 This is the third section of note E + `, ['note-e.md'] ); @@ -298,6 +369,7 @@ This is the third section of note E CONFIG_EMBED_NOTE_TYPE, 'full-inline', () => { + // markdown-it wraps the embed in a

if it's not on its own line expect( md.render(`This is the root node. @@ -305,18 +377,12 @@ This is the third section of note E full![[note-e#Section 3]]`) ).toMatch( - `

This is the root node.

-

This is the second section of note E

-

-

Section 3

-

This is the third section of note E

-

-` + `

This is the root node.

\n

This is the second section of note E

\n

Section 3

\n

This is the third section of note E

\n

\n` ); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should allow a note embedding type to be overridden if two modifiers are passed in', async () => { @@ -339,7 +405,7 @@ This is the second section of note E 'full-inline', () => { const res = md.render(`This is the root node. - + content-card![[note-e#Section 2]]`); expect(res).toContain('This is the root node'); @@ -349,7 +415,7 @@ content-card![[note-e#Section 2]]`); } ); - await deleteFile(note); + await deleteFile(note.uri); }); it('should fallback to the bare text when the note is not found', () => { @@ -377,15 +443,15 @@ content-card![[note-e#Section 2]]`); 'full-inline', () => { const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); - expect(md.render(`This is the root node. ![[note]]`)).toMatch( - `

This is the root node.

This is the text of note A which includes ![[does-not-exist]]

-

` + expect(md.render(`This is the root node. ![[note]]`)).toBe( + `

This is the root node. This is the text of note A which includes ![[does-not-exist]]

\n` ); } ); + await deleteFile(note.uri); }); - it.skip('should display a warning in case of cyclical inclusions', async () => { + it('should display a warning in case of cyclical inclusions', async () => { const noteA = await createFile( 'This is the text of note A which includes ![[note-b]]', ['preview', 'note-a.md'] @@ -411,7 +477,195 @@ content-card![[note-e#Section 2]]`); } ); - await deleteFile(noteA); - await deleteFile(noteB); + await deleteFile(noteA.uri); + await deleteFile(noteB.uri); + }); + + describe('Block Identifiers', () => { + it('should correctly transclude a paragraph block', async () => { + const content = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'paragraph.md') + ); + const note = await createFile(content, ['paragraph.md']); + const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content)); + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'full-inline', + () => { + expect(md.render(`![[paragraph#^p1]]`)).toMatch( + `

This is a paragraph. ^p1

\n` + ); + } + ); + await deleteFile(note.uri); + }); + + it('should correctly transclude a list item block', async () => { + const content = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'list.md') + ); + const note = await createFile(content, ['list.md']); + const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content)); + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'full-inline', + () => { + expect(md.render(`![[list#^li1]]`)).toMatch( + `
    \n
  • list item 1 ^li1
  • \n
\n` + ); + } + ); + await deleteFile(note.uri); + }); + + it('should correctly transclude a nested list item block', async () => { + const content = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'list.md') + ); + const note = await createFile(content, ['list.md']); + const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content)); + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'full-inline', + () => { + expect(md.render(`![[list#^nli1]]`)).toMatch( + `
    \n
  • nested list item 1 ^nli1
  • \n
\n` + ); + } + ); + await deleteFile(note.uri); + }); + + it('should correctly transclude a heading block', async () => { + const content = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'heading.md') + ); + const note = await createFile(content, ['heading.md']); + const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content)); + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'full-inline', + () => { + expect(md.render(`![[heading#^h2]]`)).toMatch( + `

Heading 2 ^h2

\n

Some more content.

\n` + ); + } + ); + await deleteFile(note.uri); + }); + + it('should correctly transclude a code block', async () => { + const content = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'code-block.md') + ); + const note = await createFile(content, ['code-block.md']); + const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content)); + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'full-inline', + () => { + expect(md.render(`![[code-block#^cb1]]`)).toMatch( + `
{
+  "key": "value"
+}
+
\n` + ); + } + ); + await deleteFile(note.uri); + }); + + it('should embed a block with links and keep them functional', async () => { + const noteA = await createFile('# Note A\n', ['note-a.md']); + const noteWithBlock = await createFile( + '# Note with block\n\nThis is a paragraph with a [[note-a]] and a block identifier. ^my-linked-block', + ['note-with-linked-block.md'] + ); + + const linkingNote = await createFile( + '# Linking note\n\nThis note embeds a block: ![[note-with-linked-block#^my-linked-block]]', + ['linking-note.md'] + ); + + const ws = new FoamWorkspace() + .set(parser.parse(noteA.uri, noteA.content)) + .set(parser.parse(noteWithBlock.uri, noteWithBlock.content)) + .set(parser.parse(linkingNote.uri, linkingNote.content)); + + const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser); + const result = md.render(linkingNote.content); + + expect(result).toContain('This is a paragraph with a'); + expect(result).toContain('note-a.md'); + expect(result).toContain('and a block identifier. ^my-linked-block'); + + await deleteFile(noteA.uri); + await deleteFile(noteWithBlock.uri); + await deleteFile(linkingNote.uri); + }); + }); +}); + +describe('Mixed Scenario Embed', () => { + it('should correctly embed a block from a note with mixed content', async () => { + const parser = createMarkdownParser([]); + const ws = new FoamWorkspace(); + const noteAContent = '# Note A'; + const noteA = await createFile(noteAContent, ['note-a.md']); + + const mixedTargetContent = + '# Mixed Target Note\n\nHere is a paragraph with a [[note-a]]. ^para-block\n\n- List item 1\n- List item 2 with [[note-a]] ^list-block'; + const mixedSourceContent = + '# Mixed Source Note\n\nThis note embeds a paragraph: ![[mixed-target#^para-block]]\n\nAnd this note embeds a list item: ![[mixed-target#^list-block]]'; + + const mixedTargetFile = await createFile(mixedTargetContent, [ + 'mixed-target.md', + ]); + const mixedSourceFile = await createFile(mixedSourceContent, [ + 'mixed-source.md', + ]); + + const mixedTarget = parser.parse(mixedTargetFile.uri, mixedTargetContent); + const mixedSource = parser.parse(mixedSourceFile.uri, mixedSourceContent); + const noteAResource = parser.parse(noteA.uri, noteAContent); + + ws.set(mixedTarget).set(mixedSource).set(noteAResource); + await withModifiedFoamConfiguration( + CONFIG_EMBED_NOTE_TYPE, + 'content-inline', + () => { + const md = markdownItWikilinkNavigation( + markdownItWikilinkEmbed(MarkdownIt(), ws, parser), + ws + ); + const result = md.render(mixedSourceContent); + + const linkHtml = `Note A`; + + // Check for embedded paragraph block content + expect(result).toContain( + `This note embeds a paragraph: Here is a paragraph with a ${linkHtml}. ^para-block` + ); + + // Check for embedded list block content + expect(result).toContain( + `
  • List item 2 with ${linkHtml} ^list-block
  • ` + ); + } + ); + + await deleteFile(mixedTargetFile.uri); + await deleteFile(mixedSourceFile.uri); + await deleteFile(noteA.uri); }); }); diff --git a/packages/foam-vscode/src/features/preview/wikilink-embed.ts b/packages/foam-vscode/src/features/preview/wikilink-embed.ts index a8f18a3e2..0dbf27ec1 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-embed.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-embed.ts @@ -6,29 +6,53 @@ import { workspace as vsWorkspace } from 'vscode'; import markdownItRegex from 'markdown-it-regex'; import { FoamWorkspace } from '../../core/model/workspace'; import { Logger } from '../../core/utils/log'; -import { Resource, ResourceParser } from '../../core/model/note'; +import { + HeadingSection, + Resource, + ResourceParser, +} from '../../core/model/note'; import { getFoamVsCodeConfig } from '../../services/config'; import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils'; import { MarkdownLink } from '../../core/services/markdown-link'; import { URI } from '../../core/model/uri'; import { Position } from '../../core/model/position'; +import { Range } from '../../core/model/range'; import { TextEdit } from '../../core/services/text-edit'; import { isNone, isSome } from '../../core/utils'; +import { stripFrontMatter } from '../../core/utils/md'; import { asAbsoluteWorkspaceUri, isVirtualWorkspace, } from '../../services/editor'; +/** + * Parses a wikilink target into its note and fragment components. + * @param wikilinkTarget The full string target of the wikilink (e.g., 'my-note#my-heading'). + * @returns An object containing the noteTarget and an optional fragment. + */ +function parseWikilink(wikilinkTarget: string): { + noteTarget: string; + fragment?: string; +} { + const [noteTarget, fragment] = wikilinkTarget.split('#'); + return { noteTarget, fragment }; +} + export const WIKILINK_EMBED_REGEX = /((?:(?:full|content)-(?:inline|card)|full|content|inline|card)?!\[\[[^[\]]+?\]\])/; // we need another regex because md.use(regex, replace) only permits capturing one group // so we capture the entire possible wikilink item (ex. content-card![[note]]) using WIKILINK_EMBED_REGEX and then -// use WIKILINK_EMBED_REGEX_GROUPER to parse it into the modifier(content-card) and the wikilink(note) +// use WIKILINK_EMBED_REGEX_GROUPS to parse it into the modifier(content-card) and the wikilink(note) export const WIKILINK_EMBED_REGEX_GROUPS = /((?:\w+)|(?:(?:\w+)-(?:\w+)))?!\[\[([^[\]]+?)\]\]/; export const CONFIG_EMBED_NOTE_TYPE = 'preview.embedNoteType'; +// refsStack is used to detect and prevent cyclic embeds. let refsStack: string[] = []; +/** + * A markdown-it plugin to handle wikilink embeds (e.g., ![[note-name]]). + * It supports embedding entire notes, specific sections, or blocks with block IDs. + */ export const markdownItWikilinkEmbed = ( md: markdownit, workspace: FoamWorkspace, @@ -39,54 +63,50 @@ export const markdownItWikilinkEmbed = ( regex: WIKILINK_EMBED_REGEX, replace: (wikilinkItem: string) => { try { - const [, noteEmbedModifier, wikilink] = wikilinkItem.match( - WIKILINK_EMBED_REGEX_GROUPS - ); + const regexMatch = wikilinkItem.match(WIKILINK_EMBED_REGEX_GROUPS); + const [, noteEmbedModifier, wikilinkTarget] = regexMatch; if (isVirtualWorkspace()) { - return ` -
    - Embed not supported in virtual workspace: ![[${wikilink}]] -
    - `; + return `\n
    \n Embed not supported in virtual workspace: ![[${wikilinkTarget}]]\n
    \n `; } - const includedNote = workspace.find(wikilink); - + // Parse the wikilink to separate the note path from the fragment. + const { noteTarget, fragment } = parseWikilink(wikilinkTarget); + const includedNote = workspace.find(noteTarget); if (!includedNote) { - return `![[${wikilink}]]`; + return `![[${wikilinkTarget}]]`; } const cyclicLinkDetected = refsStack.includes( includedNote.uri.path.toLocaleLowerCase() ); - if (cyclicLinkDetected) { - return ` - - `; + const { noteStyle } = retrieveNoteConfig(noteEmbedModifier); + const warning = `\n \n `; + return warning; } - refsStack.push(includedNote.uri.path.toLocaleLowerCase()); - const content = getNoteContent( - includedNote, - noteEmbedModifier, - parser, - workspace, - md - ); + // Extract the raw markdown for the embed + const { noteScope, noteStyle } = retrieveNoteConfig(noteEmbedModifier); + const extractor: EmbedNoteExtractor = + noteScope === 'content' ? contentExtractor : fullExtractor; + const content = extractor(includedNote, fragment, parser, workspace); + + // Render the extracted content as HTML using the correct formatter + let rendered: string; + if (noteStyle === 'card') { + rendered = cardFormatter(md.render(content), md); + } else { + rendered = inlineFormatter(content, md); + } + refsStack.pop(); - return refsStack.length === 0 ? md.render(content) : content; + return rendered; } catch (e) { + console.error(`ERROR in wikilink embed processing:`, e); Logger.error( `Error while including ${wikilinkItem} into the current document of the Preview panel`, e @@ -99,6 +119,7 @@ export const markdownItWikilinkEmbed = ( function getNoteContent( includedNote: Resource, + linkFragment: string | undefined, noteEmbedModifier: string | undefined, parser: ResourceParser, workspace: FoamWorkspace, @@ -112,39 +133,29 @@ function getNoteContent( const { noteScope, noteStyle } = retrieveNoteConfig(noteEmbedModifier); const extractor: EmbedNoteExtractor = - noteScope === 'full' - ? fullExtractor - : noteScope === 'content' - ? contentExtractor - : fullExtractor; - - const formatter: EmbedNoteFormatter = - noteStyle === 'card' - ? cardFormatter - : noteStyle === 'inline' - ? inlineFormatter - : cardFormatter; - - content = extractor(includedNote, parser, workspace); - toRender = formatter(content, md); + noteScope === 'content' ? contentExtractor : fullExtractor; + + content = extractor(includedNote, linkFragment, parser, workspace); + + // Guarantee HTML output: if the formatter returns plain text, render it as markdown + if (!/^\s* -${md.renderInline('[[' + includedNote.uri.path + ']]')}
    -Embed for attachments is not supported -`; + content = `> [[${includedNote.uri.path}]]\n>\n> Embed for attachments is not supported`; toRender = md.render(content); break; case 'image': - content = `
    ${md.render( - `![](${md.normalizeLink(includedNote.uri.path)})` - )}
    `; + content = `![](${md.normalizeLink(includedNote.uri.path)})`; toRender = md.render(content); break; default: - toRender = content; + toRender = md.render(content); } return toRender; @@ -170,9 +181,13 @@ function withLinksRelativeToWorkspaceRoot( return null; } const pathFromRoot = asAbsoluteWorkspaceUri(resource.uri).path; - return MarkdownLink.createUpdateLinkEdit(link, { + const update: { target: string; text?: string } = { target: pathFromRoot, - }); + }; + if (!info.alias) { + update.text = info.target; + } + return MarkdownLink.createUpdateLinkEdit(link, update); }) .filter(linkEdits => !isNone(linkEdits)) .sort((a, b) => Position.compareTo(b.range.start, a.range.start)); @@ -196,10 +211,11 @@ export function retrieveNoteConfig(explicitModifier: string | undefined): { noteScope = explicitModifier; } else if (['card', 'inline'].includes(explicitModifier)) { noteStyle = explicitModifier; - } else { + } else if (explicitModifier.includes('-')) { [noteScope, noteStyle] = explicitModifier.split('-'); } } + return { noteScope, noteStyle }; } @@ -208,60 +224,143 @@ export function retrieveNoteConfig(explicitModifier: string | undefined): { */ export type EmbedNoteExtractor = ( note: Resource, + linkFragment: string | undefined, parser: ResourceParser, workspace: FoamWorkspace ) => string; +/** + * Extracts the full content of a note or a specific section/block. + * For sections, it includes the heading itself. + */ function fullExtractor( note: Resource, + linkFragment: string | undefined, parser: ResourceParser, workspace: FoamWorkspace ): string { let noteText = readFileSync(note.uri.toFsPath()).toString(); - const section = Resource.findSection(note, note.uri.fragment); + + // Find the specific section or block being linked to, if a fragment is provided. + const section = linkFragment + ? Resource.findSection(note, linkFragment) + : null; + if (isSome(section)) { - const rows = noteText.split('\n'); - noteText = rows - .slice(section.range.start.line, section.range.end.line) - .join('\n'); + if (section.type === 'heading') { + // For headings, extract all content from that heading to the next. + let rows = noteText.split(/\r?\n/); + // Find the next heading after this one, regardless of level + let nextHeadingLine = rows.length; + for (let i = section.range.start.line + 1; i < rows.length; i++) { + // Find the next heading of the same or higher level + const nextHeading = note.sections.find(s => { + if (s.type === 'heading') { + return ( + s.range.start.line === i && + s.level <= (section as HeadingSection).level + ); + } + return false; + }); + if (nextHeading) { + nextHeadingLine = i; + break; + } + } + let slicedRows = rows.slice(section.range.start.line, nextHeadingLine); + noteText = slicedRows.join('\n'); + } else { + // For block-level embeds (paragraphs, list items with a ^block-id), + // extract the content precisely using the range from the parser. + const rows = noteText.split(/\r?\n/); + noteText = rows + .slice(section.range.start.line, section.range.end.line + 1) + .join('\n'); + } + } else { + // No fragment: transclude the whole note (excluding frontmatter if present) + noteText = stripFrontMatter(noteText); } + noteText = withLinksRelativeToWorkspaceRoot( note.uri, noteText, parser, workspace ); + return noteText; } +/** + * Extracts the content of a note, excluding the main title. + * For sections, it extracts the content *under* the heading. + */ function contentExtractor( note: Resource, + linkFragment: string | undefined, parser: ResourceParser, workspace: FoamWorkspace ): string { let noteText = readFileSync(note.uri.toFsPath()).toString(); - let section = Resource.findSection(note, note.uri.fragment); - if (!note.uri.fragment) { - // if there's no fragment(section), the wikilink is linking to the entire note, - // in which case we need to remove the title. We could just use rows.shift() - // but should the note start with blank lines, it will only remove the first blank line - // leaving the title - // A better way is to find where the actual title starts by assuming it's at section[0] - // then we treat it as the same case as link to a section + + // Find the specific section or block being linked to. + let section = Resource.findSection(note, linkFragment); + + if (!linkFragment) { + // If no fragment is provided, default to the first section (usually the main title) + // to extract the content of the note, excluding the title. section = note.sections.length ? note.sections[0] : null; } - let rows = noteText.split('\n'); + if (isSome(section)) { - rows = rows.slice(section.range.start.line, section.range.end.line); + if (section.type === 'heading') { + // For headings, extract the content *under* the heading. + let rows = noteText.split(/\r?\n/); + let endOfSectionLine = rows.length; + for (let i = section.range.start.line + 1; i < rows.length; i++) { + // Find the next heading of the same or higher level + const nextHeading = note.sections.find(s => { + if (s.type === 'heading') { + return ( + s.range.start.line === i && + s.level <= (section as HeadingSection).level + ); + } + return false; + }); + if (nextHeading) { + endOfSectionLine = i; + break; + } + } + noteText = rows + .slice(section.range.start.line + 1, endOfSectionLine) + .join('\n'); + } else { + // For block-level embeds (e.g., a list item with a ^block-id), + // extract the content of just that block using its range. + const rows = noteText.split(/\r?\n/); + noteText = rows + .slice(section.range.start.line, section.range.end.line + 1) + .join('\n'); + } + } else { + // If no fragment, or fragment not found as a section, + // treat as content of the entire note (excluding title) + let rows = noteText.split(/\r?\n/); + rows.shift(); // Remove the title + noteText = rows.join('\n'); } - rows.shift(); - noteText = rows.join('\n'); + noteText = withLinksRelativeToWorkspaceRoot( note.uri, noteText, parser, workspace ); + return noteText; } @@ -271,11 +370,36 @@ function contentExtractor( export type EmbedNoteFormatter = (content: string, md: markdownit) => string; function cardFormatter(content: string, md: markdownit): string { - return `
    \n\n${content}\n\n
    `; + const result = `
    + +${content} + +
    `; + + return result; } function inlineFormatter(content: string, md: markdownit): string { - return content; + const tokens = md.parse(content.trim(), {}); + + // Optimization: If the content is just a single paragraph, render only its + // inline content. This prevents wrapping the embed in an extra, unnecessary

    tag, + // which can cause layout issues. + if ( + tokens.length === 3 && + tokens[0].type === 'paragraph_open' && + tokens[1].type === 'inline' && + tokens[2].type === 'paragraph_close' + ) { + // Render only the inline content to prevent double

    tags. + // The parent renderer will wrap this in

    tags as needed. + const result = md.renderer.render(tokens[1].children, md.options, {}); + return result; + } + + const result = md.render(content); + // For more complex content (headings, lists, etc.), render as a full block. + return result; } export default markdownItWikilinkEmbed; diff --git a/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts b/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts index 79e4ed16f..bc5e3c78d 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts @@ -1,34 +1,57 @@ +import * as vscode from 'vscode'; import MarkdownIt from 'markdown-it'; import { FoamWorkspace } from '../../core/model/workspace'; import { createTestNote } from '../../test/test-utils'; -import { getUriInWorkspace } from '../../test/test-utils-vscode'; -import { default as markdownItWikilinkNavigation } from './wikilink-navigation'; +import { markdownItWikilinkNavigation } from './wikilink-navigation'; import { default as markdownItRemoveLinkReferences } from './remove-wikilink-references'; +import { URI } from '../../core/model/uri'; describe('Link generation in preview', () => { + const workspaceRoot = URI.file('/path/to/workspace'); + const workspaceRootVsCode = vscode.Uri.file('/path/to/workspace'); + + beforeEach(() => { + jest + .spyOn(vscode.workspace, 'asRelativePath') + .mockImplementation((pathOrUri: string | vscode.Uri) => { + const path = + pathOrUri instanceof vscode.Uri + ? pathOrUri.path + : pathOrUri.toString(); + if (path.startsWith(workspaceRootVsCode.path)) { + // get path relative to workspace root, remove leading slash + return path.substring(workspaceRootVsCode.path.length + 1); + } + return path; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + const noteA = createTestNote({ - uri: './path/to/note-a.md', - // TODO: this should really just be the workspace folder, use that once #806 is fixed - root: getUriInWorkspace('just-a-ref.md'), + uri: '/path/to/workspace/note-a.md', title: 'My note title', links: [{ slug: 'placeholder' }], }); const noteB = createTestNote({ - uri: './path2/to/note-b.md', - root: getUriInWorkspace('just-a-ref.md'), + uri: '/path/to/workspace/path2/to/note-b.md', title: 'My second note', - sections: ['sec1', 'sec2'], + sections: [ + { label: 'sec1', level: 1 }, + { label: 'sec2', level: 1 }, + ], }); const ws = new FoamWorkspace().set(noteA).set(noteB); - const md = [ - markdownItWikilinkNavigation, - markdownItRemoveLinkReferences, - ].reduce((acc, extension) => extension(acc, ws), MarkdownIt()); + const md = MarkdownIt(); + markdownItWikilinkNavigation(md, ws, { root: workspaceRootVsCode }); + markdownItRemoveLinkReferences(md, ws); it('generates a link to a note using the note title as link', () => { expect(md.render(`[[note-a]]`)).toEqual( - `

    ${noteA.title}

    \n` + `

    ${noteA.title}

    \n` ); }); @@ -48,7 +71,7 @@ describe('Link generation in preview', () => { const note = `[[note-a]] [note-a]: "Note A"`; expect(md.render(note)).toEqual( - `

    ${noteA.title}\n[note-a]: <note-a.md> "Note A"

    \n` + `

    ${noteA.title}\n[note-a]: <note-a.md> "Note A"

    \n` ); }); diff --git a/packages/foam-vscode/src/features/preview/wikilink-navigation.ts b/packages/foam-vscode/src/features/preview/wikilink-navigation.ts index 7e85aab8d..88f5cccf7 100644 --- a/packages/foam-vscode/src/features/preview/wikilink-navigation.ts +++ b/packages/foam-vscode/src/features/preview/wikilink-navigation.ts @@ -11,64 +11,135 @@ import { isEmpty } from 'lodash'; import { toSlug } from '../../utils/slug'; import { isNone } from '../../core/utils'; +/** + * A markdown-it plugin that converts [[wikilinks]] to navigable links in the Markdown preview. + * It handles links to notes, sections, and block IDs, generating the correct hrefs + * for navigation within the VS Code preview panel. + * + * @param md The markdown-it instance. + * @param workspace The Foam workspace to resolve links against. + * @param options Optional configuration. + */ export const markdownItWikilinkNavigation = ( md: markdownit, - workspace: FoamWorkspace + workspace: FoamWorkspace, + options?: { root?: vscode.Uri } ) => { return md.use(markdownItRegex, { name: 'connect-wikilinks', + // Regex to match a wikilink, ensuring it's not an image/embed (which starts with '!') regex: /(?=[^!])\[\[([^[\]]+?)\]\]/, + // The replacement function that turns a matched wikilink string into an HTML tag. replace: (wikilink: string) => { try { + // Deconstruct the wikilink into its constituent parts. const { target, section, alias } = MarkdownLink.analyzeLink({ rawText: '[[' + wikilink + ']]', type: 'wikilink', range: Range.create(0, 0), isEmbed: false, }); - const formattedSection = section ? `#${section}` : ''; - const linkSection = section ? `#${toSlug(section)}` : ''; - const label = isEmpty(alias) ? `${target}${formattedSection}` : alias; - // [[#section]] links + // Case 1: The wikilink points to a section/block in the *current* file. if (target.length === 0) { - // we don't have a good way to check if the section exists within the - // open file, so we just create a regular link for it - return getResourceLink(section, linkSection, label); + if (section) { + // For block IDs (^block-id), the slug is the ID itself. For headings, it's a slugified version. + const slug = section.startsWith('^') + ? section.substring(1) + : toSlug(section); + const linkText = alias || `#${section}`; + const title = alias || section; + // The href is just the fragment identifier. + return getResourceLink(title, `#${slug}`, linkText); + } + // If there's no target and no section, it's a malformed link. Return as is. + return `[[${wikilink}]]`; } + // Case 2: The wikilink points to another note. const resource = workspace.find(target); + + // If the target note doesn't exist, create a "placeholder" link. if (isNone(resource)) { - return getPlaceholderLink(label); + const linkText = alias || wikilink; + return getPlaceholderLink(linkText); } - const resourceLabel = isEmpty(alias) - ? `${resource.title}${formattedSection}` - : alias; - const resourceLink = `/${vscode.workspace.asRelativePath( + // If the target note exists, construct the link to it. + // The base href points to the file path of the target resource. + const href = `/${vscode.workspace.asRelativePath( toVsCodeUri(resource.uri), false )}`; - return getResourceLink( - `${resource.title}${formattedSection}`, - `${resourceLink}${linkSection}`, - resourceLabel - ); + + let linkTitle = resource.title; + let finalHref = href; + + // If the link includes a section or block ID part (e.g., [[note#section]] or [[note#^block-id]]) + if (section) { + linkTitle += `#${section}`; + // Find the corresponding section or block in the target resource. + // This lookup works for both heading labels (by comparing slugs) and block IDs (by direct match). + const foundSection = resource.sections.find( + s => toSlug(s.label) === toSlug(section) || s.blockId === section + ); + + let fragment; + if (foundSection) { + switch (foundSection.type) { + case 'heading': + // If the link points to a heading, the fragment is the heading's generated ID. + fragment = foundSection.id; + break; + case 'block': { + // For block ID links, find the closest preceding heading section to use as the anchor. + // This ensures navigation scrolls to the most relevant context in the preview, not just the block. + const parentHeading = resource.sections + .filter( + s => + s.type === 'heading' && + s.range.start.line < foundSection.range.start.line + ) + // Sort headings by line number descending to find the closest one *before* the block. + .sort((a, b) => b.range.start.line - a.range.start.line)[0]; + + // Use the parent heading's ID if found; otherwise, fall back to a slug of the block ID. + fragment = parentHeading ? parentHeading.id : toSlug(section); + break; + } + } + } else { + // If no specific section is found, fall back to a slug of the section identifier. + fragment = toSlug(section); + } + // Append the fragment to the base href. + finalHref += `#${fragment}`; + } + + // The visible text of the link is the alias if provided, otherwise the generated link title. + const linkText = alias || linkTitle; + + return getResourceLink(linkTitle, finalHref, linkText); } catch (e) { - Logger.error( - `Error while creating link for [[${wikilink}]] in Preview panel`, - e - ); + Logger.error('Error while parsing wikilink', e); + // Fallback for any errors during processing. return getPlaceholderLink(wikilink); } }, }); }; -const getPlaceholderLink = (content: string) => - `${content}`; - -const getResourceLink = (title: string, link: string, label: string) => - `${label}`; +/** + * Generates an HTML tag for a valid, resolved link. + * Includes data-href for compatibility with VS Code's link-following logic. + */ +function getResourceLink(title: string, href: string, text: string) { + return `${text}`; +} -export default markdownItWikilinkNavigation; +/** + * Generates a disabled-style HTML tag for a link to a non-existent note. + */ +function getPlaceholderLink(text: string) { + return `${text}`; +} diff --git a/packages/foam-vscode/src/features/refactor.spec.ts b/packages/foam-vscode/src/features/refactor.spec.ts index 9c77cd191..c0af29e40 100644 --- a/packages/foam-vscode/src/features/refactor.spec.ts +++ b/packages/foam-vscode/src/features/refactor.spec.ts @@ -53,7 +53,7 @@ describe('Note rename sync', () => { expect((await readFile(noteC.uri)).trim()).toEqual( `Link to [[${newName}]] from note C.` ); - }, 1000); + }, 3000); await deleteFile(newUri); await deleteFile(noteB.uri); @@ -89,7 +89,7 @@ describe('Note rename sync', () => { expect(doc.getText().trim()).toEqual( `Link to [[first/note-b]] from note C.` ); - }); + }, 3000); await deleteFile(newUri); await deleteFile(noteC.uri); }); @@ -126,8 +126,8 @@ describe('Note rename sync', () => { }); it('should keep the alias in wikilinks', async () => { - const noteA = await createFile(`Content of note A`); - const noteB = await createFile(`Link to [[${noteA.name}|Alias]]`); + const noteA = await createFile(`Content of note A`, ['note-a.md']); + const noteB = await createFile(`Link to [[note-a|Alias]]`, ['note-b.md']); const { doc } = await showInEditor(noteB.uri); diff --git a/packages/foam-vscode/src/features/refactor.ts b/packages/foam-vscode/src/features/refactor.ts index 334d605cc..d094fb25c 100644 --- a/packages/foam-vscode/src/features/refactor.ts +++ b/packages/foam-vscode/src/features/refactor.ts @@ -33,12 +33,14 @@ export default async function activate( const { target } = MarkdownLink.analyzeLink(connection.link); switch (connection.link.type) { case 'wikilink': { + const { section } = MarkdownLink.analyzeLink(connection.link); const identifier = foam.workspace.getIdentifier( fromVsCodeUri(newUri), [fromVsCodeUri(oldUri)] ); const edit = MarkdownLink.createUpdateLinkEdit(connection.link, { target: identifier, + section: section, }); renameEdits.replace( toVsCodeUri(connection.source), @@ -53,8 +55,9 @@ export default async function activate( : fromVsCodeUri(newUri).relativeTo( connection.source.getDirectory() ).path; + const { section } = MarkdownLink.analyzeLink(connection.link); const edit = MarkdownLink.createUpdateLinkEdit(connection.link, { - target: path, + target: section ? `${path}#${section}` : path, }); renameEdits.replace( toVsCodeUri(connection.source), diff --git a/packages/foam-vscode/src/features/wikilink-diagnostics.spec.ts b/packages/foam-vscode/src/features/wikilink-diagnostics.spec.ts index 1cf85eea9..67a67b681 100644 --- a/packages/foam-vscode/src/features/wikilink-diagnostics.spec.ts +++ b/packages/foam-vscode/src/features/wikilink-diagnostics.spec.ts @@ -1,12 +1,15 @@ import * as vscode from 'vscode'; import { createMarkdownParser } from '../core/services/markdown-parser'; import { FoamWorkspace } from '../core/model/workspace'; +import { URI } from '../core/model/uri'; import { cleanWorkspace, closeEditors, createFile, + deleteFile, showInEditor, } from '../test/test-utils-vscode'; +import { readFileFromFs, TEST_DATA_DIR } from '../test/test-utils'; import { toVsCodeUri } from '../utils/vsc-utils'; import { updateDiagnostics } from './wikilink-diagnostics'; @@ -188,6 +191,146 @@ Content of section 2 }); }); +describe('Block Identifier diagnostics', () => { + it('should show nothing when the block id is correct', async () => { + const noteWithBlockId = await createFile( + '# Note with block id\n\nThis is a paragraph. ^block-1', + [ + 'packages', + 'foam-vscode', + 'test-data', + 'block-identifiers', + 'note-with-block-id.md', + ] + ); + const linkingNote = await createFile( + `Link to [[${noteWithBlockId.name}#^block-1]]`, + [ + 'packages', + 'foam-vscode', + 'test-data', + 'block-identifiers', + 'linking-to-valid-block.md', + ] + ); + + const parser = createMarkdownParser([]); + const ws = new FoamWorkspace() + .set(parser.parse(noteWithBlockId.uri, noteWithBlockId.content)) + .set(parser.parse(linkingNote.uri, linkingNote.content)); + + await showInEditor(linkingNote.uri); + + const collection = vscode.languages.createDiagnosticCollection('foam-test'); + updateDiagnostics( + ws, + parser, + vscode.window.activeTextEditor.document, + collection + ); + expect(countEntries(collection)).toEqual(0); + }); + + it('should show a warning when the block id is incorrect', async () => { + const noteWithBlockId = await createFile( + '# Note with block id\n\nThis is a paragraph. ^block-1', + [ + 'packages', + 'foam-vscode', + 'test-data', + 'block-identifiers', + 'note-with-block-id.md', + ] + ); + const linkContent = `[[${noteWithBlockId.name}#^non-existent-block]]`; + const fileContent = `Link to ${linkContent}`; + const linkingNote = await createFile(fileContent, [ + 'packages', + 'foam-vscode', + 'test-data', + 'block-identifiers', + 'linking-to-invalid-block.md', + ]); + + const parser = createMarkdownParser([]); + const ws = new FoamWorkspace() + .set(parser.parse(noteWithBlockId.uri, noteWithBlockId.content)) + .set(parser.parse(linkingNote.uri, linkingNote.content)); + + await showInEditor(linkingNote.uri); + + const collection = vscode.languages.createDiagnosticCollection('foam-test'); + updateDiagnostics( + ws, + parser, + vscode.window.activeTextEditor.document, + collection + ); + expect(countEntries(collection)).toEqual(1); + const items = collection.get(toVsCodeUri(linkingNote.uri)); + expect(items[0].range).toEqual(new vscode.Range(0, 28, 0, 50)); + expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning); + expect(items[0].relatedInformation.map(info => info.message)).toEqual([ + 'Note with block id', + '^block-1', + ]); + }); +}); + +describe('Mixed Scenario Diagnostics', () => { + it('should report a warning for a non-existent block but not for valid links', async () => { + const parser = createMarkdownParser([]); + const ws = new FoamWorkspace(); + + const mixedTargetContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-target.md') + ); + const mixedOtherContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-other.md') + ); + const mixedSourceContent = await readFileFromFs( + TEST_DATA_DIR.joinPath('block-identifiers', 'mixed-source.md') + ); + + const mixedTargetFile = await createFile(mixedTargetContent, [ + 'mixed-target.md', + ]); + const mixedOtherFile = await createFile(mixedOtherContent, [ + 'mixed-other.md', + ]); + const mixedSourceFile = await createFile(mixedSourceContent, [ + 'mixed-source.md', + ]); + + const mixedTarget = parser.parse(mixedTargetFile.uri, mixedTargetContent); + const mixedOther = parser.parse(mixedOtherFile.uri, mixedOtherContent); + const mixedSource = parser.parse(mixedSourceFile.uri, mixedSourceContent); + + ws.set(mixedTarget).set(mixedOther).set(mixedSource); + + await showInEditor(mixedSource.uri); + + const collection = vscode.languages.createDiagnosticCollection('foam-test'); + updateDiagnostics( + ws, + parser, + vscode.window.activeTextEditor.document, + collection + ); + + expect(countEntries(collection)).toEqual(1); + const items = collection.get(toVsCodeUri(mixedSource.uri)); + // The warning should be for [[mixed-target#^no-such-block]] + // which is on line 9 (index 8) of mixed-source.md + expect(items[0].range).toEqual(new vscode.Range(8, 44, 8, 61)); + expect(items[0].message).toContain('Cannot find section'); + + await deleteFile(mixedTargetFile.uri); + await deleteFile(mixedOtherFile.uri); + await deleteFile(mixedSourceFile.uri); + }); +}); + const countEntries = (collection: vscode.DiagnosticCollection): number => { let count = 0; collection.forEach((i, diagnostics) => { diff --git a/packages/foam-vscode/src/features/wikilink-diagnostics.ts b/packages/foam-vscode/src/features/wikilink-diagnostics.ts index 01a8c4056..086561ab1 100644 --- a/packages/foam-vscode/src/features/wikilink-diagnostics.ts +++ b/packages/foam-vscode/src/features/wikilink-diagnostics.ts @@ -1,7 +1,14 @@ +/** + * @file Provides diagnostics for wikilinks in markdown files. + * This includes: + * - Detecting ambiguous links (when an identifier can resolve to multiple notes). + * - Detecting broken section links (when the note exists but the #section does not). + * - Providing Quick Fixes (Code Actions) to resolve these issues. + */ import { debounce } from 'lodash'; import * as vscode from 'vscode'; import { Foam } from '../core/model/foam'; -import { Resource, ResourceParser } from '../core/model/note'; +import { Resource, ResourceParser, ResourceLink } from '../core/model/note'; import { Range } from '../core/model/range'; import { FoamWorkspace } from '../core/model/workspace'; import { MarkdownLink } from '../core/services/markdown-link'; @@ -13,7 +20,16 @@ import { } from '../utils/vsc-utils'; import { isNone } from '../core/utils'; +/** + * Diagnostic code for an ambiguous link identifier. + * Used when a wikilink could refer to more than one note. + */ const AMBIGUOUS_IDENTIFIER_CODE = 'ambiguous-identifier'; + +/** + * Diagnostic code for an unknown section in a wikilink. + * Used when the note exists, but the section identifier (e.g., #my-section) does not. + */ const UNKNOWN_SECTION_CODE = 'unknown-section'; interface FoamCommand { @@ -28,6 +44,11 @@ interface FindIdentifierCommandArgs { amongst: vscode.Uri[]; } +/** + * A command that computes the shortest unambiguous identifier for a target URI + * among a set of potential targets and replaces the text in the editor. + * Used by the Quick Fix for ambiguous links. + */ const FIND_IDENTIFIER_COMMAND: FoamCommand = { name: 'foam:compute-identifier', execute: async ({ target, amongst, range, defaultExtension }) => { @@ -53,6 +74,10 @@ interface ReplaceTextCommandArgs { value: string; } +/** + * A generic command that replaces a range of text in the active editor with a new value. + * Used by the Quick Fix for unknown sections. + */ const REPLACE_TEXT_COMMAND: FoamCommand = { name: 'foam:replace-text', execute: async ({ range, value }) => { @@ -98,7 +123,7 @@ export default async function activate( }), vscode.languages.registerCodeActionsProvider( 'markdown', - new IdentifierResolver(foam.workspace.defaultExtension), + new IdentifierResolver(foam.workspace, foam.workspace.defaultExtension), { providedCodeActionKinds: IdentifierResolver.providedCodeActionKinds, } @@ -114,6 +139,14 @@ export default async function activate( ); } +/** + * Analyzes the current document for ambiguous or broken wikilinks and generates + * corresponding diagnostics in the editor. + * @param workspace The Foam workspace, used to resolve link targets. + * @param parser The resource parser, used to get links from the document text. + * @param document The document to analyze. + * @param collection The diagnostic collection to update. + */ export function updateDiagnostics( workspace: FoamWorkspace, parser: ResourceParser, @@ -121,134 +154,262 @@ export function updateDiagnostics( collection: vscode.DiagnosticCollection ): void { collection.clear(); - const result = []; - if (document && document.languageId === 'markdown') { - const resource = parser.parse( - fromVsCodeUri(document.uri), - document.getText() - ); + if (!document || document.languageId !== 'markdown') { + return; + } - for (const link of resource.links) { - if (link.type === 'wikilink') { - const { target, section } = MarkdownLink.analyzeLink(link); - const targets = workspace.listByIdentifier(target); - if (targets.length > 1) { - result.push({ - code: AMBIGUOUS_IDENTIFIER_CODE, - message: 'Resource identifier is ambiguous', - range: toVsCodeRange(link.range), - severity: vscode.DiagnosticSeverity.Warning, - source: 'Foam', - relatedInformation: targets.map( - t => - new vscode.DiagnosticRelatedInformation( - new vscode.Location( - toVsCodeUri(t.uri), - new vscode.Position(0, 0) - ), - `Possible target: ${vscode.workspace.asRelativePath( - toVsCodeUri(t.uri) - )}` - ) - ), - }); - } - if (section && targets.length === 1) { - const resource = targets[0]; - if (isNone(Resource.findSection(resource, section))) { - const range = Range.create( - link.range.start.line, - link.range.start.character + target.length + 2, - link.range.end.line, - link.range.end.character - ); - result.push({ - code: UNKNOWN_SECTION_CODE, - message: `Cannot find section "${section}" in document, available sections are:`, - range: toVsCodeRange(range), - severity: vscode.DiagnosticSeverity.Warning, - source: 'Foam', - relatedInformation: resource.sections.map( - b => - new vscode.DiagnosticRelatedInformation( - new vscode.Location( - toVsCodeUri(resource.uri), - toVsCodePosition(b.range.start) - ), - b.label - ) - ), - }); - } - } - } + const resource = parser.parse( + fromVsCodeUri(document.uri), + document.getText() + ); + + const diagnostics = resource.links.flatMap(link => { + if (link.type !== 'wikilink') { + return []; + } + const { target, section } = MarkdownLink.analyzeLink(link); + const targets = workspace.listByIdentifier(target); + + if (targets.length > 1) { + return [createAmbiguousIdentifierDiagnostic(link, targets)]; } - if (result.length > 0) { - collection.set(document.uri, result); + if (section && targets.length === 1) { + const targetResource = targets[0]; + if (isNone(Resource.findSection(targetResource, section))) { + return [ + createUnknownSectionDiagnostic(link, target, section, targetResource), + ]; + } } + return []; + }); + + if (diagnostics.length > 0) { + collection.set(document.uri, diagnostics); } } +/** + * Creates a VS Code Diagnostic for an ambiguous wikilink identifier. + * @param link The wikilink that is ambiguous. + * @param targets The list of potential resources the link could target. + * @returns A `vscode.Diagnostic` object. + */ +function createAmbiguousIdentifierDiagnostic( + link: ResourceLink, + targets: Resource[] +): vscode.Diagnostic { + return { + code: AMBIGUOUS_IDENTIFIER_CODE, + message: 'Resource identifier is ambiguous', + range: toVsCodeRange(link.range), + severity: vscode.DiagnosticSeverity.Warning, + source: 'Foam', + relatedInformation: targets.map( + t => + new vscode.DiagnosticRelatedInformation( + new vscode.Location(toVsCodeUri(t.uri), new vscode.Position(0, 0)), + `Possible target: ${vscode.workspace.asRelativePath( + toVsCodeUri(t.uri) + )}` + ) + ), + }; +} + +/** + * Creates a VS Code Diagnostic for a wikilink pointing to a non-existent section. + * @param link The wikilink containing the broken section reference. + * @param target The string identifier of the target note. + * @param section The string identifier of the (non-existent) section. + * @param resource The target resource where the section was not found. + * @returns A `vscode.Diagnostic` object. + */ +function createUnknownSectionDiagnostic( + link: ResourceLink, + target: string, + section: string, + resource: Resource +): vscode.Diagnostic { + const range = Range.create( + link.range.start.line, + link.range.start.character + target.length + 2, + link.range.end.line, + link.range.end.character + ); + return { + code: UNKNOWN_SECTION_CODE, + message: `Cannot find section "${section}" in document, available sections are:`, + range: toVsCodeRange(range), + severity: vscode.DiagnosticSeverity.Warning, + source: 'Foam', + relatedInformation: createSectionSuggestions(resource), + }; +} + +/** + * Generates a list of suggested sections from a resource to be displayed + * as related information in a diagnostic. + * This helps the user see the available, valid sections in a note. + * @param resource The resource to generate suggestions from. + * @returns An array of `vscode.DiagnosticRelatedInformation` objects. + */ +function createSectionSuggestions( + resource: Resource +): vscode.DiagnosticRelatedInformation[] { + return resource.sections.flatMap(s => { + const infos: vscode.DiagnosticRelatedInformation[] = []; + const location = new vscode.Location( + toVsCodeUri(resource.uri), + toVsCodePosition(s.range.start) + ); + switch (s.type) { + case 'heading': + if (s.id) { + infos.push( + new vscode.DiagnosticRelatedInformation(location, s.label) // Use s.label for heading suggestions, as Quick Fix uses this + ); + } + if (s.blockId) { + infos.push( + new vscode.DiagnosticRelatedInformation(location, s.blockId) // Use s.blockId for block IDs (including caret) + ); + } + break; + case 'block': + infos.push( + new vscode.DiagnosticRelatedInformation(location, s.blockId) // For blocks, only blockId is relevant + ); + break; + } + return infos; + }); +} + +/** + * Provides Code Actions (Quick Fixes) for the diagnostics created by this file. + */ export class IdentifierResolver implements vscode.CodeActionProvider { public static readonly providedCodeActionKinds = [ vscode.CodeActionKind.QuickFix, ]; - constructor(private defaultExtension: string) {} + constructor( + private workspace: FoamWorkspace, + private defaultExtension: string + ) {} + /** + * This method is called by VS Code when the user's cursor is on a diagnostic. + * It returns a list of applicable Quick Fixes. + */ provideCodeActions( document: vscode.TextDocument, range: vscode.Range | vscode.Selection, context: vscode.CodeActionContext, token: vscode.CancellationToken ): vscode.CodeAction[] { - return context.diagnostics.reduce((acc, diagnostic) => { - if (diagnostic.code === AMBIGUOUS_IDENTIFIER_CODE) { - const res: vscode.CodeAction[] = []; - const uris = diagnostic.relatedInformation.map( - info => info.location.uri - ); - for (const item of diagnostic.relatedInformation) { - res.push( - createFindIdentifierCommand( - diagnostic, - item.location.uri, - this.defaultExtension, - uris - ) - ); - } - return [...acc, ...res]; - } - if (diagnostic.code === UNKNOWN_SECTION_CODE) { - const res: vscode.CodeAction[] = []; - const sections = diagnostic.relatedInformation.map( - info => info.message - ); - for (const section of sections) { - res.push(createReplaceSectionCommand(diagnostic, section)); - } - return [...acc, ...res]; + return context.diagnostics.flatMap(diagnostic => { + switch (diagnostic.code) { + case AMBIGUOUS_IDENTIFIER_CODE: + return this.createAmbiguousIdentifierActions(diagnostic); + case UNKNOWN_SECTION_CODE: + return this.createUnknownSectionActions(diagnostic); + default: + return []; } - return acc; - }, [] as vscode.CodeAction[]); + }); + } + + /** + * Creates the set of Quick Fixes for an `AMBIGUOUS_IDENTIFIER_CODE` diagnostic. + * This generates one Code Action for each potential target file. + */ + private createAmbiguousIdentifierActions( + diagnostic: vscode.Diagnostic + ): vscode.CodeAction[] { + const uris = diagnostic.relatedInformation.map(info => info.location.uri); + return diagnostic.relatedInformation.map(item => + createFindIdentifierCommand( + diagnostic, + item.location.uri, + this.defaultExtension, + uris + ) + ); + } + + /** + * Creates the set of Quick Fixes for an `UNKNOWN_SECTION_CODE` diagnostic. + * This generates one Code Action for each valid section in the target file. + */ + private createUnknownSectionActions( + diagnostic: vscode.Diagnostic + ): vscode.CodeAction[] { + const sectionIds = diagnostic.relatedInformation.map(info => info.message); + return sectionIds + .map(sectionId => + createReplaceSectionCommand(diagnostic, sectionId, this.workspace) + ) + .filter((action): action is vscode.CodeAction => action !== null); } } +/** + * Creates a Code Action to fix a broken section link by replacing it with a valid one. + * @param diagnostic The `UNKNOWN_SECTION_CODE` diagnostic. + * @param sectionId The ID of a valid section to suggest as a replacement. + * @param workspace The Foam workspace. + * @returns A `vscode.CodeAction` or `null` if the target resource can't be found. + */ const createReplaceSectionCommand = ( diagnostic: vscode.Diagnostic, - section: string -): vscode.CodeAction => { + sectionId: string, + workspace: FoamWorkspace +): vscode.CodeAction | null => { + // Get the target resource from the diagnostic's related information + const targetUri = fromVsCodeUri( + diagnostic.relatedInformation[0].location.uri + ); + const targetResource = workspace.get(targetUri); + // Look up the section in the target resource by matching either heading ID or block ID. + // The sectionId may be a heading's s.id or a block's s.blockId (including caret notation). + const section = targetResource.sections.find( + s => s.id === sectionId || s.blockId === sectionId + ); + + if (!section) { + return null; // Should not happen if IDs are correctly passed + } + + const getTitle = () => { + switch (section.type) { + case 'heading': + return `Use heading "${section.label}"`; + case 'block': + return `Use block "${section.blockId}"`; + } + }; + + const getReplacementValue = () => { + switch (section.type) { + case 'heading': + return section.id; + case 'block': + return section.blockId; // Do not remove the '^' for insertion + } + }; + const action = new vscode.CodeAction( - `${section}`, + getTitle(), vscode.CodeActionKind.QuickFix ); action.command = { command: REPLACE_TEXT_COMMAND.name, - title: `Use section "${section}"`, + title: getTitle(), arguments: [ { - value: section, + value: getReplacementValue(), range: new vscode.Range( diagnostic.range.start.line, diagnostic.range.start.character + 1, @@ -262,6 +423,15 @@ const createReplaceSectionCommand = ( return action; }; +/** + * Creates a Code Action to fix an ambiguous link by replacing the link text + * with an unambiguous identifier for the chosen file. + * @param diagnostic The `AMBIGUOUS_IDENTIFIER_CODE` diagnostic. + * @param target The URI of the specific file the user wants to link to. + * @param defaultExtension The workspace's default file extension. + * @param possibleTargets The list of all possible target URIs. + * @returns A `vscode.CodeAction`. + */ const createFindIdentifierCommand = ( diagnostic: vscode.Diagnostic, target: vscode.Uri, diff --git a/packages/foam-vscode/src/services/editor.ts b/packages/foam-vscode/src/services/editor.ts index 6269b2e7b..65783d787 100644 --- a/packages/foam-vscode/src/services/editor.ts +++ b/packages/foam-vscode/src/services/editor.ts @@ -37,9 +37,9 @@ interface SelectionInfo { * Returns a MarkdownString of the note content * @param note A Foam Note */ -export function getNoteTooltip(content: string): string { +export function getNoteTooltip(content: string): MarkdownString { const strippedContent = stripFrontMatter(stripImages(content)); - return formatMarkdownTooltip(strippedContent) as any; + return formatMarkdownTooltip(strippedContent); } export function formatMarkdownTooltip(content: string): MarkdownString { diff --git a/packages/foam-vscode/src/test/test-utils.ts b/packages/foam-vscode/src/test/test-utils.ts index 3f1ab01cf..27486d8a0 100644 --- a/packages/foam-vscode/src/test/test-utils.ts +++ b/packages/foam-vscode/src/test/test-utils.ts @@ -9,7 +9,6 @@ import { FoamWorkspace } from '../core/model/workspace'; import { MarkdownResourceProvider } from '../core/services/markdown-provider'; import { NoteLinkDefinition, Resource } from '../core/model/note'; import { createMarkdownParser } from '../core/services/markdown-parser'; - export { default as waitForExpect } from 'wait-for-expect'; Logger.setLevel('error'); @@ -51,7 +50,7 @@ export const createTestNote = (params: { tags?: string[]; aliases?: string[]; text?: string; - sections?: string[]; + sections?: Array<{ label: string; blockId?: string; level?: number }>; root?: URI; type?: string; }): Resource => { @@ -62,10 +61,38 @@ export const createTestNote = (params: { properties: {}, title: params.title ?? strToUri(params.uri).getBasename(), definitions: params.definitions ?? [], - sections: params.sections?.map(label => ({ - label, - range: Range.create(0, 0, 1, 0), - })), + sections: (params.sections ?? []).map(section => { + if (section.level) { + return { + type: 'heading', + level: section.level, + id: section.label, // Use raw label for ID + label: section.label, + range: Range.create(0, 0, 1, 0), + }; + } else if (section.blockId) { + // Only enter this block if blockId is explicitly provided + const blockIdWithCaret = section.blockId.startsWith('^') + ? section.blockId + : `^${section.blockId}`; + return { + type: 'block', + id: blockIdWithCaret.substring(1), + label: section.label, + range: Range.create(0, 0, 1, 0), + blockId: blockIdWithCaret, + }; + } else { + // Default to heading if neither level nor blockId is provided + return { + type: 'heading', + level: 1, // Default level + id: section.label, + label: section.label, + range: Range.create(0, 0, 1, 0), + }; + } + }), tags: params.tags?.map(t => ({ label: t, diff --git a/packages/foam-vscode/test-data/block-identifiers/code-block.md b/packages/foam-vscode/test-data/block-identifiers/code-block.md new file mode 100644 index 000000000..c4ef1ae15 --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/code-block.md @@ -0,0 +1,7 @@ +```json +{ + "key": "value" +} +``` + +^cb1 diff --git a/packages/foam-vscode/test-data/block-identifiers/heading.md b/packages/foam-vscode/test-data/block-identifiers/heading.md new file mode 100644 index 000000000..a9f9a96bc --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/heading.md @@ -0,0 +1,7 @@ +# Heading 1 + +Some content. + +## Heading 2 ^h2 + +Some more content. diff --git a/packages/foam-vscode/test-data/block-identifiers/list.md b/packages/foam-vscode/test-data/block-identifiers/list.md new file mode 100644 index 000000000..ec1d6ad1e --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/list.md @@ -0,0 +1,5 @@ +- list item 1 ^li1 +- list item 2 + - nested list item 1 ^nli1 + - nested list item 2 +- list item 3 diff --git a/packages/foam-vscode/test-data/block-identifiers/mixed-other.md b/packages/foam-vscode/test-data/block-identifiers/mixed-other.md new file mode 100644 index 000000000..cc816a46e --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/mixed-other.md @@ -0,0 +1,3 @@ +# Another Note + +Just for linking. diff --git a/packages/foam-vscode/test-data/block-identifiers/mixed-source.md b/packages/foam-vscode/test-data/block-identifiers/mixed-source.md new file mode 100644 index 000000000..e6ec3be8e --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/mixed-source.md @@ -0,0 +1,12 @@ +# Mixed Source Note + +This note links to various things. + +- Link to whole note: [[mixed-target]] +- Link to header: [[mixed-target#Mixed Target Note]] +- Link to paragraph block: [[mixed-target#^para-block]] +- Link to list item block: [[mixed-target#^list-block]] +- Link to non-existent block: [[mixed-target#^no-such-block]] + +Let's embed the paragraph block: +![[mixed-target#^para-block]] diff --git a/packages/foam-vscode/test-data/block-identifiers/mixed-target.md b/packages/foam-vscode/test-data/block-identifiers/mixed-target.md new file mode 100644 index 000000000..a1bb540e5 --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/mixed-target.md @@ -0,0 +1,11 @@ +# Mixed Target Note + +This note has a bit of everything. + +Here is a paragraph with a block identifier. ^para-block + +- List item 1 +- List item 2 ^list-block +- List item 3 + +It also links to [[mixed-other]]. diff --git a/packages/foam-vscode/test-data/block-identifiers/note-linking-to-block-id.md b/packages/foam-vscode/test-data/block-identifiers/note-linking-to-block-id.md new file mode 100644 index 000000000..9e803fd48 --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/note-linking-to-block-id.md @@ -0,0 +1,3 @@ +# Note linking to block id + +This note links to [[note-with-block-id#^block-1]]. diff --git a/packages/foam-vscode/test-data/block-identifiers/note-with-block-id.md b/packages/foam-vscode/test-data/block-identifiers/note-with-block-id.md new file mode 100644 index 000000000..44a8a83f5 --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/note-with-block-id.md @@ -0,0 +1,3 @@ +# Note with block id + +This is a paragraph with a block identifier. ^block-1 diff --git a/packages/foam-vscode/test-data/block-identifiers/paragraph.md b/packages/foam-vscode/test-data/block-identifiers/paragraph.md new file mode 100644 index 000000000..dff46bf03 --- /dev/null +++ b/packages/foam-vscode/test-data/block-identifiers/paragraph.md @@ -0,0 +1,3 @@ +This is a paragraph. ^p1 + +This is another paragraph with a link to the first: [[#^p1]]. diff --git a/yarn.lock b/yarn.lock index 7143b7ea9..7a7b4e43d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2746,6 +2746,11 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/unist@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + "@types/vscode@^1.70.0": version "1.75.0" resolved "https://registry.npmjs.org/@types/vscode/-/vscode-1.75.0.tgz" @@ -10611,7 +10616,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10683,7 +10697,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10697,6 +10711,13 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" @@ -11715,7 +11736,7 @@ wordwrap@^1.0.0: resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11741,6 +11762,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"