@ewanc26/utils 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @ewanc26/utils
2
+
3
+ Shared utility functions extracted from [ewancroft.uk](https://ewancroft.uk). Zero runtime dependencies.
4
+
5
+ ## Modules
6
+
7
+ - **Date & Locale** — `formatRelativeTime`, `formatLocalizedDate`, `getUserLocale`
8
+ - **Number Formatting** — `formatCompactNumber`, `formatNumber`
9
+ - **URL Utilities** — `getDomain`, `atUriToBlueskyUrl`, `getBlueskyProfileUrl`, `isExternalUrl`
10
+ - **Validators & Text** — `isValidTid`, `isValidDid`, `truncateText`, `escapeHtml`, `getInitials`, `debounce`, `throttle`
11
+ - **RSS Generation** — `generateRSSFeed`, `generateRSSItem`, `createRSSResponse`, `escapeXml`, `normalizeCharacters`, `formatRSSDate`
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add @ewanc26/utils
17
+ ```
18
+
19
+ ## Quick Examples
20
+
21
+ ```typescript
22
+ import { formatRelativeTime, formatCompactNumber, getDomain, isValidDid, generateRSSFeed } from '@ewanc26/utils';
23
+
24
+ formatRelativeTime('2025-11-13T00:00:00Z'); // '3d ago'
25
+ formatCompactNumber(1500); // '1.5K'
26
+ getDomain('https://www.example.com/path'); // 'example.com'
27
+ isValidDid('did:plc:abc123'); // true
28
+
29
+ const xml = generateRSSFeed({ title: 'My Blog', link: 'https://mysite.com', description: '…' }, items);
30
+ ```
31
+
32
+ All functions are SSR-safe and fall back to `en-GB` when `navigator` / `window` are unavailable.
33
+
34
+ ## Build
35
+
36
+ ```bash
37
+ pnpm build # tsc
38
+ pnpm dev # tsc --watch
39
+ pnpm check # tsc --noEmit
40
+ ```
41
+
42
+ ## Licence
43
+
44
+ See the root [LICENSE](../../LICENSE).
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@ewanc26/utils",
3
+ "version": "0.1.0",
4
+ "description": "Shared utility functions extracted from ewancroft.uk",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "source": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "publishConfig": { "access": "public" },
16
+ "files": ["dist", "src"],
17
+ "scripts": {
18
+ "build": "tsc --project tsconfig.json",
19
+ "dev": "tsc --project tsconfig.json --watch",
20
+ "check": "tsc --noEmit"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.9.3"
24
+ }
25
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Formats a date string into a relative, human-readable time.
3
+ * Uses the user's system locale where possible, with a fallback to en-GB.
4
+ */
5
+ export function formatRelativeTime(dateString: string): string {
6
+ const date = new Date(dateString);
7
+ const now = new Date();
8
+ const diffMs = now.getTime() - date.getTime();
9
+ const diffMins = Math.floor(diffMs / 60000);
10
+ const diffHours = Math.floor(diffMins / 60);
11
+ const diffDays = Math.floor(diffHours / 24);
12
+
13
+ if (diffMins < 1) return 'just now';
14
+ if (diffMins < 60) return `${diffMins}m ago`;
15
+ if (diffHours < 24) return `${diffHours}h ago`;
16
+ if (diffDays < 7) return `${diffDays}d ago`;
17
+
18
+ const userLocale = typeof navigator !== 'undefined' ? navigator.language : 'en-GB';
19
+
20
+ return date.toLocaleDateString(userLocale, {
21
+ day: 'numeric',
22
+ month: 'short',
23
+ year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
24
+ });
25
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Number formatting utilities
3
+ */
4
+
5
+ function getLocale(locale?: string): string {
6
+ return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB';
7
+ }
8
+
9
+ export function formatCompactNumber(num?: number, locale?: string): string {
10
+ if (num === undefined || num === null) return '0';
11
+ const effectiveLocale = getLocale(locale);
12
+
13
+ if (num >= 1000) {
14
+ const divisor = num >= 1000000000 ? 1000000000 : num >= 1000000 ? 1000000 : 1000;
15
+ const roundedDown = Math.floor((num / divisor) * 10) / 10;
16
+ const adjustedNum = roundedDown * divisor;
17
+
18
+ return new Intl.NumberFormat(effectiveLocale, {
19
+ notation: 'compact',
20
+ compactDisplay: 'short',
21
+ maximumFractionDigits: 1
22
+ }).format(adjustedNum);
23
+ }
24
+
25
+ return new Intl.NumberFormat(effectiveLocale, {
26
+ notation: 'compact',
27
+ compactDisplay: 'short',
28
+ maximumFractionDigits: 1
29
+ }).format(num);
30
+ }
31
+
32
+ export function formatNumber(num: number, locale?: string): string {
33
+ const effectiveLocale = getLocale(locale);
34
+ return new Intl.NumberFormat(effectiveLocale).format(num);
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './formatDate';
2
+ export * from './formatNumber';
3
+ export * from './url';
4
+ export * from './validators';
5
+ export * from './rss';
6
+ export * from './locale';
package/src/locale.ts ADDED
@@ -0,0 +1,16 @@
1
+ export function getUserLocale(): string {
2
+ if (typeof navigator !== 'undefined') {
3
+ return navigator.language || 'en-GB';
4
+ }
5
+ return 'en-GB';
6
+ }
7
+
8
+ export function formatLocalizedDate(dateString: string, locale?: string): string {
9
+ const date = new Date(dateString);
10
+ const userLocale = locale || getUserLocale();
11
+ return date.toLocaleDateString(userLocale, {
12
+ month: 'short',
13
+ day: 'numeric',
14
+ year: 'numeric'
15
+ });
16
+ }
package/src/rss.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * RSS Feed Generation Utilities
3
+ */
4
+
5
+ export interface RSSChannelConfig {
6
+ title: string;
7
+ link: string;
8
+ description: string;
9
+ language?: string;
10
+ selfLink?: string;
11
+ copyright?: string;
12
+ managingEditor?: string;
13
+ webMaster?: string;
14
+ generator?: string;
15
+ ttl?: number;
16
+ }
17
+
18
+ export interface RSSItem {
19
+ title: string;
20
+ link: string;
21
+ guid?: string;
22
+ pubDate: Date | string;
23
+ description?: string;
24
+ content?: string;
25
+ author?: string;
26
+ categories?: string[];
27
+ enclosure?: {
28
+ url: string;
29
+ length?: number;
30
+ type?: string;
31
+ };
32
+ comments?: string;
33
+ source?: {
34
+ url: string;
35
+ title: string;
36
+ };
37
+ }
38
+
39
+ export function escapeXml(unsafe: string): string {
40
+ return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
41
+ }
42
+
43
+ export function escapeXmlAttribute(unsafe: string): string {
44
+ return unsafe
45
+ .replace(/&/g, '&amp;')
46
+ .replace(/</g, '&lt;')
47
+ .replace(/>/g, '&gt;')
48
+ .replace(/"/g, '&quot;');
49
+ }
50
+
51
+ export function normalizeCharacters(text: string): string {
52
+ return text
53
+ .replace(/\u2018|\u2019|\u201A|\u201B/g, "'")
54
+ .replace(/\u201C|\u201D|\u201E|\u201F/g, '"')
55
+ .replace(/\u2013/g, '-')
56
+ .replace(/\u2014/g, '--')
57
+ .replace(/\u00A0/g, ' ')
58
+ .replace(/\u2026/g, '...')
59
+ .replace(/\u2022/g, '*')
60
+ .replace(/&apos;/g, "'")
61
+ .replace(/&quot;/g, '"')
62
+ .replace(/&nbsp;/g, ' ')
63
+ .replace(/&mdash;/g, '--')
64
+ .replace(/&ndash;/g, '-')
65
+ .replace(/&hellip;/g, '...')
66
+ .replace(/&rsquo;/g, "'")
67
+ .replace(/&lsquo;/g, "'")
68
+ .replace(/&rdquo;/g, '"')
69
+ .replace(/&ldquo;/g, '"');
70
+ }
71
+
72
+ export function formatRSSDate(date: Date | string): string {
73
+ const d = typeof date === 'string' ? new Date(date) : date;
74
+ return d.toUTCString();
75
+ }
76
+
77
+ export function generateRSSItem(item: RSSItem): string {
78
+ const guid = item.guid || item.link;
79
+ const pubDate = formatRSSDate(item.pubDate);
80
+ const title = escapeXml(normalizeCharacters(item.title));
81
+ const description = item.description ? escapeXml(normalizeCharacters(item.description)) : '';
82
+ const content = item.content ? normalizeCharacters(item.content) : '';
83
+ const author = item.author ? escapeXml(normalizeCharacters(item.author)) : '';
84
+ const categories =
85
+ item.categories
86
+ ?.map((cat) => ` <category>${escapeXml(normalizeCharacters(cat))}</category>`)
87
+ .join('\n') || '';
88
+
89
+ let enclosure = '';
90
+ if (item.enclosure) {
91
+ const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : '';
92
+ const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : '';
93
+ enclosure = ` <enclosure url="${escapeXmlAttribute(item.enclosure.url)}"${length}${type} />`;
94
+ }
95
+
96
+ let source = '';
97
+ if (item.source) {
98
+ source = ` <source url="${escapeXmlAttribute(item.source.url)}">${escapeXml(normalizeCharacters(item.source.title))}</source>`;
99
+ }
100
+
101
+ return ` <item>
102
+ <title>${title}</title>
103
+ <link>${escapeXmlAttribute(item.link)}</link>
104
+ <guid isPermaLink="true">${escapeXmlAttribute(guid)}</guid>
105
+ <pubDate>${pubDate}</pubDate>${description ? `\n <description>${description}</description>` : ''}${content ? `\n <content:encoded><![CDATA[${content}]]></content:encoded>` : ''}${author ? `\n <author>${author}</author>` : ''}${item.comments ? `\n <comments>${escapeXmlAttribute(item.comments)}</comments>` : ''}${categories ? `\n${categories}` : ''}${enclosure ? `\n${enclosure}` : ''}${source ? `\n${source}` : ''}
106
+ </item>`;
107
+ }
108
+
109
+ export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string {
110
+ const language = config.language || 'en';
111
+ const generator = config.generator || 'SvelteKit with AT Protocol';
112
+ const lastBuildDate = formatRSSDate(new Date());
113
+ const title = escapeXml(normalizeCharacters(config.title));
114
+ const link = escapeXmlAttribute(config.link);
115
+ const description = escapeXml(normalizeCharacters(config.description));
116
+ const generatorText = escapeXml(normalizeCharacters(generator));
117
+
118
+ const atomLink = config.selfLink
119
+ ? ` <atom:link href="${escapeXmlAttribute(config.selfLink)}" rel="self" type="application/rss+xml" />`
120
+ : '';
121
+
122
+ const optionalFields = [];
123
+ if (config.copyright)
124
+ optionalFields.push(
125
+ ` <copyright>${escapeXml(normalizeCharacters(config.copyright))}</copyright>`
126
+ );
127
+ if (config.managingEditor)
128
+ optionalFields.push(
129
+ ` <managingEditor>${escapeXml(normalizeCharacters(config.managingEditor))}</managingEditor>`
130
+ );
131
+ if (config.webMaster)
132
+ optionalFields.push(
133
+ ` <webMaster>${escapeXml(normalizeCharacters(config.webMaster))}</webMaster>`
134
+ );
135
+ if (config.ttl) optionalFields.push(` <ttl>${config.ttl}</ttl>`);
136
+
137
+ const itemsXml = items.map((item) => generateRSSItem(item)).join('\n');
138
+
139
+ return `<?xml version="1.0" encoding="UTF-8"?>
140
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
141
+ <channel>
142
+ <title>${title}</title>
143
+ <link>${link}</link>
144
+ <description>${description}</description>
145
+ <language>${language}</language>${atomLink ? `\n${atomLink}` : ''}
146
+ <lastBuildDate>${lastBuildDate}</lastBuildDate>
147
+ <generator>${generatorText}</generator>${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''}
148
+ ${itemsXml}
149
+ </channel>
150
+ </rss>`;
151
+ }
152
+
153
+ export function createRSSResponse(
154
+ feed: string,
155
+ options?: { cacheMaxAge?: number; status?: number }
156
+ ): Response {
157
+ const cacheMaxAge = options?.cacheMaxAge ?? 3600;
158
+ const status = options?.status ?? 200;
159
+ return new Response(feed, {
160
+ status,
161
+ headers: {
162
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
163
+ 'Cache-Control': `public, max-age=${cacheMaxAge}`
164
+ }
165
+ });
166
+ }
package/src/url.ts ADDED
@@ -0,0 +1,29 @@
1
+ export function getDomain(url: string): string {
2
+ try {
3
+ const urlObj = new URL(url);
4
+ return urlObj.hostname.replace('www.', '');
5
+ } catch {
6
+ return '';
7
+ }
8
+ }
9
+
10
+ export function atUriToBlueskyUrl(uri: string): string {
11
+ const parts = uri.split('/');
12
+ const did = parts[2];
13
+ const rkey = parts[4];
14
+ return `https://witchsky.app/profile/${did}/post/${rkey}`;
15
+ }
16
+
17
+ export function getBlueskyProfileUrl(actor: string): string {
18
+ return `https://witchsky.app/profile/${actor}`;
19
+ }
20
+
21
+ export function isExternalUrl(url: string): boolean {
22
+ if (typeof window === 'undefined') return true;
23
+ try {
24
+ const urlObj = new URL(url, window.location.href);
25
+ return urlObj.origin !== window.location.origin;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,59 @@
1
+ export function isValidTid(tid: string): boolean {
2
+ const tidPattern = /^[a-zA-Z0-9]{12,16}$/;
3
+ return tidPattern.test(tid);
4
+ }
5
+
6
+ export function isValidDid(did: string): boolean {
7
+ const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:-]+$/;
8
+ return didPattern.test(did);
9
+ }
10
+
11
+ export function truncateText(text: string, maxLength: number, ellipsis = '...'): string {
12
+ if (text.length <= maxLength) return text;
13
+ return text.slice(0, maxLength - ellipsis.length).trim() + ellipsis;
14
+ }
15
+
16
+ export function escapeHtml(text: string): string {
17
+ const div = typeof document !== 'undefined' ? document.createElement('div') : null;
18
+ if (div) {
19
+ div.textContent = text;
20
+ return div.innerHTML;
21
+ }
22
+ return text
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#039;');
28
+ }
29
+
30
+ export function getInitials(name: string): string {
31
+ const words = name.trim().split(/\s+/);
32
+ if (words.length === 1) return words[0].charAt(0).toUpperCase();
33
+ return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
34
+ }
35
+
36
+ export function debounce<T extends (...args: any[]) => any>(
37
+ func: T,
38
+ delay: number
39
+ ): (...args: Parameters<T>) => void {
40
+ let timeoutId: ReturnType<typeof setTimeout>;
41
+ return (...args: Parameters<T>) => {
42
+ clearTimeout(timeoutId);
43
+ timeoutId = setTimeout(() => func(...args), delay);
44
+ };
45
+ }
46
+
47
+ export function throttle<T extends (...args: any[]) => any>(
48
+ func: T,
49
+ limit: number
50
+ ): (...args: Parameters<T>) => void {
51
+ let inThrottle: boolean;
52
+ return (...args: Parameters<T>) => {
53
+ if (!inThrottle) {
54
+ func(...args);
55
+ inThrottle = true;
56
+ setTimeout(() => (inThrottle = false), limit);
57
+ }
58
+ };
59
+ }