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([
+ ['<', '<'],
+ ['<', '<'],
+ ['<', '<'],
+ ['<', '<'],
+ ['"', '"'],
+ [''', '''],
+ ['<>&"'', '<>&"\''],
+ ['<>😀', '<>😀'],
+ ['< >', '< >'],
+ ['', '\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(/([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",