diff --git a/rollup.config.js b/rollup.config.js index b03d476..b68e615 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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({ diff --git a/src/regex.ts b/src/regex.ts index 51edecb..7d58cd5 100644 --- a/src/regex.ts +++ b/src/regex.ts @@ -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:(?[\p{L}\p{N}_\/\-]+)/gu; +export const INLINE_TAG_IN_NOTE = new RegExp( + r`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*\[?(?[+-]?([0-9]*[.])?[0-9]+)\s*,\s*(?[+-]?([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 = - /(?\[(?[^\]]*?)\]\(geo:(?[+-]?([0-9]*[.])?[0-9]+),(?[+-]?([0-9]*[.])?[0-9]+)\))[ \t]*(?(tag:[\p{L}\p{N}_\/\-]+(?:[\s,.]+|$))*)/gu; +export const INLINE_LOCATION_WITH_TAGS = new RegExp( + r`(?\[(?[^\]]*?)\]\(geo:(?[+-]?([0-9]*[.])?[0-9]+),(?[+-]?([0-9]*[.])?[0-9]+)\))[ \t]*(?(${INLINE_TAG_PATTERN})*)`, + 'gu', +); // Should be exactly like above but without the tags export const INLINE_LOCATION_WITHOUT_TAGS = /(?\[(?[^\]]*?)\]\(geo:(?[+-]?([0-9]*[.])?[0-9]+),(?[+-]?([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 = /(?
^---.*)(?location:[ \t]*\[(?[+-]?([0-9]*[.])?[0-9]+),(?[+-]?([0-9]*[.])?[0-9]+)\]).*^---/ms; -export const INLINE_GEOJSON = - /```geojson\n(?[^`]*)```\s*\n?(?(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(?[^\x60]*)\x60\x60\x60\s*\n?(?(${INLINE_TAG_PATTERN})*)`, + 'gu', +); diff --git a/tests/query.test.ts b/tests/query.test.ts index a4a42cd..a777e73 100644 --- a/tests/query.test.ts +++ b/tests/query.test.ts @@ -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); + }); }); diff --git a/tests/regex.test.ts b/tests/regex.test.ts index 6ed3f25..6d0f641 100644 --- a/tests/regex.test.ts +++ b/tests/regex.test.ts @@ -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:☕️'); + }); });