From d6d9ff6ed6beb41a9ac1a35426131cfb1081a039 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Thu, 8 Oct 2026 02:17:02 +0200 Subject: [PATCH] Improve normalization performance and fix double entity decoding. --- package.json | 2 +- packages/purestack/package.json | 2 +- packages/purestack/src/index.ts | 2 +- packages/ts-common/package.json | 2 +- packages/ts-components/package.json | 2 +- .../standard/composer/emailHtmlPolicy.test.ts | 30 ++++++++++++++ .../src/standard/composer/emailHtmlPolicy.ts | 5 ++- packages/ts-css/package.json | 2 +- packages/ts-css/src/hyphenize.test.ts | 8 ++++ packages/ts-css/src/hyphenize.ts | 2 +- packages/ts-html/package.json | 2 +- packages/ts-minidom/package.json | 2 +- packages/ts-minidom/src/minidom.test.ts | 20 +++++++++ packages/ts-minidom/src/minidom.ts | 32 +++++++++------ packages/ts-page-scripts/package.json | 2 +- packages/ts-render/package.json | 2 +- packages/ts-ssg-vscode/package.json | 2 +- packages/ts-ssg/package.json | 2 +- packages/ts-ssg/src/config/config.test.ts | 22 ++++++++++ packages/ts-ssg/src/config/config.ts | 5 ++- packages/ts-ssg/src/config/head.ts | 2 +- .../ts-ssg/src/navigation/nav-file.test.ts | 41 +++++++++++++++++++ packages/ts-ssg/src/navigation/nav-file.ts | 12 +++++- packages/ts-style/package.json | 2 +- packages/ts-svg-icons/package.json | 2 +- packages/ts-util-node/package.json | 2 +- packages/ts-util/package.json | 2 +- 27 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 packages/ts-components/src/standard/composer/emailHtmlPolicy.test.ts create mode 100644 packages/ts-ssg/src/navigation/nav-file.test.ts diff --git a/package.json b/package.json index a70e58c3..1226f1c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index dc57a4a2..7a9cff36 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 7325cd5b..de6775e8 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.7' +export const version: string = '1.1.8' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index 131ce641..3f450c88 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index bf09d6ba..e1c5a068 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-components/src/standard/composer/emailHtmlPolicy.test.ts b/packages/ts-components/src/standard/composer/emailHtmlPolicy.test.ts new file mode 100644 index 00000000..1ca81023 --- /dev/null +++ b/packages/ts-components/src/standard/composer/emailHtmlPolicy.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeEmailStyleValue } from './emailHtmlPolicy' + +describe('sanitizeEmailStyleValue', () => { + it.each([ + [' red ', 'red'], + ['red!important', 'red'], + [' red \t!IMPORTANT \n', 'red'], + ['red !important extra', 'red !important extra'], + ['red ! important', 'red ! important'], + ['!important', undefined], + ])('normalizes %j to %j', (value, expected) => { + expect(sanitizeEmailStyleValue('color', value)).toBe(expected) + }) + + it('rejects oversized values containing long internal whitespace', () => { + const value = `red${' '.repeat(200_000)}x` + expect(sanitizeEmailStyleValue('color', value)).toBeUndefined() + }) + + it('applies the length limit after removing the important suffix', () => { + const value = 'a'.repeat(512) + expect(sanitizeEmailStyleValue('font-family', `${value} !important`)).toBe( + value, + ) + expect( + sanitizeEmailStyleValue('font-family', `${value}a !important`), + ).toBeUndefined() + }) +}) diff --git a/packages/ts-components/src/standard/composer/emailHtmlPolicy.ts b/packages/ts-components/src/standard/composer/emailHtmlPolicy.ts index a8191486..f5399b79 100644 --- a/packages/ts-components/src/standard/composer/emailHtmlPolicy.ts +++ b/packages/ts-components/src/standard/composer/emailHtmlPolicy.ts @@ -432,7 +432,10 @@ export function normalizeEmailContentId(value: string) { } function stripImportant(value: string) { - return value.replace(/\s*!important\s*$/i, '').trim() + const trimmed = value.trim() + return trimmed.slice(-10).toLowerCase() === '!important' + ? trimmed.slice(0, -10).trimEnd() + : trimmed } function isSafeCssValue(value: string) { diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index 66afc97b..e70f8308 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-css/src/hyphenize.test.ts b/packages/ts-css/src/hyphenize.test.ts index 7d7e9f7e..e0da6ba6 100644 --- a/packages/ts-css/src/hyphenize.test.ts +++ b/packages/ts-css/src/hyphenize.test.ts @@ -32,6 +32,14 @@ describe('hyphenizeCss', () => { expect(hyphenizeCss('ID')).toBe('id') // all uppercase, no hyphen }) + it('handles long uppercase runs with and without an acronym boundary', () => { + const acronym = 'A'.repeat(200_000) + expect(hyphenizeCss(acronym)).toBe(acronym.toLowerCase()) + expect(hyphenizeCss(`${acronym}Value`)).toBe( + `${acronym.toLowerCase()}-value`, + ) + }) + it('preserves existing kebab-case / hyphens', () => { expect(hyphenizeCss('font-size')).toBe('font-size') expect(hyphenizeCss('-moz-appearance')).toBe('-moz-appearance') diff --git a/packages/ts-css/src/hyphenize.ts b/packages/ts-css/src/hyphenize.ts index dd61006a..16680669 100644 --- a/packages/ts-css/src/hyphenize.ts +++ b/packages/ts-css/src/hyphenize.ts @@ -36,7 +36,7 @@ export function hyphenizeCss(prop: string): string { // 1. Split acronym boundaries like "XMLHttp" → "XML-Http" // 2. Then split camelCase boundaries like "fooBar" → "foo-Bar" const withSeparators = withOpera - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') .replace(/([a-z0-9])([A-Z])/g, '$1-$2') return withSeparators.toLowerCase() diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index 0668f6d5..7ea4e78b 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index 3f77678e..33acf4a3 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-minidom/src/minidom.test.ts b/packages/ts-minidom/src/minidom.test.ts index b3fbf8ba..c0ba6c58 100644 --- a/packages/ts-minidom/src/minidom.test.ts +++ b/packages/ts-minidom/src/minidom.test.ts @@ -2,6 +2,26 @@ import { describe, expect, it } from 'vitest' import { parseFragment } from './minidom' describe('parseFragment', () => { + it.each([ + ['&lt;', '<'], + ['&lt;', '<'], + ['&#60;', '<'], + ['&#x3c;', '<'], + ['&quot;', '"'], + ['&apos;', '''], + ['<>&"'', '<>&"\''], + ['<>😀', '<>😀'], + ['< >', '< >'], + ['�', '\uFFFD'], + ['&unknown;', '&unknown;'], + ])('decodes entities once in text and attributes: %j', (input, expected) => { + const fragment = parseFragment(`

${input}

`) + const paragraph = fragment.firstElementChild + expect(paragraph?.textContent).toBe(expected) + expect(paragraph?.getAttribute('title')).toBe(expected) + expect(paragraph?.children.length).toBe(0) + }) + it('throws when a duplicate attribute would hide meaningful information', () => { expect(() => parseFragment('
'), diff --git a/packages/ts-minidom/src/minidom.ts b/packages/ts-minidom/src/minidom.ts index b60c3773..33d5846f 100644 --- a/packages/ts-minidom/src/minidom.ts +++ b/packages/ts-minidom/src/minidom.ts @@ -1722,18 +1722,26 @@ function decodeEntities(value: string) { if (code < 0 || code > 0x10ffff) return '\uFFFD' return String.fromCodePoint(code) } - return value - .replace(/&#x([0-9a-fA-F]+);?/g, (_, hex: string) => - safeCodePoint(Number.parseInt(hex, 16)), - ) - .replace(/&#([0-9]+);?/g, (_, dec: string) => - safeCodePoint(Number.parseInt(dec, 10)), - ) - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") + const namedEntities: Record = { + lt: '<', + gt: '>', + amp: '&', + quot: '"', + apos: "'", + } + return value.replace( + /&#(?:x([0-9a-fA-F]+)|([0-9]+));?|&(lt|gt|amp|quot|apos);/g, + ( + match: string, + hex: string | undefined, + dec: string | undefined, + named: string | undefined, + ) => { + if (hex !== undefined) return safeCodePoint(Number.parseInt(hex, 16)) + if (dec !== undefined) return safeCodePoint(Number.parseInt(dec, 10)) + return namedEntities[named ?? ''] ?? match + }, + ) } function escapeText(value: string) { diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index 62e3359f..31c3466b 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index 26136727..591cc738 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index a8470808..bba63be6 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.7", + "version": "1.1.8", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index f35a5669..20b6e877 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", diff --git a/packages/ts-ssg/src/config/config.test.ts b/packages/ts-ssg/src/config/config.test.ts index 8db1df02..e809a6a6 100644 --- a/packages/ts-ssg/src/config/config.test.ts +++ b/packages/ts-ssg/src/config/config.test.ts @@ -431,6 +431,28 @@ describe('resolveSiteConfig sitemap', () => { expect(config.sitemap.robots.allow).toEqual(['/']) }) + it.each([ + [' https://docs.example.com/// ', 'https://docs.example.com'], + ['', ''], + [' ', ''], + ['///', ''], + ['https://docs.example.com/path', 'https://docs.example.com/path'], + [ + `https://docs.example.com/${'/'.repeat(200_000)}x`, + `https://docs.example.com/${'/'.repeat(200_000)}x`, + ], + [ + `https://docs.example.com${'/'.repeat(200_000)}`, + 'https://docs.example.com', + ], + ])('normalizes sitemap base URL case %#', (baseUrl, expected) => { + const config = resolveSiteConfig({ + rootDir: process.cwd(), + sitemap: { baseUrl }, + }) + expect(config.sitemap.baseUrl).toBe(expected) + }) + it('resolves robots directives from sitemap config', () => { const config = resolveSiteConfig({ rootDir: process.cwd(), diff --git a/packages/ts-ssg/src/config/config.ts b/packages/ts-ssg/src/config/config.ts index 7ccbdd97..dcbf5e23 100644 --- a/packages/ts-ssg/src/config/config.ts +++ b/packages/ts-ssg/src/config/config.ts @@ -825,8 +825,9 @@ function resolveCrossOrigin( function normalizeBaseUrl(value: string): string { const normalized = value.trim() - if (normalized.length === 0) return '' - return normalized.replace(/\/+$/, '') + let end = normalized.length + while (end > 0 && normalized[end - 1] === '/') --end + return normalized.slice(0, end) } function resolveRobotsConfig( diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 74fed785..8b9d3bed 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -25,7 +25,7 @@ export function getHead(config?: BasicHeadConfig) { const head = createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', - content: 'PureStack v1.1.7', + content: 'PureStack v1.1.8', }), h('meta').attr({ name: 'color-scheme', diff --git a/packages/ts-ssg/src/navigation/nav-file.test.ts b/packages/ts-ssg/src/navigation/nav-file.test.ts new file mode 100644 index 00000000..7ccc7045 --- /dev/null +++ b/packages/ts-ssg/src/navigation/nav-file.test.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveNavigationConfig } from './config' +import { loadCustomNavigation } from './nav-file' + +describe('navigation file roots', () => { + afterEach(() => vi.restoreAllMocks()) + + it.each([ + [' ///guide/// ', 'guide'], + ['reference/', 'docs/reference'], + ['reference\\nested\\', 'docs/reference/nested'], + ['///', '.'], + ['.', 'docs'], + ['', undefined], + [`guide${'/'.repeat(200_000)}x`, 'docs/guide/x'], + [`${'/'.repeat(200_000)}guide${'/'.repeat(200_000)}`, 'guide'], + ])('normalizes root case %#', async (root, expected) => { + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ root })) + const navigation = await loadCustomNavigation( + 'content', + new Set(['docs']), + resolveNavigationConfig(), + ) + expect(navigation.docs.root).toBe(expected) + }) + + it.each(['../../outside', '/../../outside', '..\\..\\outside'])( + 'rejects traversal root %j', + async (root) => { + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ root })) + await expect( + loadCustomNavigation( + 'content', + new Set(['docs']), + resolveNavigationConfig(), + ), + ).rejects.toThrow('Navigation roots must stay inside the content root.') + }, + ) +}) diff --git a/packages/ts-ssg/src/navigation/nav-file.ts b/packages/ts-ssg/src/navigation/nav-file.ts index c56f645e..07c7da4c 100644 --- a/packages/ts-ssg/src/navigation/nav-file.ts +++ b/packages/ts-ssg/src/navigation/nav-file.ts @@ -72,12 +72,12 @@ async function readNavFile( function normalizeNavRoot(value: unknown, folder: string) { const root = resolveString(value) if (!root) return undefined - const normalizedInput = root.replaceAll('\\', '/').replace(/^\/+|\/+$/g, '') + const normalizedInput = trimSlashes(root.replaceAll('\\', '/')) const joined = root.trim().startsWith('/') ? normalizedInput : path.posix.join(folder, normalizedInput) const normalized = - joined === '.' ? '' : path.posix.normalize(joined).replace(/^\/+|\/+$/g, '') + joined === '.' ? '' : trimSlashes(path.posix.normalize(joined)) if ( normalized === '..' || normalized.startsWith('../') || @@ -90,6 +90,14 @@ function normalizeNavRoot(value: unknown, folder: string) { return normalized } +function trimSlashes(value: string) { + let start = 0 + let end = value.length + while (start < end && value[start] === '/') ++start + while (end > start && value[end - 1] === '/') --end + return value.slice(start, end) +} + function normalizeNavItems(value: unknown, folder: string): InternalNavItem[] { if (!Array.isArray(value)) return [] const items: InternalNavItem[] = [] diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index d20f1cf5..9ae5e616 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index fc4dcebf..3c85c058 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index d9db3527..bb9bb167 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index e02dc717..cc7bb5a3 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.7", + "version": "1.1.8", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts",