From 6b329241dab710b4fe645eaf6e00fa642302cf7f Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 6 Mar 2026 23:17:15 +0000 Subject: [PATCH 1/3] chore: restructure into monorepo, bump to v11.0.0 --- package.json | 24 +++++++ src/formatDate.ts | 25 +++++++ src/formatNumber.ts | 35 ++++++++++ src/index.ts | 6 ++ src/locale.ts | 16 +++++ src/rss.ts | 166 ++++++++++++++++++++++++++++++++++++++++++++ src/url.ts | 29 ++++++++ src/validators.ts | 59 ++++++++++++++++ tsconfig.json | 13 ++++ 9 files changed, 373 insertions(+) create mode 100644 package.json create mode 100644 src/formatDate.ts create mode 100644 src/formatNumber.ts create mode 100644 src/index.ts create mode 100644 src/locale.ts create mode 100644 src/rss.ts create mode 100644 src/url.ts create mode 100644 src/validators.ts create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..d16061e --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ewanc26/utils", + "version": "0.1.0", + "description": "Shared utility functions extracted from ewancroft.uk", + "type": "module", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist", "src"], + "scripts": { + "build": "tsc --project tsconfig.json", + "dev": "tsc --project tsconfig.json --watch", + "check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/src/formatDate.ts b/src/formatDate.ts new file mode 100644 index 0000000..2d915d9 --- /dev/null +++ b/src/formatDate.ts @@ -0,0 +1,25 @@ +/** + * Formats a date string into a relative, human-readable time. + * Uses the user's system locale where possible, with a fallback to en-GB. + */ +export function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + const userLocale = typeof navigator !== 'undefined' ? navigator.language : 'en-GB'; + + return date.toLocaleDateString(userLocale, { + day: 'numeric', + month: 'short', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); +} diff --git a/src/formatNumber.ts b/src/formatNumber.ts new file mode 100644 index 0000000..863b9a8 --- /dev/null +++ b/src/formatNumber.ts @@ -0,0 +1,35 @@ +/** + * Number formatting utilities + */ + +function getLocale(locale?: string): string { + return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB'; +} + +export function formatCompactNumber(num?: number, locale?: string): string { + if (num === undefined || num === null) return '0'; + const effectiveLocale = getLocale(locale); + + if (num >= 1000) { + const divisor = num >= 1000000000 ? 1000000000 : num >= 1000000 ? 1000000 : 1000; + const roundedDown = Math.floor((num / divisor) * 10) / 10; + const adjustedNum = roundedDown * divisor; + + return new Intl.NumberFormat(effectiveLocale, { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1 + }).format(adjustedNum); + } + + return new Intl.NumberFormat(effectiveLocale, { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1 + }).format(num); +} + +export function formatNumber(num: number, locale?: string): string { + const effectiveLocale = getLocale(locale); + return new Intl.NumberFormat(effectiveLocale).format(num); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..01e5cbd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +export * from './formatDate'; +export * from './formatNumber'; +export * from './url'; +export * from './validators'; +export * from './rss'; +export * from './locale'; diff --git a/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..3b7c78c --- /dev/null +++ b/src/locale.ts @@ -0,0 +1,16 @@ +export function getUserLocale(): string { + if (typeof navigator !== 'undefined') { + return navigator.language || 'en-GB'; + } + return 'en-GB'; +} + +export function formatLocalizedDate(dateString: string, locale?: string): string { + const date = new Date(dateString); + const userLocale = locale || getUserLocale(); + return date.toLocaleDateString(userLocale, { + month: 'short', + day: 'numeric', + year: 'numeric' + }); +} diff --git a/src/rss.ts b/src/rss.ts new file mode 100644 index 0000000..9ac698c --- /dev/null +++ b/src/rss.ts @@ -0,0 +1,166 @@ +/** + * RSS Feed Generation Utilities + */ + +export interface RSSChannelConfig { + title: string; + link: string; + description: string; + language?: string; + selfLink?: string; + copyright?: string; + managingEditor?: string; + webMaster?: string; + generator?: string; + ttl?: number; +} + +export interface RSSItem { + title: string; + link: string; + guid?: string; + pubDate: Date | string; + description?: string; + content?: string; + author?: string; + categories?: string[]; + enclosure?: { + url: string; + length?: number; + type?: string; + }; + comments?: string; + source?: { + url: string; + title: string; + }; +} + +export function escapeXml(unsafe: string): string { + return unsafe.replace(/&/g, '&').replace(//g, '>'); +} + +export function escapeXmlAttribute(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function normalizeCharacters(text: string): string { + return text + .replace(/\u2018|\u2019|\u201A|\u201B/g, "'") + .replace(/\u201C|\u201D|\u201E|\u201F/g, '"') + .replace(/\u2013/g, '-') + .replace(/\u2014/g, '--') + .replace(/\u00A0/g, ' ') + .replace(/\u2026/g, '...') + .replace(/\u2022/g, '*') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/ /g, ' ') + .replace(/—/g, '--') + .replace(/–/g, '-') + .replace(/…/g, '...') + .replace(/’/g, "'") + .replace(/‘/g, "'") + .replace(/”/g, '"') + .replace(/“/g, '"'); +} + +export function formatRSSDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date; + return d.toUTCString(); +} + +export function generateRSSItem(item: RSSItem): string { + const guid = item.guid || item.link; + const pubDate = formatRSSDate(item.pubDate); + const title = escapeXml(normalizeCharacters(item.title)); + const description = item.description ? escapeXml(normalizeCharacters(item.description)) : ''; + const content = item.content ? normalizeCharacters(item.content) : ''; + const author = item.author ? escapeXml(normalizeCharacters(item.author)) : ''; + const categories = + item.categories + ?.map((cat) => ` ${escapeXml(normalizeCharacters(cat))}`) + .join('\n') || ''; + + let enclosure = ''; + if (item.enclosure) { + const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : ''; + const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : ''; + enclosure = ` `; + } + + let source = ''; + if (item.source) { + source = ` ${escapeXml(normalizeCharacters(item.source.title))}`; + } + + return ` + ${title} + ${escapeXmlAttribute(item.link)} + ${escapeXmlAttribute(guid)} + ${pubDate}${description ? `\n ${description}` : ''}${content ? `\n ` : ''}${author ? `\n ${author}` : ''}${item.comments ? `\n ${escapeXmlAttribute(item.comments)}` : ''}${categories ? `\n${categories}` : ''}${enclosure ? `\n${enclosure}` : ''}${source ? `\n${source}` : ''} + `; +} + +export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string { + const language = config.language || 'en'; + const generator = config.generator || 'SvelteKit with AT Protocol'; + const lastBuildDate = formatRSSDate(new Date()); + const title = escapeXml(normalizeCharacters(config.title)); + const link = escapeXmlAttribute(config.link); + const description = escapeXml(normalizeCharacters(config.description)); + const generatorText = escapeXml(normalizeCharacters(generator)); + + const atomLink = config.selfLink + ? ` ` + : ''; + + const optionalFields = []; + if (config.copyright) + optionalFields.push( + ` ${escapeXml(normalizeCharacters(config.copyright))}` + ); + if (config.managingEditor) + optionalFields.push( + ` ${escapeXml(normalizeCharacters(config.managingEditor))}` + ); + if (config.webMaster) + optionalFields.push( + ` ${escapeXml(normalizeCharacters(config.webMaster))}` + ); + if (config.ttl) optionalFields.push(` ${config.ttl}`); + + const itemsXml = items.map((item) => generateRSSItem(item)).join('\n'); + + return ` + + + ${title} + ${link} + ${description} + ${language}${atomLink ? `\n${atomLink}` : ''} + ${lastBuildDate} + ${generatorText}${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''} +${itemsXml} + +`; +} + +export function createRSSResponse( + feed: string, + options?: { cacheMaxAge?: number; status?: number } +): Response { + const cacheMaxAge = options?.cacheMaxAge ?? 3600; + const status = options?.status ?? 200; + return new Response(feed, { + status, + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Cache-Control': `public, max-age=${cacheMaxAge}` + } + }); +} diff --git a/src/url.ts b/src/url.ts new file mode 100644 index 0000000..8a37d19 --- /dev/null +++ b/src/url.ts @@ -0,0 +1,29 @@ +export function getDomain(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace('www.', ''); + } catch { + return ''; + } +} + +export function atUriToBlueskyUrl(uri: string): string { + const parts = uri.split('/'); + const did = parts[2]; + const rkey = parts[4]; + return `https://witchsky.app/profile/${did}/post/${rkey}`; +} + +export function getBlueskyProfileUrl(actor: string): string { + return `https://witchsky.app/profile/${actor}`; +} + +export function isExternalUrl(url: string): boolean { + if (typeof window === 'undefined') return true; + try { + const urlObj = new URL(url, window.location.href); + return urlObj.origin !== window.location.origin; + } catch { + return false; + } +} diff --git a/src/validators.ts b/src/validators.ts new file mode 100644 index 0000000..caaa703 --- /dev/null +++ b/src/validators.ts @@ -0,0 +1,59 @@ +export function isValidTid(tid: string): boolean { + const tidPattern = /^[a-zA-Z0-9]{12,16}$/; + return tidPattern.test(tid); +} + +export function isValidDid(did: string): boolean { + const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:-]+$/; + return didPattern.test(did); +} + +export function truncateText(text: string, maxLength: number, ellipsis = '...'): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength - ellipsis.length).trim() + ellipsis; +} + +export function escapeHtml(text: string): string { + const div = typeof document !== 'undefined' ? document.createElement('div') : null; + if (div) { + div.textContent = text; + return div.innerHTML; + } + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length === 1) return words[0].charAt(0).toUpperCase(); + return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase(); +} + +export function debounce any>( + func: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); + }; +} + +export function throttle any>( + func: T, + limit: number +): (...args: Parameters) => void { + let inThrottle: boolean; + return (...args: Parameters) => { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..21e2579 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "moduleResolution": "bundler", + "module": "esnext", + "target": "es2022" + }, + "include": ["src"] +} -- 2.51.2 From 06f90745963518a7dec048f050dd35a90b87998d Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 6 Mar 2026 23:18:31 +0000 Subject: [PATCH 2/3] chore: allow pkgs to be public --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index d16061e..4f6eae6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "publishConfig": { "access": "public" }, "files": ["dist", "src"], "scripts": { "build": "tsc --project tsconfig.json", -- 2.51.2 From 6b57ccf48473f92d9854149332ba16111bf6c626 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Fri, 6 Mar 2026 23:32:34 +0000 Subject: [PATCH 3/3] chore: create READMEs for pkgs --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b731ce --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# @ewanc26/utils + +Shared utility functions extracted from [ewancroft.uk](https://ewancroft.uk). Zero runtime dependencies. + +## Modules + +- **Date & Locale** — `formatRelativeTime`, `formatLocalizedDate`, `getUserLocale` +- **Number Formatting** — `formatCompactNumber`, `formatNumber` +- **URL Utilities** — `getDomain`, `atUriToBlueskyUrl`, `getBlueskyProfileUrl`, `isExternalUrl` +- **Validators & Text** — `isValidTid`, `isValidDid`, `truncateText`, `escapeHtml`, `getInitials`, `debounce`, `throttle` +- **RSS Generation** — `generateRSSFeed`, `generateRSSItem`, `createRSSResponse`, `escapeXml`, `normalizeCharacters`, `formatRSSDate` + +## Installation + +```bash +pnpm add @ewanc26/utils +``` + +## Quick Examples + +```typescript +import { formatRelativeTime, formatCompactNumber, getDomain, isValidDid, generateRSSFeed } from '@ewanc26/utils'; + +formatRelativeTime('2025-11-13T00:00:00Z'); // '3d ago' +formatCompactNumber(1500); // '1.5K' +getDomain('https://www.example.com/path'); // 'example.com' +isValidDid('did:plc:abc123'); // true + +const xml = generateRSSFeed({ title: 'My Blog', link: 'https://mysite.com', description: '…' }, items); +``` + +All functions are SSR-safe and fall back to `en-GB` when `navigator` / `window` are unavailable. + +## Build + +```bash +pnpm build # tsc +pnpm dev # tsc --watch +pnpm check # tsc --noEmit +``` + +## Licence + +See the root [LICENSE](../../LICENSE). -- 2.51.2