Allow tags to start with a number and be escaped with angle brackets (#1116)
Includes tests and Markdown/Hashtags page for website. Closes #1072pull/1127/head
parent
f37cf23ba7
commit
010e2b23f4
|
@ -1,5 +1,5 @@
|
||||||
export const wikiLinkRegex = /(!?\[\[)([^\]\|]+)(?:\|([^\]]+))?(\]\])/g; // [fullMatch, firstMark, url, alias, lastMark]
|
export const wikiLinkRegex = /(!?\[\[)([^\]\|]+)(?:\|([^\]]+))?(\]\])/g; // [fullMatch, firstMark, url, alias, lastMark]
|
||||||
export const mdLinkRegex = /!?\[(?<title>[^\]]*)\]\((?<url>.+)\)/g; // [fullMatch, alias, url]
|
export const mdLinkRegex = /!?\[(?<title>[^\]]*)\]\((?<url>.+)\)/g; // [fullMatch, alias, url]
|
||||||
export const tagRegex =
|
export const tagRegex =
|
||||||
/#[^\d\s!@#$%^&*(),.?":{}|<>\\][^\s!@#$%^&*(),.?":{}|<>\\]*/;
|
/#(?:(?:\d*[^\d\s!@#$%^&*(),.?":{}|<>\\][^\s!@#$%^&*(),.?":{}|<>\\]*)|(?:<[^>\n]+>))/;
|
||||||
export const pWikiLinkRegex = new RegExp("^" + wikiLinkRegex.source); // Modified regex used only in parser
|
export const pWikiLinkRegex = new RegExp("^" + wikiLinkRegex.source); // Modified regex used only in parser
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
} from "@silverbulletmd/silverbullet/lib/tree";
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
import { assert, assertEquals, assertNotEquals } from "@std/assert";
|
import { assert, assertEquals, assertNotEquals } from "@std/assert";
|
||||||
import { parseMarkdown } from "./parser.ts";
|
import { parseMarkdown } from "./parser.ts";
|
||||||
|
import { extractHashtag, renderHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
|
|
||||||
const sample1 = `---
|
const sample1 = `---
|
||||||
type: page
|
type: page
|
||||||
|
@ -162,3 +163,56 @@ Deno.test("Test lua directive parser", () => {
|
||||||
const simpleExample = `Simple \${{a=}}`;
|
const simpleExample = `Simple \${{a=}}`;
|
||||||
console.log(JSON.stringify(parseMarkdown(simpleExample), null, 2));
|
console.log(JSON.stringify(parseMarkdown(simpleExample), null, 2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hashtagSample = `
|
||||||
|
Hashtags, e.g. #mytag but ignore in code \`#mytag\`.
|
||||||
|
They can contain slashes like #level/beginner, single quotes, and dashes: #Mike's-idea.
|
||||||
|
Can be just #a single letter.
|
||||||
|
But no other #interpunction: #exclamation! #question?
|
||||||
|
There is a way to write #<tag with spaces>
|
||||||
|
These cannot span #<multiple
|
||||||
|
lines>
|
||||||
|
#no#spacing also works.
|
||||||
|
Hashtags can start with number if there's something after it: #3dprint #15-52_Trip-to-NYC.
|
||||||
|
But magazine issue #1 or #123 are not hashtags.
|
||||||
|
Should support other languages, like #żółć or #井号
|
||||||
|
`;
|
||||||
|
|
||||||
|
Deno.test("Test hashtag parser", () => {
|
||||||
|
const tree = parseMarkdown(hashtagSample);
|
||||||
|
const hashtags = collectNodesOfType(tree, "Hashtag");
|
||||||
|
assertEquals(hashtags.length, 14);
|
||||||
|
|
||||||
|
assertEquals(hashtags[0].children![0].text, "#mytag");
|
||||||
|
assertEquals(hashtags[1].children![0].text, "#level/beginner");
|
||||||
|
assertEquals(hashtags[2].children![0].text, "#Mike's-idea");
|
||||||
|
assertEquals(hashtags[3].children![0].text, "#a");
|
||||||
|
assertEquals(hashtags[4].children![0].text, "#interpunction");
|
||||||
|
assertEquals(hashtags[5].children![0].text, "#exclamation");
|
||||||
|
assertEquals(hashtags[6].children![0].text, "#question");
|
||||||
|
assertEquals(hashtags[7].children![0].text, "#<tag with spaces>");
|
||||||
|
// multiple lines not allowed
|
||||||
|
assertEquals(hashtags[8].children![0].text, "#no");
|
||||||
|
assertEquals(hashtags[9].children![0].text, "#spacing");
|
||||||
|
assertEquals(hashtags[10].children![0].text, "#3dprint");
|
||||||
|
assertEquals(hashtags[11].children![0].text, "#15-52_Trip-to-NYC");
|
||||||
|
assertEquals(hashtags[12].children![0].text, "#żółć");
|
||||||
|
assertEquals(hashtags[13].children![0].text, "#井号");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Test hashtag helper functions", () => {
|
||||||
|
assertEquals(extractHashtag("#name"), "name");
|
||||||
|
assertEquals(extractHashtag("#123-content"), "123-content");
|
||||||
|
assertEquals(extractHashtag("#<escaped tag>"), "escaped tag");
|
||||||
|
assertEquals(
|
||||||
|
extractHashtag("#<allow < and # inside>"),
|
||||||
|
"allow < and # inside",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(renderHashtag("simple"), "#simple");
|
||||||
|
assertEquals(renderHashtag("123-content"), "#123-content");
|
||||||
|
assertEquals(renderHashtag("with spaces"), "#<with spaces>");
|
||||||
|
assertEquals(renderHashtag("single'quote"), "#single'quote");
|
||||||
|
// should behave like this for all characters in tagRegex
|
||||||
|
assertEquals(renderHashtag("exclamation!"), "#<exclamation!>");
|
||||||
|
});
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "./tree.ts";
|
} from "./tree.ts";
|
||||||
import { cleanupJSON } from "./json.ts";
|
import { cleanupJSON } from "./json.ts";
|
||||||
import { YAML } from "../syscalls.ts";
|
import { YAML } from "../syscalls.ts";
|
||||||
|
import { extractHashtag } from "./tags.ts";
|
||||||
|
|
||||||
export type FrontMatter = { tags?: string[] } & Record<string, any>;
|
export type FrontMatter = { tags?: string[] } & Record<string, any>;
|
||||||
|
|
||||||
|
@ -48,7 +49,7 @@ export async function extractFrontmatter(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (child.type === "Hashtag") {
|
} else if (child.type === "Hashtag") {
|
||||||
const tagname = child.children![0].text!.substring(1);
|
const tagname = extractHashtag(child.children![0].text!);
|
||||||
collectedTags.add(tagname);
|
collectedTags.add(tagname);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
type ParseTree,
|
type ParseTree,
|
||||||
traverseTree,
|
traverseTree,
|
||||||
} from "@silverbulletmd/silverbullet/lib/tree";
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
|
import { tagRegex } from "$common/markdown_parser/constants.ts";
|
||||||
|
|
||||||
export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
|
export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
|
||||||
const itags = [obj.tag, ...frontmatter.tags || []];
|
const itags = [obj.tag, ...frontmatter.tags || []];
|
||||||
|
@ -26,7 +27,7 @@ export function extractHashTags(n: ParseTree): string[] {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
traverseTree(n, (n) => {
|
traverseTree(n, (n) => {
|
||||||
if (n.type === "Hashtag") {
|
if (n.type === "Hashtag") {
|
||||||
tags.add(n.children![0].text!.substring(1));
|
tags.add(extractHashtag(n.children![0].text!));
|
||||||
return true;
|
return true;
|
||||||
} else if (n.type === "OrderedList" || n.type === "BulletList") {
|
} else if (n.type === "OrderedList" || n.type === "BulletList") {
|
||||||
// Don't traverse into sub-lists
|
// Don't traverse into sub-lists
|
||||||
|
@ -37,6 +38,32 @@ export function extractHashTags(n: ParseTree): string[] {
|
||||||
return [...tags];
|
return [...tags];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract the name from hashtag text, removing # prefix and <angle brackets> if necessary */
|
||||||
|
export function extractHashtag(text: string): string {
|
||||||
|
if (text[0] !== "#") { // you shouldn't call this function at all
|
||||||
|
console.error("extractHashtag called on already clean string", text);
|
||||||
|
return text;
|
||||||
|
} else if (text[1] === "<") {
|
||||||
|
if (text.slice(-1) !== ">") { // this is malformed: #<name but maybe we're trying to autocomplete
|
||||||
|
return text.slice(2);
|
||||||
|
} else { // this is correct #<name>
|
||||||
|
return text.slice(2, -1);
|
||||||
|
}
|
||||||
|
} else { // this is just #name
|
||||||
|
return text.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get markup for a hashtag name with # prefix and angle brackets if necessary */
|
||||||
|
export function renderHashtag(name: string): string {
|
||||||
|
// detect with the same regex as the parser
|
||||||
|
const simple: string = "#" + name;
|
||||||
|
const match = simple.match(tagRegex);
|
||||||
|
if (!match || match[0].length !== simple.length) {
|
||||||
|
return `#<${name}>`;
|
||||||
|
} else return simple;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans hashtags from a tree as a side effect
|
* Cleans hashtags from a tree as a side effect
|
||||||
* @param n
|
* @param n
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ClickEvent } from "../../plug-api/types.ts";
|
import type { ClickEvent } from "../../plug-api/types.ts";
|
||||||
|
import { extractHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
import {
|
import {
|
||||||
editor,
|
editor,
|
||||||
markdown,
|
markdown,
|
||||||
|
@ -123,7 +124,7 @@ async function actionClickOrActionEnter(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "Hashtag": {
|
case "Hashtag": {
|
||||||
const hashtag = mdTree.children![0].text!.slice(1);
|
const hashtag = extractHashtag(mdTree.children![0].text!);
|
||||||
await editor.navigate(
|
await editor.navigate(
|
||||||
{ page: `${tagPrefix}${hashtag}`, pos: 0 },
|
{ page: `${tagPrefix}${hashtag}`, pos: 0 },
|
||||||
false,
|
false,
|
||||||
|
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
import { indexObjects, queryObjects } from "./api.ts";
|
import { indexObjects, queryObjects } from "./api.ts";
|
||||||
import { parsePageRef } from "@silverbulletmd/silverbullet/lib/page_ref";
|
import { parsePageRef } from "@silverbulletmd/silverbullet/lib/page_ref";
|
||||||
import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute";
|
import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute";
|
||||||
|
import { extractHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
|
|
||||||
type HeaderObject = ObjectValue<
|
type HeaderObject = ObjectValue<
|
||||||
{
|
{
|
||||||
|
@ -35,7 +36,7 @@ export async function indexHeaders({ name: pageName, tree }: IndexTreeEvent) {
|
||||||
|
|
||||||
collectNodesOfType(n, "Hashtag").forEach((h) => {
|
collectNodesOfType(n, "Hashtag").forEach((h) => {
|
||||||
// Push tag to the list, removing the initial #
|
// Push tag to the list, removing the initial #
|
||||||
tags.add(h.children![0].text!.substring(1));
|
tags.add(extractHashtag(h.children![0].text!));
|
||||||
h.children = [];
|
h.children = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute";
|
||||||
import type { ObjectValue } from "../../plug-api/types.ts";
|
import type { ObjectValue } from "../../plug-api/types.ts";
|
||||||
import { updateITags } from "@silverbulletmd/silverbullet/lib/tags";
|
import { updateITags } from "@silverbulletmd/silverbullet/lib/tags";
|
||||||
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
||||||
|
import { extractHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
|
|
||||||
/** ParagraphObject An index object for the top level text nodes */
|
/** ParagraphObject An index object for the top level text nodes */
|
||||||
export type ParagraphObject = ObjectValue<
|
export type ParagraphObject = ObjectValue<
|
||||||
|
@ -40,7 +41,7 @@ export async function indexParagraphs({ name: page, tree }: IndexTreeEvent) {
|
||||||
// Collect tags and remove from the tree
|
// Collect tags and remove from the tree
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
collectNodesOfType(p, "Hashtag").forEach((tagNode) => {
|
collectNodesOfType(p, "Hashtag").forEach((tagNode) => {
|
||||||
tags.add(tagNode.children![0].text!.substring(1));
|
tags.add(extractHashtag(tagNode.children![0].text!));
|
||||||
// Hacky way to remove the hashtag
|
// Hacky way to remove the hashtag
|
||||||
tagNode.children = [];
|
tagNode.children = [];
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { IndexTreeEvent, ObjectValue } from "../../plug-api/types.ts";
|
import type { IndexTreeEvent, ObjectValue } from "../../plug-api/types.ts";
|
||||||
|
import { extractHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
import {
|
import {
|
||||||
collectNodesMatching,
|
collectNodesMatching,
|
||||||
collectNodesOfType,
|
collectNodesOfType,
|
||||||
|
@ -51,7 +52,7 @@ export async function indexTables({ name: pageName, tree }: IndexTreeEvent) {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
collectNodesOfType(row, "Hashtag").forEach((h) => {
|
collectNodesOfType(row, "Hashtag").forEach((h) => {
|
||||||
// Push tag to the list, removing the initial #
|
// Push tag to the list, removing the initial #
|
||||||
tags.add(h.children![0].text!.substring(1));
|
tags.add(extractHashtag(h.children![0].text!));
|
||||||
});
|
});
|
||||||
|
|
||||||
const cells = collectNodesOfType(row, "TableCell");
|
const cells = collectNodesOfType(row, "TableCell");
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { FileMeta } from "../../plug-api/types.ts";
|
import type { FileMeta } from "../../plug-api/types.ts";
|
||||||
|
import { renderHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
import { markdown, system } from "@silverbulletmd/silverbullet/syscalls";
|
import { markdown, system } from "@silverbulletmd/silverbullet/syscalls";
|
||||||
import { renderToText } from "@silverbulletmd/silverbullet/lib/tree";
|
import { renderToText } from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
import { tagPrefix } from "./constants.ts";
|
import { tagPrefix } from "./constants.ts";
|
||||||
|
@ -10,7 +11,7 @@ export async function readFileTag(
|
||||||
tagPrefix.length,
|
tagPrefix.length,
|
||||||
name.length - ".md".length,
|
name.length - ".md".length,
|
||||||
);
|
);
|
||||||
const text = `All objects in your space tagged with #${tagName}:
|
const text = `All objects in your space tagged with ${renderHashtag(tagName)}:
|
||||||
\`\`\`template
|
\`\`\`template
|
||||||
template: |
|
template: |
|
||||||
{{#if .}}
|
{{#if .}}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import type { CompleteEvent, IndexTreeEvent } from "../../plug-api/types.ts";
|
import type {
|
||||||
|
CompleteEvent,
|
||||||
|
IndexTreeEvent,
|
||||||
|
ObjectValue,
|
||||||
|
} from "../../plug-api/types.ts";
|
||||||
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
||||||
import { indexObjects, queryObjects } from "./api.ts";
|
import { indexObjects, queryObjects } from "./api.ts";
|
||||||
import {
|
import {
|
||||||
|
@ -6,7 +10,7 @@ import {
|
||||||
collectNodesOfType,
|
collectNodesOfType,
|
||||||
findParentMatching,
|
findParentMatching,
|
||||||
} from "@silverbulletmd/silverbullet/lib/tree";
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
||||||
import type { ObjectValue } from "../../plug-api/types.ts";
|
import { extractHashtag, renderHashtag } from "../../plug-api/lib/tags.ts";
|
||||||
|
|
||||||
export type TagObject = ObjectValue<{
|
export type TagObject = ObjectValue<{
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -22,7 +26,7 @@ export async function indexTags({ name, tree }: IndexTreeEvent) {
|
||||||
tags.add(`${pageTag}:page`);
|
tags.add(`${pageTag}:page`);
|
||||||
}
|
}
|
||||||
collectNodesOfType(tree, "Hashtag").forEach((h) => {
|
collectNodesOfType(tree, "Hashtag").forEach((h) => {
|
||||||
const tagName = h.children![0].text!.substring(1);
|
const tagName = extractHashtag(h.children![0].text!);
|
||||||
// Check if this occurs in the context of a task
|
// Check if this occurs in the context of a task
|
||||||
if (findParentMatching(h, (n) => n.type === "Task")) {
|
if (findParentMatching(h, (n) => n.type === "Task")) {
|
||||||
tags.add(`${tagName}:task`);
|
tags.add(`${tagName}:task`);
|
||||||
|
@ -58,11 +62,10 @@ export async function tagComplete(completeEvent: CompleteEvent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = /#[^#\d\s\[\]]+\w*$/.exec(completeEvent.linePrefix);
|
const match = /#[^#\s\[\]]+\w*$/.exec(completeEvent.linePrefix);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const tagPrefix = match[0].substring(1);
|
|
||||||
|
|
||||||
// Query all tags with a matching parent
|
// Query all tags with a matching parent
|
||||||
const allTags: any[] = await queryObjects<TagObject>("tag", {
|
const allTags: any[] = await queryObjects<TagObject>("tag", {
|
||||||
|
@ -71,9 +74,9 @@ export async function tagComplete(completeEvent: CompleteEvent) {
|
||||||
}, 5);
|
}, 5);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: completeEvent.pos - tagPrefix.length,
|
from: completeEvent.pos - match[0].length,
|
||||||
options: allTags.map((tag) => ({
|
options: allTags.map((tag) => ({
|
||||||
label: tag.name,
|
label: renderHashtag(tag.name),
|
||||||
type: "tag",
|
type: "tag",
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,7 +10,7 @@ In addition to supporting [[Markdown/Basics|markdown basics]] as standardized by
|
||||||
* [[Transclusions]] syntax
|
* [[Transclusions]] syntax
|
||||||
* [[Markdown/Anchors]]
|
* [[Markdown/Anchors]]
|
||||||
* [[Markdown/Admonitions]]
|
* [[Markdown/Admonitions]]
|
||||||
* Hashtags, e.g. `#mytag`.
|
* [[Markdown/Hashtags]]
|
||||||
* [[Markdown/Command links]] syntax
|
* [[Markdown/Command links]] syntax
|
||||||
* [Tables](https://www.markdownguide.org/extended-syntax/#tables)
|
* [Tables](https://www.markdownguide.org/extended-syntax/#tables)
|
||||||
* [Task lists](https://www.markdownguide.org/extended-syntax/#task-lists)
|
* [Task lists](https://www.markdownguide.org/extended-syntax/#task-lists)
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
#level/beginner
|
||||||
|
|
||||||
|
These can be used in text to assign an [[Objects#tag]]. If hashtags are the only content of first paragraph, they are applied to the entire page.
|
||||||
|
|
||||||
|
Hashtags can contain letters, dashes, underscores and other characters, but not:
|
||||||
|
- Whitespace (space, newline etc.)
|
||||||
|
- Characters from this list: `!@#$%^&*(),.?":{}|<>\`
|
||||||
|
- Consist of digits only like #123
|
||||||
|
|
||||||
|
If you need your tags to contain these characters, you have to surround the tag content with angle brackets like this: #<my tag>
|
||||||
|
|
||||||
|
```query
|
||||||
|
tag where page = @page.name
|
||||||
|
```
|
Loading…
Reference in New Issue