Erez Shermer 2026-03-13 18:15:21 +02:00
parent fb81b9b5d8
commit c9edcc7124
4 changed files with 112 additions and 10 deletions

View file

@ -47,7 +47,9 @@ export default {
exportConditions: ['svelte'],
extensions: ['.mjs', '.js', '.json', '.node', '.ts', '.svelte'],
}),
typescript(),
typescript({
outDir: process.env.BUILD === 'development' ? '.' : './dist',
}),
image(),
commonjs(),
postcss({

View file

@ -1,11 +1,26 @@
const r = String.raw;
// Shared character class content for tag characters: unicode letters, numbers,
// emoji (including variation selectors like U+FE0F), and punctuation allowed in tags.
const TAG_CHARS = r`\p{L}\p{N}\p{Extended_Pictographic}\p{So}\uFE00-\uFE0F_\/\-`;
// A complete tag pattern used inside larger regexes (no '#', no wildcard)
const INLINE_TAG_PATTERN = r`tag:[${TAG_CHARS}]+(?:[\s,.]+|$)`;
// The pound sign is optional here
export const TAG_NAME_WITH_HEADER =
/tag:(#?[\p{L}\p{N}\p{Extended_Pictographic}\p{So}_\/\-]*)/gu;
export const TAG_NAME_WITH_HEADER = new RegExp(
r`tag:(#?[${TAG_CHARS}]*)`,
'gu',
);
// Same as above, but also supporting wildcards for query purposes (not used for inline tags)
export const TAG_NAME_WITH_HEADER_AND_WILDCARD =
/tag:(#?[\p{L}\p{N}\p{Extended_Pictographic}\p{So}_\/\-\*]*)/gu;
export const TAG_NAME_WITH_HEADER_AND_WILDCARD = new RegExp(
r`tag:(#?[${TAG_CHARS}\*]*)`,
'gu',
);
// Note no '#' sign
export const INLINE_TAG_IN_NOTE = /tag:(?<tag>[\p{L}\p{N}_\/\-]+)/gu;
export const INLINE_TAG_IN_NOTE = new RegExp(
r`tag:(?<tag>[${TAG_CHARS}]+)`,
'gu',
);
export const PATH = "['p{L}p{N}_,&()/-\\.]+?";
// path:"..."
export const PATH_QUERY_WITH_HEADER = /path:"((?:[^"]|\\")+?)"/gu;
@ -25,8 +40,10 @@ export const INLINE_LOCATION_OLD_SYNTAX =
/`location:\s*\[?(?<lat>[+-]?([0-9]*[.])?[0-9]+)\s*,\s*(?<lng>[+-]?([0-9]*[.])?[0-9]+)\]?/g;
// A link name is defined here as [^\]]* to prevent a previous link in the same line to count as the beginning
// of the link name
export const INLINE_LOCATION_WITH_TAGS =
/(?<link>\[(?<name>[^\]]*?)\]\(geo:(?<lat>[+-]?([0-9]*[.])?[0-9]+),(?<lng>[+-]?([0-9]*[.])?[0-9]+)\))[ \t]*(?<tags>(tag:[\p{L}\p{N}_\/\-]+(?:[\s,.]+|$))*)/gu;
export const INLINE_LOCATION_WITH_TAGS = new RegExp(
r`(?<link>\[(?<name>[^\]]*?)\]\(geo:(?<lat>[+-]?([0-9]*[.])?[0-9]+),(?<lng>[+-]?([0-9]*[.])?[0-9]+)\))[ \t]*(?<tags>(${INLINE_TAG_PATTERN})*)`,
'gu',
);
// Should be exactly like above but without the tags
export const INLINE_LOCATION_WITHOUT_TAGS =
/(?<link>\[(?<name>[^\]]*?)\]\(geo:(?<lat>[+-]?([0-9]*[.])?[0-9]+),(?<lng>[+-]?([0-9]*[.])?[0-9]+)\))/gu;
@ -38,5 +55,8 @@ export const FRONT_MATTER_LOCATION_V2 =
// location: [32.84577588420059,35.36074429750443]
export const FRONT_MATTER_LOCATION =
/(?<header>^---.*)(?<loc>location:[ \t]*\[(?<lat>[+-]?([0-9]*[.])?[0-9]+),(?<lng>[+-]?([0-9]*[.])?[0-9]+)\]).*^---/ms;
export const INLINE_GEOJSON =
/```geojson\n(?<content>[^`]*)```\s*\n?(?<tags>(tag:[\p{L}\p{N}_\/\-]+(?:[\s,.]+|$))*)/gu;
// Note: backtick (\x60) can't appear in a template literal, so we use \x60 (hex) to represent it.
export const INLINE_GEOJSON = new RegExp(
r`\x60\x60\x60geojson\n(?<content>[^\x60]*)\x60\x60\x60\s*\n?(?<tags>(${INLINE_TAG_PATTERN})*)`,
'gu',
);

View file

@ -215,4 +215,30 @@ describe('Query.testLayer', () => {
const q = new Query(null as any, 'tag:#trip*');
expect(q.testLayer(mockLayer({ tags: ['#trip-🏕️'] }))).toBe(true);
});
// Emoji tags with variation selectors (U+FE0F) — issue #385
// These emojis are two code points: base emoji + variation selector-16 ().
// The query preprocessor regex must include variation selectors, otherwise the
// variation selector ends up outside the quoted token and crashes boon-js.
it('does not crash when constructing query with emoji+variation-selector tag (☕️)', () => {
expect(() => new Query(null as any, 'tag:#☕️')).not.toThrow();
});
it('does not crash when constructing query with emoji+variation-selector tag (⚽️)', () => {
expect(() => new Query(null as any, 'tag:#⚽️')).not.toThrow();
});
it('does not crash when constructing query with emoji+variation-selector tag (🏔️)', () => {
expect(() => new Query(null as any, 'tag:#🏔️')).not.toThrow();
});
it('emoji+variation-selector tag query matches layer with that tag', () => {
const q = new Query(null as any, 'tag:#☕️');
expect(q.testLayer(mockLayer({ tags: ['#☕️'] }))).toBe(true);
});
it('emoji+variation-selector tag query does not match layer without that tag', () => {
const q = new Query(null as any, 'tag:#☕️');
expect(q.testLayer(mockLayer({ tags: ['#🍵'] }))).toBe(false);
});
});

View file

@ -150,6 +150,21 @@ describe('TAG_NAME_WITH_HEADER', () => {
expect(matches[0][1]).toBe('#🌍');
});
it('captures emoji tag with variation selector (e.g. ☕️)', () => {
// ☕️ = U+2615 + U+FE0F (variation selector-16)
const input = 'tag:#☕️';
const matches = [...input.matchAll(regex.TAG_NAME_WITH_HEADER)];
expect(matches).toHaveLength(1);
expect(matches[0][1]).toBe('#☕️');
});
it('captures emoji tag with variation selector (e.g. ⚽️)', () => {
const input = 'tag:#⚽️';
const matches = [...input.matchAll(regex.TAG_NAME_WITH_HEADER)];
expect(matches).toHaveLength(1);
expect(matches[0][1]).toBe('#⚽️');
});
it('captures accented tag', () => {
const input = 'tag:#café';
const matches = [...input.matchAll(regex.TAG_NAME_WITH_HEADER)];
@ -167,6 +182,15 @@ describe('TAG_NAME_WITH_HEADER_AND_WILDCARD', () => {
expect(matches).toHaveLength(1);
expect(matches[0][1]).toBe('#trip*');
});
it('captures emoji tag with variation selector', () => {
const input = 'tag:#☕️';
const matches = [
...input.matchAll(regex.TAG_NAME_WITH_HEADER_AND_WILDCARD),
];
expect(matches).toHaveLength(1);
expect(matches[0][1]).toBe('#☕️');
});
});
describe('PATH_QUERY_WITH_HEADER', () => {
@ -192,4 +216,34 @@ describe('INLINE_TAG_IN_NOTE', () => {
expect(matches).toHaveLength(1);
expect(matches[0].groups?.tag).toBe('旅行');
});
it('captures an emoji tag (no variation selector)', () => {
const input = 'tag:🌍';
const matches = [...input.matchAll(regex.INLINE_TAG_IN_NOTE)];
expect(matches).toHaveLength(1);
expect(matches[0].groups?.tag).toBe('🌍');
});
it('captures an emoji tag with variation selector (e.g. ☕️)', () => {
const input = 'tag:☕️';
const matches = [...input.matchAll(regex.INLINE_TAG_IN_NOTE)];
expect(matches).toHaveLength(1);
expect(matches[0].groups?.tag).toBe('☕️');
});
});
describe('INLINE_LOCATION_WITH_TAGS emoji tags', () => {
it('extracts emoji tag without variation selector', () => {
const input = '[Café](geo:32.1,35.2) tag:🌍';
const matches = [...input.matchAll(regex.INLINE_LOCATION_WITH_TAGS)];
expect(matches).toHaveLength(1);
expect(matches[0].groups?.tags).toContain('tag:🌍');
});
it('extracts emoji tag with variation selector (e.g. ☕️)', () => {
const input = '[Café](geo:32.1,35.2) tag:☕️';
const matches = [...input.matchAll(regex.INLINE_LOCATION_WITH_TAGS)];
expect(matches).toHaveLength(1);
expect(matches[0].groups?.tags).toContain('tag:☕️');
});
});