@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 +44 -0
- package/package.json +25 -0
- package/src/formatDate.ts +25 -0
- package/src/formatNumber.ts +35 -0
- package/src/index.ts +6 -0
- package/src/locale.ts +16 -0
- package/src/rss.ts +166 -0
- package/src/url.ts +29 -0
- package/src/validators.ts +59 -0
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
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function escapeXmlAttribute(unsafe: string): string {
|
|
44
|
+
return unsafe
|
|
45
|
+
.replace(/&/g, '&')
|
|
46
|
+
.replace(/</g, '<')
|
|
47
|
+
.replace(/>/g, '>')
|
|
48
|
+
.replace(/"/g, '"');
|
|
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(/'/g, "'")
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/ /g, ' ')
|
|
63
|
+
.replace(/—/g, '--')
|
|
64
|
+
.replace(/–/g, '-')
|
|
65
|
+
.replace(/…/g, '...')
|
|
66
|
+
.replace(/’/g, "'")
|
|
67
|
+
.replace(/‘/g, "'")
|
|
68
|
+
.replace(/”/g, '"')
|
|
69
|
+
.replace(/“/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, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|