mirror of
https://github.com/foambubble/foam.git
synced 2026-01-10 06:28:12 -05:00
Improved support for link references (#1531)
* Support for links with references * wikilink also use references * Removed [[//begin]] and [[//end]] definitions from code and documentation
This commit is contained in:
@@ -12,6 +12,4 @@ Here are a few specific constraints, mainly because our tooling is a bit fragmen
|
||||
- **In addition to normal Markdown Links syntax you can use `[[MediaWiki]]` links.** See [[wikilinks]] for more details.
|
||||
- **You can embed other notes using `![[note]]` syntax.** This supports various modifiers like `content![[note]]` or `full-card![[note]]` to control how content is displayed.
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[wikilinks]: ../user/features/wikilinks.md 'Wikilinks'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -296,10 +296,8 @@ Foam builds on [Visual Studio Code](https://code.visualstudio.com/), [GitHub](ht
|
||||
|
||||
Foam is licensed under the [MIT license](LICENSE.txt).
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[recommended-extensions]: user/getting-started/recommended-extensions.md 'Recommended Extensions'
|
||||
[recipes]: user/recipes/recipes.md 'Recipes'
|
||||
[frequently-asked-questions]: user/frequently-asked-questions.md 'Frequently Asked Questions'
|
||||
[principles]: principles.md 'Principles'
|
||||
[contribution-guide]: dev/contribution-guide.md 'Contribution Guide'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -23,10 +23,8 @@ Related to [[Data Science]] and [[Statistics]].
|
||||
|
||||
Related to [[Data Science]] and [[Statistics]].
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[Data Science]: data-science.md 'Data Science'
|
||||
[Statistics]: statistics.md 'Statistics'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
```
|
||||
|
||||
## Enabling Reference Definitions
|
||||
@@ -60,6 +58,3 @@ If you are using your notes only within Foam, you can keep definitions `off` (al
|
||||
- **Publishing platforms** - Compatible with GitHub Pages, Jekyll, etc.
|
||||
- **Future-proofing** - Not locked into Foam-specific format
|
||||
- **Team collaboration** - Others can read notes without Foam
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,4 @@ Customize tag appearance in markdown preview by adding CSS:
|
||||
|
||||
Some users prefer [[book]] backlinks instead of #book tags for categorization. Both approaches work - choose what fits your workflow.
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[graph-view]: graph-view.md 'Graph Visualization'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -398,6 +398,4 @@ existing_frontmatter: 'Existing Frontmatter block'
|
||||
This is the rest of the template
|
||||
```
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[daily-notes]: daily-notes.md 'Daily Notes'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -36,10 +36,8 @@ Foam can automatically generate [[link-reference-definitions]] at the bottom of
|
||||
- [[templates]] - Creating new notes
|
||||
- [[link-reference-definition-improvements]] - Current limitations
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[graph-visualization]: graph-visualization.md 'Graph Visualization'
|
||||
[link-reference-definitions]: link-reference-definitions.md 'Link Reference Definitions'
|
||||
[foam-file-format]: ../../dev/foam-file-format.md 'Foam File Format'
|
||||
[note-templates]: note-templates.md 'Note Templates'
|
||||
[link-reference-definition-improvements]: ../../dev/proposals/link-reference-definition-improvements.md 'Link Reference Definition Improvements'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
- Check the formatting rules for links on [[foam-file-format]] and [[wikilinks]]
|
||||
|
||||
## I don't want Foam enabled for all my workspaces
|
||||
|
||||
Any extension you install in Visual Studio Code is enabled by default. Given the philosophy of Foam, it works out of the box without doing any configuration upfront. In case you want to disable Foam for a specific workspace, or disable Foam by default and enable it for specific workspaces, it is advised to follow the best practices as [documented by Visual Studio Code](https://code.visualstudio.com/docs/editor/extension-marketplace#_manage-extensions)
|
||||
|
||||
## I want to publish the graph view to GitHub pages or Vercel
|
||||
|
||||
If you want a different front-end look to your published foam and a way to see your graph view, we'd recommend checking out these templates:
|
||||
|
||||
- [foam-gatsby](https://github.com/mathieudutour/foam-gatsby-template) by [Mathieu Dutour](https://github.com/mathieudutour)
|
||||
- [foam-gatsby-kb](https://github.com/hikerpig/foam-template-gatsby-kb) by [hikerpig](https://github.com/hikerpig)
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[recommended-extensions]: getting-started/recommended-extensions.md "Recommended Extensions"
|
||||
[foam-file-format]: ../dev/foam-file-format.md "Foam File Format"
|
||||
[wikilinks]: features/wikilinks.md "Wikilinks"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[recommended-extensions]: getting-started/recommended-extensions.md 'Recommended Extensions'
|
||||
[foam-file-format]: ../dev/foam-file-format.md 'Foam File Format'
|
||||
[wikilinks]: features/wikilinks.md 'Wikilinks'
|
||||
|
||||
@@ -233,7 +233,6 @@ Now that you understand note-taking basics:
|
||||
3. **[Set up templates](../features/templates.md)** - Create reusable note structures
|
||||
4. **[Use daily notes](../features/daily-notes.md)** - Establish a daily capture routine
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[navigation]: navigation.md 'Navigation in Foam'
|
||||
[tags]: ../features/tags.md 'Tags'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
|
||||
@@ -1,134 +1,188 @@
|
||||
import { generateLinkReferences } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { MarkdownResourceProvider } from '../services/markdown-provider';
|
||||
import { Resource } from '../model/note';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
import { Range } from '../model/range';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { Logger } from '../utils/log';
|
||||
import fs from 'fs';
|
||||
import { URI } from '../model/uri';
|
||||
import { EOL } from 'os';
|
||||
import { createMarkdownParser } from '../services/markdown-parser';
|
||||
import { FileDataStore } from '../../test/test-datastore';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('generateLinkReferences', () => {
|
||||
let _workspace: FoamWorkspace;
|
||||
// TODO slug must be reserved for actual slugs, not file names
|
||||
const findBySlug = (slug: string): Resource => {
|
||||
return _workspace
|
||||
.list()
|
||||
.find(res => res.uri.getName() === slug) as Resource;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
/** Use fs for reading files in units where vscode.workspace is unavailable */
|
||||
const readFile = async (uri: URI) =>
|
||||
(await fs.promises.readFile(uri.toFsPath())).toString();
|
||||
const dataStore = new FileDataStore(
|
||||
readFile,
|
||||
TEST_DATA_DIR.joinPath('__scaffold__').toFsPath()
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
const mdProvider = new MarkdownResourceProvider(dataStore, parser);
|
||||
_workspace = await FoamWorkspace.fromProviders([mdProvider], dataStore);
|
||||
});
|
||||
|
||||
it('initialised test graph correctly', () => {
|
||||
expect(_workspace.list().length).toEqual(11);
|
||||
});
|
||||
|
||||
it('should add link references to a file that does not have them', async () => {
|
||||
const note = findBySlug('index');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/first-document.md', title: 'First Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({
|
||||
uri: '/file-without-title.md',
|
||||
title: 'file-without-title',
|
||||
})
|
||||
);
|
||||
const noteText = `# Index
|
||||
|
||||
This file is intentionally missing the link reference definitions
|
||||
|
||||
[[first-document]]
|
||||
|
||||
[[second-document]]
|
||||
|
||||
[[file-without-title]]
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[first-document]: first-document "First Document"
|
||||
[second-document]: second-document "Second Document"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
[file-without-title]: file-without-title "file-without-title"`
|
||||
),
|
||||
range: Range.create(9, 0, 9, 0),
|
||||
};
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual![0].range.start).toEqual(expected.range.start);
|
||||
expect(actual![0].range.end).toEqual(expected.range.end);
|
||||
expect(actual![0].newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should remove link definitions from a file that has them, if no links are present', async () => {
|
||||
const note = findBySlug('second-document');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/first-document.md', title: 'First Document' })
|
||||
);
|
||||
const noteText = `# Second Document
|
||||
|
||||
This is just a link target for now.
|
||||
|
||||
We can use it for other things later if needed.
|
||||
|
||||
[first-document]: first-document 'First Document'
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = {
|
||||
newText: '',
|
||||
range: Range.create(6, 0, 8, 42),
|
||||
range: Range.create(6, 0, 6, 49),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual.length).toBe(1);
|
||||
expect(actual[0]!.range.start).toEqual(expected.range.start);
|
||||
expect(actual[0]!.range.end).toEqual(expected.range.end);
|
||||
expect(actual[0]!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should update link definitions if they are present but changed', async () => {
|
||||
const note = findBySlug('first-document');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({
|
||||
uri: '/file-without-title.md',
|
||||
title: 'file-without-title',
|
||||
})
|
||||
);
|
||||
const noteText = `# First Document
|
||||
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
),
|
||||
range: Range.create(8, 0, 10, 42),
|
||||
};
|
||||
Here's some [unrelated] content.
|
||||
|
||||
[unrelated]: http://unrelated.com 'This link should not be changed'
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
[second-document]: second-document 'Second Document'
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = [
|
||||
{
|
||||
newText: '',
|
||||
range: Range.create(8, 0, 8, 52),
|
||||
},
|
||||
{
|
||||
newText: textForNote(
|
||||
`\n[file-without-title]: file-without-title "file-without-title"`
|
||||
),
|
||||
range: Range.create(9, 0, 9, 0),
|
||||
},
|
||||
];
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual.length).toBe(2);
|
||||
expect(actual[0]!.range.start).toEqual(expected[0].range.start);
|
||||
expect(actual[0]!.range.end).toEqual(expected[0].range.end);
|
||||
expect(actual[0]!.newText).toEqual(expected[0].newText);
|
||||
expect(actual[1]!.range.start).toEqual(expected[1].range.start);
|
||||
expect(actual[1]!.range.end).toEqual(expected[1].range.end);
|
||||
expect(actual[1]!.newText).toEqual(expected[1].newText);
|
||||
});
|
||||
|
||||
it('should not cause any changes if link reference definitions were up to date', async () => {
|
||||
const note = findBySlug('third-document');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')])
|
||||
.set(
|
||||
createTestNote({ uri: '/first-document.md', title: 'First Document' })
|
||||
)
|
||||
.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
const noteText = `# Third Document
|
||||
|
||||
const expected = null;
|
||||
All the link references are correct in this file.
|
||||
|
||||
[[first-document]]
|
||||
|
||||
[[second-document]]
|
||||
|
||||
[first-document]: first-document "First Document"
|
||||
[second-document]: second-document "Second Document"
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = [];
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -136,43 +190,71 @@ describe('generateLinkReferences', () => {
|
||||
});
|
||||
|
||||
it('should put links with spaces in angel brackets', async () => {
|
||||
const note = findBySlug('angel-reference');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]).set(
|
||||
createTestNote({
|
||||
uri: '/Note being referred as angel.md',
|
||||
title: 'Note being referred as angel',
|
||||
})
|
||||
);
|
||||
const noteText = `# Angel reference
|
||||
|
||||
[[Note being referred as angel]]
|
||||
`;
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[Note being referred as angel]: <Note being referred as angel> "Note being referred as angel"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
[Note being referred as angel]: <Note being referred as angel> "Note being referred as angel"`
|
||||
),
|
||||
range: Range.create(3, 0, 3, 0),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual.length).toBe(1);
|
||||
expect(actual[0]!.range.start).toEqual(expected.range.start);
|
||||
expect(actual[0]!.range.end).toEqual(expected.range.end);
|
||||
expect(actual[0]!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should not remove explicitly entered link references', async () => {
|
||||
const note = findBySlug('file-with-explicit-link-references');
|
||||
const expected = null;
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({
|
||||
uri: '/file-without-title.md',
|
||||
title: 'file-without-title',
|
||||
})
|
||||
);
|
||||
const noteText = `# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference]
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = [];
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -180,27 +262,33 @@ describe('generateLinkReferences', () => {
|
||||
});
|
||||
|
||||
it('should not remove explicitly entered link references and have an implicit link', async () => {
|
||||
const note = findBySlug('file-with-explicit-and-implicit-link-references');
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[first-document]: first-document "First Document"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
),
|
||||
range: Range.create(8, 0, 10, 42),
|
||||
};
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
const noteText = `# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference].
|
||||
I also want a [[first-document]].
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
[first-document]: first-document 'First Document'
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
expect(actual.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,80 +1,74 @@
|
||||
import { NoteLinkDefinition, Resource } from '../model/note';
|
||||
import { NoteLinkDefinition, Resource, ResourceLink } from '../model/note';
|
||||
import { Range } from '../model/range';
|
||||
import { createMarkdownReferences } from '../services/markdown-provider';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { TextEdit } from '../services/text-edit';
|
||||
import { Position } from '../model/position';
|
||||
import { getLinkDefinitions } from '../services/markdown-parser';
|
||||
|
||||
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
|
||||
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
|
||||
|
||||
export const generateLinkReferences = async (
|
||||
note: Resource,
|
||||
text: string,
|
||||
currentNoteText: string,
|
||||
eol: string,
|
||||
workspace: FoamWorkspace,
|
||||
includeExtensions: boolean
|
||||
): Promise<TextEdit | null> => {
|
||||
): Promise<TextEdit[]> => {
|
||||
if (!note) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const newWikilinkDefinitions = createMarkdownReferences(
|
||||
const nLines = currentNoteText.split(eol).length;
|
||||
|
||||
const updatedWikilinkDefinitions = createMarkdownReferences(
|
||||
workspace,
|
||||
note,
|
||||
includeExtensions
|
||||
);
|
||||
|
||||
const beginDelimiterDef = note.definitions.find(
|
||||
({ label }) => label === '//begin'
|
||||
const existingWikilinkDefinitions = getLinkDefinitions(currentNoteText);
|
||||
|
||||
const toAddWikilinkDefinitions = updatedWikilinkDefinitions.filter(
|
||||
newDef =>
|
||||
!existingWikilinkDefinitions.some(
|
||||
existingDef => existingDef.label === newDef.label
|
||||
)
|
||||
);
|
||||
const endDelimiterDef = note.definitions.find(
|
||||
({ label }) => label === '//end'
|
||||
const toRemovedWikilinkDefinitions = existingWikilinkDefinitions.filter(
|
||||
existingDef =>
|
||||
!updatedWikilinkDefinitions.some(
|
||||
newDef => newDef.label === existingDef.label
|
||||
)
|
||||
);
|
||||
|
||||
const lines = text.split(eol);
|
||||
const edits: TextEdit[] = [];
|
||||
|
||||
const targetRange =
|
||||
beginDelimiterDef && endDelimiterDef
|
||||
? Range.createFromPosition(
|
||||
beginDelimiterDef.range.start,
|
||||
endDelimiterDef.range.end
|
||||
)
|
||||
: Range.create(
|
||||
lines.length - 1,
|
||||
lines[lines.length - 1].length,
|
||||
lines.length - 1,
|
||||
lines[lines.length - 1].length
|
||||
);
|
||||
// Remove old definitions
|
||||
for (const def of toRemovedWikilinkDefinitions) {
|
||||
edits.push({ range: def.range, newText: '' });
|
||||
}
|
||||
|
||||
const newReferences =
|
||||
newWikilinkDefinitions.length === 0
|
||||
? ''
|
||||
: [
|
||||
LINK_REFERENCE_DEFINITION_HEADER,
|
||||
...newWikilinkDefinitions.map(NoteLinkDefinition.format),
|
||||
LINK_REFERENCE_DEFINITION_FOOTER,
|
||||
].join(eol);
|
||||
// Add new definitions
|
||||
if (toAddWikilinkDefinitions.length > 0) {
|
||||
const lastLine = currentNoteText.split(eol)[nLines - 1];
|
||||
const isLastLineEmpty = lastLine.trim().length === 0;
|
||||
|
||||
// check if the new references match the existing references
|
||||
const existingReferences = lines
|
||||
.slice(targetRange.start.line, targetRange.end.line + 1)
|
||||
.join(eol);
|
||||
let text = isLastLineEmpty ? '' : eol;
|
||||
for (const def of toAddWikilinkDefinitions) {
|
||||
// Choose the correct position for insertion, e.g., end of file or after last reference
|
||||
text = `${text}${eol}${NoteLinkDefinition.format(def)}`;
|
||||
}
|
||||
edits.push({
|
||||
range: Range.create(
|
||||
nLines - 1,
|
||||
lastLine.length,
|
||||
nLines - 1,
|
||||
lastLine.length
|
||||
),
|
||||
newText: text,
|
||||
});
|
||||
}
|
||||
|
||||
// adjust padding based on whether there are existing definitions
|
||||
// and, if not, whether we are on an empty line at the end of the file
|
||||
const padding =
|
||||
newWikilinkDefinitions.length === 0 || // no definitions
|
||||
!Position.isEqual(targetRange.start, targetRange.end) // replace existing definitions
|
||||
? ''
|
||||
: targetRange.start.character > 0 // not an empty line
|
||||
? `${eol}${eol}`
|
||||
: eol;
|
||||
|
||||
return existingReferences === newReferences
|
||||
? null
|
||||
: {
|
||||
newText: `${padding}${newReferences}`,
|
||||
range: targetRange,
|
||||
};
|
||||
return edits;
|
||||
};
|
||||
|
||||
@@ -264,15 +264,10 @@ describe('Placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/page-a.md',
|
||||
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: './page-b.md',
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-c',
|
||||
url: '/path/to/page-c.md',
|
||||
links: [
|
||||
{ slug: 'page-b', definitionUrl: './page-b.md' },
|
||||
{ slug: 'page-c', definitionUrl: '/path/to/page-c.md' },
|
||||
],
|
||||
});
|
||||
ws.set(noteA).set(
|
||||
createTestNote({ uri: '/different/location/for/note-b.md' })
|
||||
|
||||
@@ -6,6 +6,41 @@ export interface ResourceLink {
|
||||
rawText: string;
|
||||
range: Range;
|
||||
isEmbed: boolean;
|
||||
definition?: string | NoteLinkDefinition;
|
||||
}
|
||||
|
||||
export abstract class ResourceLink {
|
||||
/**
|
||||
* Check if this is any kind of reference-style link (resolved or unresolved)
|
||||
*/
|
||||
static isReferenceStyleLink(link: ResourceLink): boolean {
|
||||
return link.definition !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a reference-style link with unresolved definition
|
||||
*/
|
||||
static isUnresolvedReference(
|
||||
link: ResourceLink
|
||||
): link is ResourceLink & { definition: string } {
|
||||
return typeof link.definition === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a reference-style link with resolved definition
|
||||
*/
|
||||
static isResolvedReference(
|
||||
link: ResourceLink
|
||||
): link is ResourceLink & { definition: NoteLinkDefinition } {
|
||||
return typeof link.definition === 'object' && link.definition !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a regular inline link (not reference-style)
|
||||
*/
|
||||
static isRegularLink(link: ResourceLink): boolean {
|
||||
return link.definition === undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoteLinkDefinition {
|
||||
@@ -52,9 +87,6 @@ export interface Resource {
|
||||
tags: Tag[];
|
||||
aliases: Alias[];
|
||||
links: ResourceLink[];
|
||||
|
||||
// TODO to remove
|
||||
definitions: NoteLinkDefinition[];
|
||||
}
|
||||
|
||||
export interface ResourceParser {
|
||||
|
||||
@@ -26,7 +26,6 @@ const asResource = (uri: URI): Resource => {
|
||||
sections: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
definitions: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -523,4 +523,101 @@ describe('MarkdownLink', () => {
|
||||
expect(wikilinkEdit.range).toEqual(wikilink.range);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse links with resolved definitions', () => {
|
||||
it('should parse wikilink with resolved definition - target and section from definition, alias from rawText', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'wikilink',
|
||||
rawText: '[[my-note|Custom Display Text]]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'my-note',
|
||||
url: './docs/document.md#introduction',
|
||||
title: 'Document Title',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./docs/document.md'); // From definition.url (base)
|
||||
expect(parsed.section).toEqual('introduction'); // From definition.url (fragment)
|
||||
expect(parsed.alias).toEqual('Custom Display Text'); // From rawText
|
||||
});
|
||||
|
||||
it('should parse reference-style link with resolved definition - target and section from definition, alias from rawText', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'link',
|
||||
rawText: '[Click here to read][myref]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'myref',
|
||||
url: './document.md#section',
|
||||
title: 'My Document',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./document.md'); // From definition.url (base)
|
||||
expect(parsed.section).toEqual('section'); // From definition.url (fragment)
|
||||
expect(parsed.alias).toEqual('Click here to read'); // From rawText
|
||||
});
|
||||
|
||||
it('should handle wikilink with resolved definition but no section in URL', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'wikilink',
|
||||
rawText: '[[my-note#ignored-section|Display Text]]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'my-note',
|
||||
url: './docs/document.md', // No fragment
|
||||
title: 'Document Title',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./docs/document.md'); // From definition.url
|
||||
expect(parsed.section).toEqual(''); // Empty - no fragment in definition.url
|
||||
expect(parsed.alias).toEqual('Display Text'); // From rawText
|
||||
});
|
||||
|
||||
it('should handle reference-style link with resolved definition but no alias in rawText', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'link',
|
||||
rawText: '[text][ref]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'ref',
|
||||
url: './target.md#section',
|
||||
title: 'Target',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./target.md'); // From definition.url (base)
|
||||
expect(parsed.section).toEqual('section'); // From definition.url (fragment)
|
||||
expect(parsed.alias).toEqual('text'); // From rawText
|
||||
});
|
||||
|
||||
it('should handle complex URLs in definitions', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'wikilink',
|
||||
rawText: '[[note|Alias]]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'note',
|
||||
url: '../path/to/some file.md#complex section name',
|
||||
title: 'Title',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('../path/to/some file.md'); // Base path
|
||||
expect(parsed.section).toEqual('complex section name'); // Fragment with spaces
|
||||
expect(parsed.alias).toEqual('Alias'); // From rawText
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResourceLink } from '../model/note';
|
||||
import { URI } from '../model/uri';
|
||||
import { TextEdit } from './text-edit';
|
||||
|
||||
export abstract class MarkdownLink {
|
||||
@@ -15,6 +16,17 @@ export abstract class MarkdownLink {
|
||||
const [, target, section, alias] = this.wikilinkRegex.exec(
|
||||
link.rawText
|
||||
);
|
||||
|
||||
// For wikilinks with resolved definitions, parse target and section from definition URL
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
const definitionUri = URI.parse(link.definition.url, 'tmp');
|
||||
return {
|
||||
target: definitionUri.path, // Base path from definition
|
||||
section: definitionUri.fragment, // Fragment from definition
|
||||
alias: alias ?? '', // Alias from rawText
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: target?.replace(/\\/g, '') ?? '',
|
||||
section: section ?? '',
|
||||
@@ -22,9 +34,34 @@ export abstract class MarkdownLink {
|
||||
};
|
||||
}
|
||||
if (link.type === 'link') {
|
||||
const [, alias, target, section] = this.directLinkRegex.exec(
|
||||
link.rawText
|
||||
);
|
||||
// For reference-style links with resolved definitions, parse target and section from definition URL
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
// Extract alias from rawText for reference-style links
|
||||
const referenceMatch = /^\[([^\]]*)\]/.exec(link.rawText);
|
||||
const alias = referenceMatch ? referenceMatch[1] : '';
|
||||
|
||||
// Parse target and section from definition URL
|
||||
const definitionUri = URI.parse(link.definition.url, 'tmp');
|
||||
return {
|
||||
target: definitionUri.path, // Base path from definition
|
||||
section: definitionUri.fragment, // Fragment from definition
|
||||
alias: alias, // Alias from rawText
|
||||
};
|
||||
}
|
||||
|
||||
const match = this.directLinkRegex.exec(link.rawText);
|
||||
if (!match) {
|
||||
// This might be a reference-style link that wasn't resolved
|
||||
// Try to extract just the alias text for reference-style links
|
||||
const referenceMatch = /^\[([^\]]*)\]/.exec(link.rawText);
|
||||
const alias = referenceMatch ? referenceMatch[1] : '';
|
||||
return {
|
||||
target: '',
|
||||
section: '',
|
||||
alias: alias,
|
||||
};
|
||||
}
|
||||
const [, alias, target, section] = match;
|
||||
return {
|
||||
target: target ?? '',
|
||||
section: section ?? '',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getBlockFor,
|
||||
ParserPlugin,
|
||||
} from './markdown-parser';
|
||||
import { NoteLinkDefinition, ResourceLink } from '../model/note';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
import { Range } from '../model/range';
|
||||
@@ -102,6 +103,17 @@ describe('Markdown parsing', () => {
|
||||
expect(link.isEmbed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should set reference to alias for wikilinks with alias', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'This is a [[target-file|Display Name]] wikilink.'
|
||||
);
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(ResourceLink.isUnresolvedReference(link)).toBe(true);
|
||||
expect(link.definition).toEqual('target-file');
|
||||
});
|
||||
|
||||
it('should skip wikilinks in codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some text with our [[first-wikilink]].
|
||||
@@ -131,6 +143,74 @@ this is some text with our [[second-wikilink]].
|
||||
'[[second-wikilink]]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect reference-style links', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Test Document
|
||||
|
||||
This is a [reference-style link][ref1] and another [link][ref2].
|
||||
|
||||
[ref1]: target1.md "Target 1"
|
||||
[ref2]: target2.md "Target 2"
|
||||
`);
|
||||
|
||||
expect(note.links.length).toEqual(2);
|
||||
|
||||
const link1 = note.links[0];
|
||||
expect(link1.type).toEqual('link');
|
||||
expect(link1.rawText).toEqual('[reference-style link][ref1]');
|
||||
expect(ResourceLink.isResolvedReference(link1)).toBe(true);
|
||||
const definition1 = link1.definition as NoteLinkDefinition;
|
||||
expect(definition1.label).toEqual('ref1');
|
||||
expect(definition1.url).toEqual('target1.md');
|
||||
expect(definition1.title).toEqual('Target 1');
|
||||
|
||||
const link2 = note.links[1];
|
||||
expect(link2.type).toEqual('link');
|
||||
expect(link2.rawText).toEqual('[link][ref2]');
|
||||
expect(ResourceLink.isResolvedReference(link2)).toBe(true);
|
||||
const definition2 = link2.definition as NoteLinkDefinition;
|
||||
expect(definition2.label).toEqual('ref2');
|
||||
expect(definition2.url).toEqual('target2.md');
|
||||
});
|
||||
|
||||
it('should handle reference-style links without matching definitions', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
This is a [reference-style link][missing-ref].
|
||||
|
||||
[existing-ref]: target.md "Target"
|
||||
`);
|
||||
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0];
|
||||
expect(link.type).toEqual('link');
|
||||
expect(link.rawText).toEqual('[reference-style link][missing-ref]');
|
||||
expect(ResourceLink.isUnresolvedReference(link)).toBe(true);
|
||||
expect(link.definition).toEqual('missing-ref');
|
||||
});
|
||||
|
||||
it('should handle mixed link types', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
This has [[wikilink]], [inline link](target.md), and [reference link][ref].
|
||||
|
||||
[ref]: reference-target.md "Reference Target"
|
||||
`);
|
||||
|
||||
expect(note.links.length).toEqual(3);
|
||||
|
||||
expect(note.links[0].type).toEqual('wikilink');
|
||||
expect(note.links[0].rawText).toEqual('[[wikilink]]');
|
||||
expect(ResourceLink.isUnresolvedReference(note.links[0])).toBe(true);
|
||||
expect(note.links[0].definition).toEqual('wikilink');
|
||||
|
||||
expect(note.links[1].type).toEqual('link');
|
||||
expect(note.links[1].rawText).toEqual('[inline link](target.md)');
|
||||
expect(ResourceLink.isReferenceStyleLink(note.links[1])).toBe(false);
|
||||
|
||||
expect(note.links[2].type).toEqual('link');
|
||||
expect(note.links[2].rawText).toEqual('[reference link][ref]');
|
||||
expect(ResourceLink.isResolvedReference(note.links[2])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note Title', () => {
|
||||
|
||||
@@ -6,7 +6,12 @@ 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 {
|
||||
NoteLinkDefinition,
|
||||
Resource,
|
||||
ResourceLink,
|
||||
ResourceParser,
|
||||
} from '../model/note';
|
||||
import { Position } from '../model/position';
|
||||
import { Range } from '../model/range';
|
||||
import { extractHashtags, extractTagsFromProp, hash, isSome } from '../utils';
|
||||
@@ -41,19 +46,34 @@ export interface ParserCacheEntry {
|
||||
*/
|
||||
export type ParserCache = ICache<URI, ParserCacheEntry>;
|
||||
|
||||
const parser = unified()
|
||||
.use(markdownParse, { gfm: true })
|
||||
.use(frontmatterPlugin, ['yaml'])
|
||||
.use(wikiLinkPlugin, { aliasDivider: '|' });
|
||||
|
||||
export function getLinkDefinitions(markdown: string): NoteLinkDefinition[] {
|
||||
const definitions: NoteLinkDefinition[] = [];
|
||||
const tree = parser.parse(markdown);
|
||||
visit(tree, node => {
|
||||
if (node.type === 'definition') {
|
||||
definitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
});
|
||||
return definitions;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -89,9 +109,10 @@ export function createMarkdownParser(
|
||||
tags: [],
|
||||
aliases: [],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
|
||||
const localDefinitions: NoteLinkDefinition[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onWillVisitTree?.(tree, note);
|
||||
@@ -119,6 +140,15 @@ export function createMarkdownParser(
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'definition') {
|
||||
localDefinitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.visit?.(node, note, markdown);
|
||||
@@ -134,6 +164,21 @@ export function createMarkdownParser(
|
||||
handleError(plugin, 'onDidVisitTree', uri, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-processing: Resolve reference identifiers to definitions for all links
|
||||
note.links.forEach(link => {
|
||||
if (ResourceLink.isUnresolvedReference(link)) {
|
||||
// This link has a reference identifier (from linkReference or wikilink)
|
||||
const referenceId = link.definition;
|
||||
const definition = localDefinitions.find(
|
||||
def => def.label === referenceId
|
||||
);
|
||||
|
||||
// Set definition to definition object if found, otherwise keep as string
|
||||
(link as any).definition = definition || referenceId;
|
||||
}
|
||||
});
|
||||
|
||||
Logger.debug('Result:', note);
|
||||
return note;
|
||||
},
|
||||
@@ -359,6 +404,7 @@ const wikilinkPlugin: ParserPlugin = {
|
||||
rawText: literalContent,
|
||||
range,
|
||||
isEmbed,
|
||||
definition: (node as any).value,
|
||||
});
|
||||
}
|
||||
if (node.type === 'link' || node.type === 'image') {
|
||||
@@ -378,24 +424,27 @@ const wikilinkPlugin: ParserPlugin = {
|
||||
isEmbed: literalContent.startsWith('!'),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
if (node.type === 'linkReference') {
|
||||
const literalContent = noteSource.substring(
|
||||
node.position!.start.offset!,
|
||||
node.position!.end.offset!
|
||||
);
|
||||
|
||||
const definitionsPlugin: ParserPlugin = {
|
||||
name: 'definitions',
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'definition') {
|
||||
note.definitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
const identifier = (node as any).identifier;
|
||||
|
||||
note.links.push({
|
||||
type: 'link',
|
||||
rawText: literalContent,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
isEmbed: false,
|
||||
// Store reference identifier temporarily - will be resolved in onDidVisitTree
|
||||
definition: identifier,
|
||||
});
|
||||
}
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
const end = astPointToFoamPosition(tree.position.end);
|
||||
note.definitions = getFoamDefinitions(note.definitions, end);
|
||||
// This onDidVisitTree is now handled globally after all plugins have run
|
||||
// and localDefinitions have been collected.
|
||||
},
|
||||
};
|
||||
|
||||
@@ -414,31 +463,6 @@ const handleError = (
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the 1-index Point object into the VS Code 0-index Position object
|
||||
* @param point ast Point (1-indexed)
|
||||
|
||||
@@ -97,11 +97,7 @@ describe('Link resolution', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/from/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: '../to/page-b.md',
|
||||
links: [{ slug: 'page-b', definitionUrl: '../to/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/somewhere/to/page-b.md',
|
||||
@@ -307,7 +303,8 @@ describe('Link resolution', () => {
|
||||
|
||||
expect(ws.resolveLink(noteB, noteB.links[0])).toEqual(noteA.uri);
|
||||
expect(ws.resolveLink(noteC, noteC.links[0])).toEqual(noteA.uri);
|
||||
expect(noteD.links).toEqual([]);
|
||||
expect(noteD.links.length).toEqual(1);
|
||||
expect(noteD.links[0].definition).toEqual('note'); // Unresolved reference
|
||||
});
|
||||
|
||||
describe('Workspace-relative paths (root-path relative)', () => {
|
||||
|
||||
@@ -57,15 +57,8 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
const { target, section } = MarkdownLink.analyzeLink(link);
|
||||
switch (link.type) {
|
||||
case 'wikilink': {
|
||||
let definitionUri = undefined;
|
||||
for (const def of resource.definitions) {
|
||||
if (def.label === target) {
|
||||
definitionUri = def.url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isSome(definitionUri)) {
|
||||
const definedUri = resource.uri.resolve(definitionUri);
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
const definedUri = resource.uri.resolve(link.definition.url);
|
||||
targetUri =
|
||||
workspace.find(definedUri, resource.uri)?.uri ??
|
||||
URI.placeholder(definedUri.path);
|
||||
@@ -75,23 +68,33 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
? resource.uri
|
||||
: workspace.find(target, resource.uri)?.uri ??
|
||||
URI.placeholder(target);
|
||||
|
||||
if (section) {
|
||||
targetUri = targetUri.with({ fragment: section });
|
||||
}
|
||||
}
|
||||
if (section) {
|
||||
targetUri = targetUri.with({ fragment: section });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'link': {
|
||||
if (ResourceLink.isUnresolvedReference(link)) {
|
||||
// Reference-style link with unresolved reference - treat as placeholder
|
||||
targetUri = URI.placeholder(link.definition);
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle reference-style links first
|
||||
const targetPath = ResourceLink.isResolvedReference(link)
|
||||
? link.definition.url
|
||||
: target;
|
||||
|
||||
let path: string;
|
||||
let foundResource: Resource | null = null;
|
||||
|
||||
if (target.startsWith('/')) {
|
||||
if (targetPath.startsWith('/')) {
|
||||
// Handle workspace-relative paths (root-path relative)
|
||||
if (this.workspaceRoots.length > 0) {
|
||||
// Try to resolve against each workspace root
|
||||
for (const workspaceRoot of this.workspaceRoots) {
|
||||
const candidatePath = target.substring(1); // Remove leading '/'
|
||||
const candidatePath = targetPath.substring(1); // Remove leading '/'
|
||||
const absolutePath = workspaceRoot.joinPath(candidatePath);
|
||||
const found = workspace.find(absolutePath);
|
||||
if (found) {
|
||||
@@ -103,7 +106,7 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
if (!foundResource) {
|
||||
// Not found in any workspace root, create placeholder relative to first workspace root
|
||||
const firstRoot = this.workspaceRoots[0];
|
||||
const candidatePath = target.substring(1);
|
||||
const candidatePath = targetPath.substring(1);
|
||||
const absolutePath = firstRoot.joinPath(candidatePath);
|
||||
targetUri = URI.placeholder(absolutePath.path);
|
||||
} else {
|
||||
@@ -111,7 +114,7 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
}
|
||||
} else {
|
||||
// No workspace roots provided, fall back to existing behavior
|
||||
path = target;
|
||||
path = targetPath;
|
||||
targetUri =
|
||||
workspace.find(path, resource.uri)?.uri ??
|
||||
URI.placeholder(resource.uri.resolve(path).path);
|
||||
@@ -119,9 +122,9 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
} else {
|
||||
// Handle relative paths and non-root paths
|
||||
path =
|
||||
target.startsWith('./') || target.startsWith('../')
|
||||
? target
|
||||
: './' + target;
|
||||
targetPath.startsWith('./') || targetPath.startsWith('../')
|
||||
? targetPath
|
||||
: './' + targetPath;
|
||||
targetUri =
|
||||
workspace.find(path, resource.uri)?.uri ??
|
||||
URI.placeholder(resource.uri.resolve(path).path);
|
||||
@@ -149,8 +152,12 @@ export function createMarkdownReferences(
|
||||
const resource = source instanceof URI ? workspace.find(source) : source;
|
||||
|
||||
const definitions = resource.links
|
||||
.filter(link => link.type === 'wikilink')
|
||||
.filter(link => ResourceLink.isReferenceStyleLink(link))
|
||||
.map(link => {
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
return link.definition;
|
||||
}
|
||||
|
||||
const targetUri = workspace.resolveLink(resource, link);
|
||||
const target = workspace.find(targetUri);
|
||||
if (isNone(target)) {
|
||||
|
||||
@@ -72,4 +72,32 @@ describe('applyTextEdit', () => {
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should apply multiple TextEdits in reverse order (VS Code behavior)', () => {
|
||||
// This test shows why reverse order is important for range stability
|
||||
const textEdits = [
|
||||
// Edit near beginning - would affect later ranges if applied first
|
||||
{
|
||||
newText: `[PREFIX] `,
|
||||
range: Range.create(0, 0, 0, 0),
|
||||
},
|
||||
// Edit in middle - range stays valid with reverse order
|
||||
{
|
||||
newText: `[MIDDLE] `,
|
||||
range: Range.create(0, 11, 0, 11),
|
||||
},
|
||||
// Edit at end - applied first, doesn't affect other ranges
|
||||
{
|
||||
newText: ` [END]`,
|
||||
range: Range.create(0, 15, 0, 15),
|
||||
},
|
||||
];
|
||||
|
||||
const text = `this is my text`;
|
||||
const expected = `[PREFIX] this is my [MIDDLE] text [END]`;
|
||||
|
||||
const actual = TextEdit.apply(text, textEdits);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,28 @@ export abstract class TextEdit {
|
||||
* @param textEdit
|
||||
* @returns {string} text with the applied textEdit
|
||||
*/
|
||||
public static apply(text: string, textEdit: TextEdit): string {
|
||||
public static apply(text: string, textEdit: TextEdit): string;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
public static apply(text: string, textEdits: TextEdit[]): string;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
public static apply(
|
||||
text: string,
|
||||
textEditOrEdits: TextEdit | TextEdit[]
|
||||
): string {
|
||||
if (Array.isArray(textEditOrEdits)) {
|
||||
// Apply edits in reverse order (end-to-beginning) to maintain range validity
|
||||
// This matches VS Code's behavior for TextEdit application
|
||||
const sortedEdits = [...textEditOrEdits].sort((a, b) =>
|
||||
Position.compareTo(b.range.start, a.range.start)
|
||||
);
|
||||
let result = text;
|
||||
for (const textEdit of sortedEdits) {
|
||||
result = this.apply(result, textEdit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const textEdit = textEditOrEdits;
|
||||
const eol = detectNewline.graceful(text);
|
||||
const lines = text.split(eol);
|
||||
const characters = text.split('');
|
||||
|
||||
@@ -106,7 +106,7 @@ async function runJanitor(foam: Foam) {
|
||||
|
||||
const definitions =
|
||||
wikilinkSetting === 'off'
|
||||
? null
|
||||
? []
|
||||
: await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
@@ -114,11 +114,11 @@ async function runJanitor(foam: Foam) {
|
||||
foam.workspace,
|
||||
wikilinkSetting === 'withExtensions'
|
||||
);
|
||||
if (definitions) {
|
||||
if (definitions.length > 0) {
|
||||
updatedDefinitionListCount += 1;
|
||||
}
|
||||
|
||||
if (!heading && !definitions) {
|
||||
if (!heading && definitions.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ async function runJanitor(foam: Foam) {
|
||||
// Note: The ordering matters. Definitions need to be inserted
|
||||
// before heading, since inserting a heading changes line numbers below
|
||||
let text = noteText;
|
||||
text = definitions ? TextEdit.apply(text, definitions) : text;
|
||||
text = definitions.length > 0 ? TextEdit.apply(text, definitions) : text;
|
||||
text = heading ? TextEdit.apply(text, heading) : text;
|
||||
|
||||
return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));
|
||||
@@ -148,7 +148,7 @@ async function runJanitor(foam: Foam) {
|
||||
const heading = await generateHeading(note, noteText, eol);
|
||||
const definitions =
|
||||
wikilinkSetting === 'off'
|
||||
? null
|
||||
? []
|
||||
: await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
@@ -157,19 +157,22 @@ async function runJanitor(foam: Foam) {
|
||||
wikilinkSetting === 'withExtensions'
|
||||
);
|
||||
|
||||
if (heading || definitions) {
|
||||
if (heading || definitions.length > 0) {
|
||||
// Apply Edits
|
||||
/* eslint-disable */
|
||||
await editor.edit(editBuilder => {
|
||||
// Note: The ordering matters. Definitions need to be inserted
|
||||
// before heading, since inserting a heading changes line numbers below
|
||||
if (definitions) {
|
||||
if (definitions.length > 0) {
|
||||
updatedDefinitionListCount += 1;
|
||||
const start = definitions.range.start;
|
||||
const end = definitions.range.end;
|
||||
// Apply all definition edits
|
||||
definitions.forEach(definition => {
|
||||
const start = definition.range.start;
|
||||
const end = definition.range.end;
|
||||
|
||||
const range = Range.createFromPosition(start, end);
|
||||
editBuilder.replace(toVsCodeRange(range), definitions!.newText);
|
||||
const range = Range.createFromPosition(start, end);
|
||||
editBuilder.replace(toVsCodeRange(range), definition.newText);
|
||||
});
|
||||
}
|
||||
|
||||
if (heading) {
|
||||
|
||||
@@ -93,7 +93,7 @@ async function updateWikilinkDefinitions(
|
||||
}
|
||||
|
||||
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
|
||||
const update = await generateLinkReferences(
|
||||
const updates = await generateLinkReferences(
|
||||
resource,
|
||||
text,
|
||||
eol,
|
||||
@@ -101,12 +101,14 @@ async function updateWikilinkDefinitions(
|
||||
setting === 'withExtensions'
|
||||
);
|
||||
|
||||
if (update) {
|
||||
if (updates.length > 0) {
|
||||
await editor.edit(editBuilder => {
|
||||
const gap = doc.lineAt(update.range.start.line - 1).isEmptyOrWhitespace
|
||||
? ''
|
||||
: eol;
|
||||
editBuilder.replace(toVsCodeRange(update.range), gap + update.newText);
|
||||
updates.forEach(update => {
|
||||
const gap = doc.lineAt(update.range.start.line - 1).isEmptyOrWhitespace
|
||||
? ''
|
||||
: eol;
|
||||
editBuilder.replace(toVsCodeRange(update.range), gap + update.newText);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
@@ -107,7 +106,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
@@ -138,7 +136,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
|
||||
const resource2: Resource = {
|
||||
@@ -155,7 +152,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
|
||||
workspace.set(resource1);
|
||||
@@ -192,7 +188,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
@@ -221,7 +216,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ export const createTestWorkspace = (workspaceRoots: URI[] = []) => {
|
||||
export const createTestNote = (params: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
definitions?: NoteLinkDefinition[];
|
||||
links?: Array<{ slug: string } | { to: string }>;
|
||||
links?: Array<{ slug: string; definitionUrl?: string } | { to: string }>;
|
||||
tags?: string[];
|
||||
aliases?: string[];
|
||||
text?: string;
|
||||
@@ -63,7 +62,6 @@ export const createTestNote = (params: {
|
||||
type: params.type ?? 'note',
|
||||
properties: {},
|
||||
title: params.title ?? strToUri(params.uri).getBasename(),
|
||||
definitions: params.definitions ?? [],
|
||||
sections: params.sections?.map(label => ({
|
||||
label,
|
||||
range: Range.create(0, 0, 1, 0),
|
||||
@@ -92,6 +90,13 @@ export const createTestNote = (params: {
|
||||
range: range,
|
||||
rawText: `[[${link.slug}]]`,
|
||||
isEmbed: false,
|
||||
definition: link.definitionUrl
|
||||
? {
|
||||
label: link.slug,
|
||||
url: link.definitionUrl,
|
||||
range: Range.create(0, 0, 0, 0),
|
||||
}
|
||||
: link.slug,
|
||||
}
|
||||
: {
|
||||
type: 'link',
|
||||
|
||||
@@ -6,6 +6,4 @@ I also want a [[first-document]].
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkrefenrece]: https://foambubble.github.io/
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[first-document]: first-document 'First Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -6,6 +6,4 @@ Here's some [unrelated] content.
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[second-document]: second-document 'Second Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -4,6 +4,4 @@ This is just a link target for now.
|
||||
|
||||
We can use it for other things later if needed.
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[first-document]: first-document 'First Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
|
||||
[](#contributors-)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
@@ -186,9 +188,7 @@ You can also browse the [docs folder](https://github.com/foambubble/foam/tree/ma
|
||||
|
||||
Foam is licensed under the [MIT license](LICENSE).
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[Backlinking]: docs/user/features/backlinking.md 'Backlinking'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
## Contribution Guide
|
||||
|
||||
@@ -384,7 +384,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[Backlinking]: docs/user/features/backlinking.md "Backlinking"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[Backlinking]: docs/user/features/backlinking.md 'Backlinks'
|
||||
|
||||
Reference in New Issue
Block a user