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).