@sarimarcus/content-sites-core 0.7.0-rc.1 → 0.8.0-rc.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/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/dist/dates/buildClock.d.ts +11 -0
- package/dist/dates/buildClock.js +13 -0
- package/dist/dates/formatDate.d.ts +19 -0
- package/dist/dates/formatDate.js +39 -0
- package/dist/dates/ics.d.ts +36 -0
- package/dist/dates/ics.js +107 -0
- package/dist/dates/index.d.ts +5 -1
- package/dist/dates/index.js +5 -2
- package/dist/dates/lastmod.d.ts +15 -0
- package/dist/dates/lastmod.js +35 -0
- package/dist/geo/distance.d.ts +9 -0
- package/dist/geo/distance.js +24 -0
- package/dist/geo/index.d.ts +1 -0
- package/dist/geo/index.js +2 -0
- package/dist/text/fitLength.d.ts +5 -0
- package/dist/text/fitLength.js +8 -0
- package/dist/text/index.d.ts +2 -0
- package/dist/text/index.js +2 -0
- package/dist/text/readingTime.d.ts +17 -0
- package/dist/text/readingTime.js +39 -0
- package/package.json +8 -1
- package/src/dates/buildClock.ts +13 -0
- package/src/dates/formatDate.ts +39 -0
- package/src/dates/ics.ts +137 -0
- package/src/dates/index.ts +6 -2
- package/src/dates/lastmod.ts +36 -0
- package/src/geo/distance.ts +30 -0
- package/src/geo/index.ts +2 -0
- package/src/text/fitLength.ts +8 -0
- package/src/text/index.ts +2 -0
- package/src/text/readingTime.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.8.0-rc.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
- `./dates`: `formatUtcDate`, `formatUtcDateRange`, `buildNow`, `maxDate`, `latestDateModified`, `NEW_BADGE_DAYS`, `isNew` (takes `now`), and the iCalendar builders `eventToIcs`, `eventsToIcs`, `googleCalendarUrl`, `monthsCovered` (site identity passed as an `IcsCalendar`). New `./geo`: `haversineMetres`, `formatDistance`, `walkingMinutes`. `./text`: `fitLength`, `readingTime`. First runtime dependency: `marked` (SLA-2747).
|
|
8
|
+
|
|
9
|
+
## 0.7.0 (2026-09-30)
|
|
10
|
+
|
|
11
|
+
- Stable release of 0.7.0-rc.1, unchanged: adopted on all three sites with every page identical (Córdoba's 8 walk pages differ only by a stylesheet reorder, screenshots unchanged); astro check 0 errors (SLA-2747).
|
|
12
|
+
|
|
5
13
|
## 0.7.0-rc.1 (2026-09-30)
|
|
6
14
|
|
|
7
15
|
- `buildImageMap` / `keyBySlug` are typed with Astro's `ImageMetadata` again, as the site copies were; the generic form typed an untyped `import.meta.glob` result as `unknown` (SLA-2747).
|
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ Non-visual code shared by the content sites: generic utilities, Astro config bui
|
|
|
4
4
|
and the type contracts `ui` and `tourism` build on (for example the card item and tag-link shapes that
|
|
5
5
|
today's ui components import from tourism code).
|
|
6
6
|
|
|
7
|
-
Named entry points: `url`, `dates`, `dom`, `text`, `config` and `types` (`src/internal` is never exported). No `.astro`
|
|
8
|
-
files. Lowest layer: imports nothing from the other packages. Ownership per path is in
|
|
7
|
+
Named entry points: `url`, `dates`, `geo`, `dom`, `text`, `config` and `types` (`src/internal` is never exported). No `.astro`
|
|
8
|
+
files. Lowest layer: imports nothing from the other packages; its one runtime dependency is `marked` (`readingTime`). Ownership per path is in
|
|
9
9
|
`.planning/platform/catalogue.json` (owner `core`).
|
|
10
10
|
|
|
11
11
|
Code Node loads directly (config builders imported by `astro.config.mjs`) ships compiled to `dist/` with
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time "now", pinned to the start of the UTC day.
|
|
3
|
+
*
|
|
4
|
+
* Astro prerenders every page, so a bare `new Date()` in frontmatter bakes the build *instant* into the HTML, and
|
|
5
|
+
* anything derived from it (a moon phase, a countdown) differs between two builds a minute apart. Consumers that
|
|
6
|
+
* need the real instant re-compute client-side, so SSR only paints a no-JS fallback. Truncating to the UTC day
|
|
7
|
+
* matches the convention `validate:dates` uses for "today".
|
|
8
|
+
*
|
|
9
|
+
* Two builds either side of UTC midnight still differ; that cannot drift within one build session.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildNow(): Date;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time "now", pinned to the start of the UTC day.
|
|
3
|
+
*
|
|
4
|
+
* Astro prerenders every page, so a bare `new Date()` in frontmatter bakes the build *instant* into the HTML, and
|
|
5
|
+
* anything derived from it (a moon phase, a countdown) differs between two builds a minute apart. Consumers that
|
|
6
|
+
* need the real instant re-compute client-side, so SSR only paints a no-JS fallback. Truncating to the UTC day
|
|
7
|
+
* matches the convention `validate:dates` uses for "today".
|
|
8
|
+
*
|
|
9
|
+
* Two builds either side of UTC midnight still differ; that cannot drift within one build session.
|
|
10
|
+
*/
|
|
11
|
+
export function buildNow() {
|
|
12
|
+
return new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a date-only ISO string (`YYYY-MM-DD`) for display, anchored to UTC.
|
|
3
|
+
*
|
|
4
|
+
* The `T00:00:00Z` + `timeZone: 'UTC'` pairing is load-bearing: parsing a bare
|
|
5
|
+
* `YYYY-MM-DD` and formatting it in the host timezone drifts the displayed day
|
|
6
|
+
* by one in any negative-UTC-offset locale. Centralizing it here keeps every
|
|
7
|
+
* date on the site off-by-one-safe.
|
|
8
|
+
*
|
|
9
|
+
* @param iso date-only or ISO datetime string (only the date part is used)
|
|
10
|
+
* @param options Intl options (default: long month + day, e.g. "July 14")
|
|
11
|
+
* @param locale BCP-47 locale (default en-US)
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatUtcDate(iso: string | undefined, options?: Intl.DateTimeFormatOptions, locale?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Format a `startDate`/`endDate` pair as a compact display range, UTC-anchored
|
|
16
|
+
* via `formatUtcDate`: "Aug 12" (single day), "Aug 12–13" (same month),
|
|
17
|
+
* "May 29 – Jun 5" (cross-month). Returns '' when `start` is missing/invalid.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatUtcDateRange(start?: string, end?: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a date-only ISO string (`YYYY-MM-DD`) for display, anchored to UTC.
|
|
3
|
+
*
|
|
4
|
+
* The `T00:00:00Z` + `timeZone: 'UTC'` pairing is load-bearing: parsing a bare
|
|
5
|
+
* `YYYY-MM-DD` and formatting it in the host timezone drifts the displayed day
|
|
6
|
+
* by one in any negative-UTC-offset locale. Centralizing it here keeps every
|
|
7
|
+
* date on the site off-by-one-safe.
|
|
8
|
+
*
|
|
9
|
+
* @param iso date-only or ISO datetime string (only the date part is used)
|
|
10
|
+
* @param options Intl options (default: long month + day, e.g. "July 14")
|
|
11
|
+
* @param locale BCP-47 locale (default en-US)
|
|
12
|
+
*/
|
|
13
|
+
export function formatUtcDate(iso, options = { month: 'long', day: 'numeric' }, locale = 'en-US') {
|
|
14
|
+
// Guard partial/malformed input: a short string like "2026" would otherwise
|
|
15
|
+
// parse to a valid-but-wrong date (Jan 1), and junk would render "Invalid Date".
|
|
16
|
+
if (!iso || iso.length < 10)
|
|
17
|
+
return '';
|
|
18
|
+
const date = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
|
|
19
|
+
if (Number.isNaN(date.getTime()))
|
|
20
|
+
return '';
|
|
21
|
+
return date.toLocaleDateString(locale, { ...options, timeZone: 'UTC' });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Format a `startDate`/`endDate` pair as a compact display range, UTC-anchored
|
|
25
|
+
* via `formatUtcDate`: "Aug 12" (single day), "Aug 12–13" (same month),
|
|
26
|
+
* "May 29 – Jun 5" (cross-month). Returns '' when `start` is missing/invalid.
|
|
27
|
+
*/
|
|
28
|
+
export function formatUtcDateRange(start, end) {
|
|
29
|
+
const s = formatUtcDate(start, { month: 'short', day: 'numeric' });
|
|
30
|
+
if (!s)
|
|
31
|
+
return '';
|
|
32
|
+
if (!end || end.length < 10 || end.slice(0, 10) === start.slice(0, 10))
|
|
33
|
+
return s;
|
|
34
|
+
const e = formatUtcDate(end, { month: 'short', day: 'numeric' });
|
|
35
|
+
if (!e)
|
|
36
|
+
return s;
|
|
37
|
+
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
|
|
38
|
+
return sameMonth ? `${s}–${formatUtcDate(end, { day: 'numeric' })}` : `${s} – ${e}`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iCalendar (RFC 5545) builders for all-day events, plus the matching Google Calendar prefill URL. Pure string
|
|
3
|
+
* functions; the site supplies its calendar identity.
|
|
4
|
+
*
|
|
5
|
+
* All-day date semantics: DTEND;VALUE=DATE is exclusive, so a single-day event ends the following day and a range
|
|
6
|
+
* ends the day after endDate.
|
|
7
|
+
*/
|
|
8
|
+
export interface IcsEvent {
|
|
9
|
+
slug: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** YYYY-MM-DD */
|
|
13
|
+
startDate: string;
|
|
14
|
+
/** YYYY-MM-DD */
|
|
15
|
+
endDate?: string;
|
|
16
|
+
/** Absolute URL of the event page */
|
|
17
|
+
url: string;
|
|
18
|
+
}
|
|
19
|
+
export interface IcsCalendar {
|
|
20
|
+
/** Domain after `@` in each UID, e.g. `example.com`. */
|
|
21
|
+
uidDomain: string;
|
|
22
|
+
/** PRODID value, e.g. `-//Example//events//EN`. */
|
|
23
|
+
prodId: string;
|
|
24
|
+
/** Plain-text location; escaped for the LOCATION line, raw in the Google URL. */
|
|
25
|
+
location: string;
|
|
26
|
+
/** X-WR-CALNAME of the multi-event feed. */
|
|
27
|
+
calendarName: string;
|
|
28
|
+
}
|
|
29
|
+
/** A one-event calendar, named after the event. */
|
|
30
|
+
export declare function eventToIcs(ev: IcsEvent, cal: IcsCalendar): string;
|
|
31
|
+
/** A multi-event feed named `cal.calendarName`. */
|
|
32
|
+
export declare function eventsToIcs(events: IcsEvent[], cal: IcsCalendar): string;
|
|
33
|
+
/** Google Calendar prefill URL for the same all-day event. */
|
|
34
|
+
export declare function googleCalendarUrl(ev: IcsEvent, cal: IcsCalendar): string;
|
|
35
|
+
/** Month numbers (1-12) an event overlaps, for by-month buckets. */
|
|
36
|
+
export declare function monthsCovered(startDate: string, endDate?: string): number[];
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iCalendar (RFC 5545) builders for all-day events, plus the matching Google Calendar prefill URL. Pure string
|
|
3
|
+
* functions; the site supplies its calendar identity.
|
|
4
|
+
*
|
|
5
|
+
* All-day date semantics: DTEND;VALUE=DATE is exclusive, so a single-day event ends the following day and a range
|
|
6
|
+
* ends the day after endDate.
|
|
7
|
+
*/
|
|
8
|
+
const compact = (isoDate) => isoDate.replaceAll('-', '');
|
|
9
|
+
/** Day after the given YYYY-MM-DD, compact form (exclusive DTEND). */
|
|
10
|
+
function nextDayCompact(isoDate) {
|
|
11
|
+
const d = new Date(`${isoDate}T00:00:00Z`);
|
|
12
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
13
|
+
return d.toISOString().slice(0, 10).replaceAll('-', '');
|
|
14
|
+
}
|
|
15
|
+
/** RFC 5545 TEXT escaping: backslash, semicolon, comma, newline. */
|
|
16
|
+
function escapeText(value) {
|
|
17
|
+
return value
|
|
18
|
+
.replaceAll('\\', '\\\\')
|
|
19
|
+
.replaceAll(';', '\\;')
|
|
20
|
+
.replaceAll(',', '\\,')
|
|
21
|
+
.replaceAll(/\r?\n/g, '\\n');
|
|
22
|
+
}
|
|
23
|
+
/** Fold lines longer than 75 octets with CRLF + space (RFC 5545 §3.1). */
|
|
24
|
+
function foldLine(line) {
|
|
25
|
+
const bytes = new TextEncoder().encode(line);
|
|
26
|
+
if (bytes.length <= 75)
|
|
27
|
+
return line;
|
|
28
|
+
const parts = [];
|
|
29
|
+
let current = '';
|
|
30
|
+
let currentBytes = 0;
|
|
31
|
+
for (const ch of line) {
|
|
32
|
+
const chBytes = new TextEncoder().encode(ch).length;
|
|
33
|
+
const limit = parts.length === 0 ? 75 : 74; // continuation lines lose 1 octet to the leading space
|
|
34
|
+
if (currentBytes + chBytes > limit) {
|
|
35
|
+
parts.push(current);
|
|
36
|
+
current = ch;
|
|
37
|
+
currentBytes = chBytes;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
current += ch;
|
|
41
|
+
currentBytes += chBytes;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (current)
|
|
45
|
+
parts.push(current);
|
|
46
|
+
return parts.join('\r\n ');
|
|
47
|
+
}
|
|
48
|
+
function vevent(ev, cal) {
|
|
49
|
+
const start = compact(ev.startDate);
|
|
50
|
+
const end = nextDayCompact(ev.endDate || ev.startDate);
|
|
51
|
+
return [
|
|
52
|
+
'BEGIN:VEVENT',
|
|
53
|
+
// Deterministic UID + DTSTAMP so rebuilds don't churn the committed output.
|
|
54
|
+
`UID:${ev.slug}-${start}@${cal.uidDomain}`,
|
|
55
|
+
`DTSTAMP:${start}T000000Z`,
|
|
56
|
+
`DTSTART;VALUE=DATE:${start}`,
|
|
57
|
+
`DTEND;VALUE=DATE:${end}`,
|
|
58
|
+
`SUMMARY:${escapeText(ev.name)}`,
|
|
59
|
+
...(ev.description ? [`DESCRIPTION:${escapeText(ev.description.slice(0, 300))}`] : []),
|
|
60
|
+
`LOCATION:${escapeText(cal.location)}`,
|
|
61
|
+
`URL:${ev.url}`,
|
|
62
|
+
'END:VEVENT',
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
function wrapCalendar(name, veventLines, cal) {
|
|
66
|
+
const lines = [
|
|
67
|
+
'BEGIN:VCALENDAR',
|
|
68
|
+
'VERSION:2.0',
|
|
69
|
+
`PRODID:${cal.prodId}`,
|
|
70
|
+
'CALSCALE:GREGORIAN',
|
|
71
|
+
`X-WR-CALNAME:${escapeText(name)}`,
|
|
72
|
+
...veventLines,
|
|
73
|
+
'END:VCALENDAR',
|
|
74
|
+
];
|
|
75
|
+
return lines.map(foldLine).join('\r\n') + '\r\n';
|
|
76
|
+
}
|
|
77
|
+
/** A one-event calendar, named after the event. */
|
|
78
|
+
export function eventToIcs(ev, cal) {
|
|
79
|
+
return wrapCalendar(ev.name, vevent(ev, cal), cal);
|
|
80
|
+
}
|
|
81
|
+
/** A multi-event feed named `cal.calendarName`. */
|
|
82
|
+
export function eventsToIcs(events, cal) {
|
|
83
|
+
return wrapCalendar(cal.calendarName, events.flatMap((ev) => vevent(ev, cal)), cal);
|
|
84
|
+
}
|
|
85
|
+
/** Google Calendar prefill URL for the same all-day event. */
|
|
86
|
+
export function googleCalendarUrl(ev, cal) {
|
|
87
|
+
const params = new URLSearchParams({
|
|
88
|
+
action: 'TEMPLATE',
|
|
89
|
+
text: ev.name,
|
|
90
|
+
dates: `${compact(ev.startDate)}/${nextDayCompact(ev.endDate || ev.startDate)}`,
|
|
91
|
+
details: `${(ev.description || '').slice(0, 300)}\n${ev.url}`.trim(),
|
|
92
|
+
location: cal.location,
|
|
93
|
+
});
|
|
94
|
+
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
|
95
|
+
}
|
|
96
|
+
/** Month numbers (1-12) an event overlaps, for by-month buckets. */
|
|
97
|
+
export function monthsCovered(startDate, endDate) {
|
|
98
|
+
const start = new Date(`${startDate}T00:00:00Z`);
|
|
99
|
+
const end = new Date(`${endDate || startDate}T00:00:00Z`);
|
|
100
|
+
const months = new Set();
|
|
101
|
+
const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1));
|
|
102
|
+
while (cursor <= end) {
|
|
103
|
+
months.add(cursor.getUTCMonth() + 1);
|
|
104
|
+
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
105
|
+
}
|
|
106
|
+
return [...months];
|
|
107
|
+
}
|
package/dist/dates/index.d.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { formatUtcDate, formatUtcDateRange } from './formatDate.ts';
|
|
2
|
+
export { buildNow } from './buildClock.ts';
|
|
3
|
+
export { maxDate, latestDateModified, NEW_BADGE_DAYS, isNew } from './lastmod.ts';
|
|
4
|
+
export { eventToIcs, eventsToIcs, googleCalendarUrl, monthsCovered } from './ics.ts';
|
|
5
|
+
export type { IcsEvent, IcsCalendar } from './ics.ts';
|
package/dist/dates/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-core/dates.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/dates.
|
|
2
|
+
export { formatUtcDate, formatUtcDateRange } from "./formatDate.js";
|
|
3
|
+
export { buildNow } from "./buildClock.js";
|
|
4
|
+
export { maxDate, latestDateModified, NEW_BADGE_DAYS, isNew } from "./lastmod.js";
|
|
5
|
+
export { eventToIcs, eventsToIcs, googleCalendarUrl, monthsCovered } from "./ics.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The latest of several `YYYY-MM-DD` strings; empty values are skipped, and none gives 1970-01-01. */
|
|
2
|
+
export declare function maxDate(...dates: string[]): string;
|
|
3
|
+
/** The latest `data.dateModified` across collection entries; entries without one are skipped. */
|
|
4
|
+
export declare function latestDateModified(entries: ReadonlyArray<{
|
|
5
|
+
data: object;
|
|
6
|
+
}>): string;
|
|
7
|
+
/** Freshness window for a "New" badge, in days. */
|
|
8
|
+
export declare const NEW_BADGE_DAYS = 30;
|
|
9
|
+
/**
|
|
10
|
+
* True when `date` is a valid YYYY-MM-DD within the last `days` days of `now`.
|
|
11
|
+
*
|
|
12
|
+
* Compared as ISO strings against a UTC cutoff, so there is no timezone parsing. A future date is not new: nothing
|
|
13
|
+
* gates a future `datePublished`, so a mistyped year would otherwise pin a permanent badge.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isNew(date: string | undefined, days?: number, now?: number): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const EPOCH = '1970-01-01';
|
|
2
|
+
/** The latest of several `YYYY-MM-DD` strings; empty values are skipped, and none gives 1970-01-01. */
|
|
3
|
+
export function maxDate(...dates) {
|
|
4
|
+
let latest = EPOCH;
|
|
5
|
+
for (const d of dates) {
|
|
6
|
+
if (d && d > latest)
|
|
7
|
+
latest = d;
|
|
8
|
+
}
|
|
9
|
+
return latest;
|
|
10
|
+
}
|
|
11
|
+
/** The latest `data.dateModified` across collection entries; entries without one are skipped. */
|
|
12
|
+
export function latestDateModified(entries) {
|
|
13
|
+
let latest = EPOCH;
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const d = entry.data.dateModified;
|
|
16
|
+
if (d && d > latest)
|
|
17
|
+
latest = d;
|
|
18
|
+
}
|
|
19
|
+
return latest;
|
|
20
|
+
}
|
|
21
|
+
/** Freshness window for a "New" badge, in days. */
|
|
22
|
+
export const NEW_BADGE_DAYS = 30;
|
|
23
|
+
/**
|
|
24
|
+
* True when `date` is a valid YYYY-MM-DD within the last `days` days of `now`.
|
|
25
|
+
*
|
|
26
|
+
* Compared as ISO strings against a UTC cutoff, so there is no timezone parsing. A future date is not new: nothing
|
|
27
|
+
* gates a future `datePublished`, so a mistyped year would otherwise pin a permanent badge.
|
|
28
|
+
*/
|
|
29
|
+
export function isNew(date, days = NEW_BADGE_DAYS, now = Date.now()) {
|
|
30
|
+
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
31
|
+
return false;
|
|
32
|
+
const today = new Date(now).toISOString().slice(0, 10);
|
|
33
|
+
const cutoff = new Date(now - days * 86_400_000).toISOString().slice(0, 10);
|
|
34
|
+
return date >= cutoff && date <= today;
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Haversine distance between two coordinates in metres.
|
|
3
|
+
* Build-time only: proximity pages sort items by it.
|
|
4
|
+
*/
|
|
5
|
+
export declare function haversineMetres(lat1: number, lng1: number, lat2: number, lng2: number): number;
|
|
6
|
+
/** Format metres as a human-readable distance badge string. */
|
|
7
|
+
export declare function formatDistance(metres: number): string;
|
|
8
|
+
/** Estimate walking time in minutes (~80 m/min pedestrian pace). */
|
|
9
|
+
export declare function walkingMinutes(metres: number): number;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Haversine distance between two coordinates in metres.
|
|
3
|
+
* Build-time only: proximity pages sort items by it.
|
|
4
|
+
*/
|
|
5
|
+
export function haversineMetres(lat1, lng1, lat2, lng2) {
|
|
6
|
+
const R = 6_371_000; // Earth radius in metres
|
|
7
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
8
|
+
const dLat = toRad(lat2 - lat1);
|
|
9
|
+
const dLng = toRad(lng2 - lng1);
|
|
10
|
+
const a = Math.sin(dLat / 2) ** 2 +
|
|
11
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
12
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
13
|
+
}
|
|
14
|
+
/** Format metres as a human-readable distance badge string. */
|
|
15
|
+
export function formatDistance(metres) {
|
|
16
|
+
if (metres < 1000) {
|
|
17
|
+
return `${Math.round(metres)}m`;
|
|
18
|
+
}
|
|
19
|
+
return `${(metres / 1000).toFixed(1)}km`;
|
|
20
|
+
}
|
|
21
|
+
/** Estimate walking time in minutes (~80 m/min pedestrian pace). */
|
|
22
|
+
export function walkingMinutes(metres) {
|
|
23
|
+
return Math.max(1, Math.round(metres / 80));
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { haversineMetres, formatDistance, walkingMinutes } from './distance.ts';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first candidate whose length is within [min, max], else the one that misses the range by the fewest
|
|
3
|
+
* characters (the earliest wins a tie).
|
|
4
|
+
*/
|
|
5
|
+
export function fitLength(candidates, min, max) {
|
|
6
|
+
const miss = (s) => (s.length < min ? min - s.length : s.length > max ? s.length - max : 0);
|
|
7
|
+
return candidates.find((s) => miss(s) === 0) ?? [...candidates].sort((a, b) => miss(a) - miss(b))[0];
|
|
8
|
+
}
|
package/dist/text/index.d.ts
CHANGED
package/dist/text/index.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface ArticleSection {
|
|
2
|
+
body?: string;
|
|
3
|
+
subsections?: Array<{
|
|
4
|
+
body?: string;
|
|
5
|
+
}>;
|
|
6
|
+
}
|
|
7
|
+
interface ArticleLike {
|
|
8
|
+
intro?: string;
|
|
9
|
+
sections?: ArticleSection[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Estimate reading time for an article.
|
|
13
|
+
* Counts words from intro + section bodies + subsection bodies,
|
|
14
|
+
* strips Markdown, and divides by 220 WPM. Minimum 1 minute.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readingTime(article: ArticleLike): number;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
/**
|
|
3
|
+
* Strip Markdown / HTML markup and collapse whitespace so we can count
|
|
4
|
+
* real words. Uses `marked` to normalize Markdown, then removes HTML tags.
|
|
5
|
+
*/
|
|
6
|
+
function stripMarkup(markdown) {
|
|
7
|
+
const html = marked.parse(markdown, { breaks: true, gfm: true, async: false });
|
|
8
|
+
return html
|
|
9
|
+
.replace(/<[^>]+>/g, ' ')
|
|
10
|
+
.replace(/\[\^(\d+)\]/g, ' ')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
function countWords(text) {
|
|
15
|
+
const cleaned = stripMarkup(text);
|
|
16
|
+
if (!cleaned)
|
|
17
|
+
return 0;
|
|
18
|
+
return cleaned.split(/\s+/).filter((w) => w.length > 0).length;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Estimate reading time for an article.
|
|
22
|
+
* Counts words from intro + section bodies + subsection bodies,
|
|
23
|
+
* strips Markdown, and divides by 220 WPM. Minimum 1 minute.
|
|
24
|
+
*/
|
|
25
|
+
export function readingTime(article) {
|
|
26
|
+
let words = 0;
|
|
27
|
+
if (article.intro) {
|
|
28
|
+
words += countWords(article.intro);
|
|
29
|
+
}
|
|
30
|
+
for (const section of article.sections ?? []) {
|
|
31
|
+
if (section.body)
|
|
32
|
+
words += countWords(section.body);
|
|
33
|
+
for (const sub of section.subsections ?? []) {
|
|
34
|
+
if (sub.body)
|
|
35
|
+
words += countWords(sub.body);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Math.max(1, Math.round(words / 220));
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-rc.0",
|
|
4
4
|
"description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./dist/dates/index.d.ts",
|
|
38
38
|
"default": "./dist/dates/index.js"
|
|
39
39
|
},
|
|
40
|
+
"./geo": {
|
|
41
|
+
"types": "./dist/geo/index.d.ts",
|
|
42
|
+
"default": "./dist/geo/index.js"
|
|
43
|
+
},
|
|
40
44
|
"./dom": {
|
|
41
45
|
"types": "./dist/dom/index.d.ts",
|
|
42
46
|
"default": "./dist/dom/index.js"
|
|
@@ -54,6 +58,9 @@
|
|
|
54
58
|
"default": "./dist/types/index.js"
|
|
55
59
|
}
|
|
56
60
|
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"marked": "^18.0.14"
|
|
63
|
+
},
|
|
57
64
|
"peerDependencies": {
|
|
58
65
|
"astro": "^7.0.6"
|
|
59
66
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time "now", pinned to the start of the UTC day.
|
|
3
|
+
*
|
|
4
|
+
* Astro prerenders every page, so a bare `new Date()` in frontmatter bakes the build *instant* into the HTML, and
|
|
5
|
+
* anything derived from it (a moon phase, a countdown) differs between two builds a minute apart. Consumers that
|
|
6
|
+
* need the real instant re-compute client-side, so SSR only paints a no-JS fallback. Truncating to the UTC day
|
|
7
|
+
* matches the convention `validate:dates` uses for "today".
|
|
8
|
+
*
|
|
9
|
+
* Two builds either side of UTC midnight still differ; that cannot drift within one build session.
|
|
10
|
+
*/
|
|
11
|
+
export function buildNow(): Date {
|
|
12
|
+
return new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a date-only ISO string (`YYYY-MM-DD`) for display, anchored to UTC.
|
|
3
|
+
*
|
|
4
|
+
* The `T00:00:00Z` + `timeZone: 'UTC'` pairing is load-bearing: parsing a bare
|
|
5
|
+
* `YYYY-MM-DD` and formatting it in the host timezone drifts the displayed day
|
|
6
|
+
* by one in any negative-UTC-offset locale. Centralizing it here keeps every
|
|
7
|
+
* date on the site off-by-one-safe.
|
|
8
|
+
*
|
|
9
|
+
* @param iso date-only or ISO datetime string (only the date part is used)
|
|
10
|
+
* @param options Intl options (default: long month + day, e.g. "July 14")
|
|
11
|
+
* @param locale BCP-47 locale (default en-US)
|
|
12
|
+
*/
|
|
13
|
+
export function formatUtcDate(
|
|
14
|
+
iso: string | undefined,
|
|
15
|
+
options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' },
|
|
16
|
+
locale: string = 'en-US',
|
|
17
|
+
): string {
|
|
18
|
+
// Guard partial/malformed input: a short string like "2026" would otherwise
|
|
19
|
+
// parse to a valid-but-wrong date (Jan 1), and junk would render "Invalid Date".
|
|
20
|
+
if (!iso || iso.length < 10) return '';
|
|
21
|
+
const date = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
|
|
22
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
23
|
+
return date.toLocaleDateString(locale, { ...options, timeZone: 'UTC' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format a `startDate`/`endDate` pair as a compact display range, UTC-anchored
|
|
28
|
+
* via `formatUtcDate`: "Aug 12" (single day), "Aug 12–13" (same month),
|
|
29
|
+
* "May 29 – Jun 5" (cross-month). Returns '' when `start` is missing/invalid.
|
|
30
|
+
*/
|
|
31
|
+
export function formatUtcDateRange(start?: string, end?: string): string {
|
|
32
|
+
const s = formatUtcDate(start, { month: 'short', day: 'numeric' });
|
|
33
|
+
if (!s) return '';
|
|
34
|
+
if (!end || end.length < 10 || end.slice(0, 10) === start!.slice(0, 10)) return s;
|
|
35
|
+
const e = formatUtcDate(end, { month: 'short', day: 'numeric' });
|
|
36
|
+
if (!e) return s;
|
|
37
|
+
const sameMonth = start!.slice(0, 7) === end.slice(0, 7);
|
|
38
|
+
return sameMonth ? `${s}–${formatUtcDate(end, { day: 'numeric' })}` : `${s} – ${e}`;
|
|
39
|
+
}
|
package/src/dates/ics.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iCalendar (RFC 5545) builders for all-day events, plus the matching Google Calendar prefill URL. Pure string
|
|
3
|
+
* functions; the site supplies its calendar identity.
|
|
4
|
+
*
|
|
5
|
+
* All-day date semantics: DTEND;VALUE=DATE is exclusive, so a single-day event ends the following day and a range
|
|
6
|
+
* ends the day after endDate.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface IcsEvent {
|
|
10
|
+
slug: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** YYYY-MM-DD */
|
|
14
|
+
startDate: string;
|
|
15
|
+
/** YYYY-MM-DD */
|
|
16
|
+
endDate?: string;
|
|
17
|
+
/** Absolute URL of the event page */
|
|
18
|
+
url: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IcsCalendar {
|
|
22
|
+
/** Domain after `@` in each UID, e.g. `example.com`. */
|
|
23
|
+
uidDomain: string;
|
|
24
|
+
/** PRODID value, e.g. `-//Example//events//EN`. */
|
|
25
|
+
prodId: string;
|
|
26
|
+
/** Plain-text location; escaped for the LOCATION line, raw in the Google URL. */
|
|
27
|
+
location: string;
|
|
28
|
+
/** X-WR-CALNAME of the multi-event feed. */
|
|
29
|
+
calendarName: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const compact = (isoDate: string): string => isoDate.replaceAll('-', '');
|
|
33
|
+
|
|
34
|
+
/** Day after the given YYYY-MM-DD, compact form (exclusive DTEND). */
|
|
35
|
+
function nextDayCompact(isoDate: string): string {
|
|
36
|
+
const d = new Date(`${isoDate}T00:00:00Z`);
|
|
37
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
38
|
+
return d.toISOString().slice(0, 10).replaceAll('-', '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** RFC 5545 TEXT escaping: backslash, semicolon, comma, newline. */
|
|
42
|
+
function escapeText(value: string): string {
|
|
43
|
+
return value
|
|
44
|
+
.replaceAll('\\', '\\\\')
|
|
45
|
+
.replaceAll(';', '\\;')
|
|
46
|
+
.replaceAll(',', '\\,')
|
|
47
|
+
.replaceAll(/\r?\n/g, '\\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fold lines longer than 75 octets with CRLF + space (RFC 5545 §3.1). */
|
|
51
|
+
function foldLine(line: string): string {
|
|
52
|
+
const bytes = new TextEncoder().encode(line);
|
|
53
|
+
if (bytes.length <= 75) return line;
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
let current = '';
|
|
56
|
+
let currentBytes = 0;
|
|
57
|
+
for (const ch of line) {
|
|
58
|
+
const chBytes = new TextEncoder().encode(ch).length;
|
|
59
|
+
const limit = parts.length === 0 ? 75 : 74; // continuation lines lose 1 octet to the leading space
|
|
60
|
+
if (currentBytes + chBytes > limit) {
|
|
61
|
+
parts.push(current);
|
|
62
|
+
current = ch;
|
|
63
|
+
currentBytes = chBytes;
|
|
64
|
+
} else {
|
|
65
|
+
current += ch;
|
|
66
|
+
currentBytes += chBytes;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (current) parts.push(current);
|
|
70
|
+
return parts.join('\r\n ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function vevent(ev: IcsEvent, cal: IcsCalendar): string[] {
|
|
74
|
+
const start = compact(ev.startDate);
|
|
75
|
+
const end = nextDayCompact(ev.endDate || ev.startDate);
|
|
76
|
+
return [
|
|
77
|
+
'BEGIN:VEVENT',
|
|
78
|
+
// Deterministic UID + DTSTAMP so rebuilds don't churn the committed output.
|
|
79
|
+
`UID:${ev.slug}-${start}@${cal.uidDomain}`,
|
|
80
|
+
`DTSTAMP:${start}T000000Z`,
|
|
81
|
+
`DTSTART;VALUE=DATE:${start}`,
|
|
82
|
+
`DTEND;VALUE=DATE:${end}`,
|
|
83
|
+
`SUMMARY:${escapeText(ev.name)}`,
|
|
84
|
+
...(ev.description ? [`DESCRIPTION:${escapeText(ev.description.slice(0, 300))}`] : []),
|
|
85
|
+
`LOCATION:${escapeText(cal.location)}`,
|
|
86
|
+
`URL:${ev.url}`,
|
|
87
|
+
'END:VEVENT',
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function wrapCalendar(name: string, veventLines: string[], cal: IcsCalendar): string {
|
|
92
|
+
const lines = [
|
|
93
|
+
'BEGIN:VCALENDAR',
|
|
94
|
+
'VERSION:2.0',
|
|
95
|
+
`PRODID:${cal.prodId}`,
|
|
96
|
+
'CALSCALE:GREGORIAN',
|
|
97
|
+
`X-WR-CALNAME:${escapeText(name)}`,
|
|
98
|
+
...veventLines,
|
|
99
|
+
'END:VCALENDAR',
|
|
100
|
+
];
|
|
101
|
+
return lines.map(foldLine).join('\r\n') + '\r\n';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A one-event calendar, named after the event. */
|
|
105
|
+
export function eventToIcs(ev: IcsEvent, cal: IcsCalendar): string {
|
|
106
|
+
return wrapCalendar(ev.name, vevent(ev, cal), cal);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A multi-event feed named `cal.calendarName`. */
|
|
110
|
+
export function eventsToIcs(events: IcsEvent[], cal: IcsCalendar): string {
|
|
111
|
+
return wrapCalendar(cal.calendarName, events.flatMap((ev) => vevent(ev, cal)), cal);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Google Calendar prefill URL for the same all-day event. */
|
|
115
|
+
export function googleCalendarUrl(ev: IcsEvent, cal: IcsCalendar): string {
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
action: 'TEMPLATE',
|
|
118
|
+
text: ev.name,
|
|
119
|
+
dates: `${compact(ev.startDate)}/${nextDayCompact(ev.endDate || ev.startDate)}`,
|
|
120
|
+
details: `${(ev.description || '').slice(0, 300)}\n${ev.url}`.trim(),
|
|
121
|
+
location: cal.location,
|
|
122
|
+
});
|
|
123
|
+
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Month numbers (1-12) an event overlaps, for by-month buckets. */
|
|
127
|
+
export function monthsCovered(startDate: string, endDate?: string): number[] {
|
|
128
|
+
const start = new Date(`${startDate}T00:00:00Z`);
|
|
129
|
+
const end = new Date(`${endDate || startDate}T00:00:00Z`);
|
|
130
|
+
const months = new Set<number>();
|
|
131
|
+
const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1));
|
|
132
|
+
while (cursor <= end) {
|
|
133
|
+
months.add(cursor.getUTCMonth() + 1);
|
|
134
|
+
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
135
|
+
}
|
|
136
|
+
return [...months];
|
|
137
|
+
}
|
package/src/dates/index.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-core/dates.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/dates.
|
|
2
|
+
export { formatUtcDate, formatUtcDateRange } from './formatDate.ts';
|
|
3
|
+
export { buildNow } from './buildClock.ts';
|
|
4
|
+
export { maxDate, latestDateModified, NEW_BADGE_DAYS, isNew } from './lastmod.ts';
|
|
5
|
+
export { eventToIcs, eventsToIcs, googleCalendarUrl, monthsCovered } from './ics.ts';
|
|
6
|
+
export type { IcsEvent, IcsCalendar } from './ics.ts';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const EPOCH = '1970-01-01';
|
|
2
|
+
|
|
3
|
+
/** The latest of several `YYYY-MM-DD` strings; empty values are skipped, and none gives 1970-01-01. */
|
|
4
|
+
export function maxDate(...dates: string[]): string {
|
|
5
|
+
let latest = EPOCH;
|
|
6
|
+
for (const d of dates) {
|
|
7
|
+
if (d && d > latest) latest = d;
|
|
8
|
+
}
|
|
9
|
+
return latest;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The latest `data.dateModified` across collection entries; entries without one are skipped. */
|
|
13
|
+
export function latestDateModified(entries: ReadonlyArray<{ data: object }>): string {
|
|
14
|
+
let latest = EPOCH;
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const d = (entry.data as { dateModified?: string }).dateModified;
|
|
17
|
+
if (d && d > latest) latest = d;
|
|
18
|
+
}
|
|
19
|
+
return latest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Freshness window for a "New" badge, in days. */
|
|
23
|
+
export const NEW_BADGE_DAYS = 30;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when `date` is a valid YYYY-MM-DD within the last `days` days of `now`.
|
|
27
|
+
*
|
|
28
|
+
* Compared as ISO strings against a UTC cutoff, so there is no timezone parsing. A future date is not new: nothing
|
|
29
|
+
* gates a future `datePublished`, so a mistyped year would otherwise pin a permanent badge.
|
|
30
|
+
*/
|
|
31
|
+
export function isNew(date: string | undefined, days = NEW_BADGE_DAYS, now = Date.now()): boolean {
|
|
32
|
+
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return false;
|
|
33
|
+
const today = new Date(now).toISOString().slice(0, 10);
|
|
34
|
+
const cutoff = new Date(now - days * 86_400_000).toISOString().slice(0, 10);
|
|
35
|
+
return date >= cutoff && date <= today;
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Haversine distance between two coordinates in metres.
|
|
3
|
+
* Build-time only: proximity pages sort items by it.
|
|
4
|
+
*/
|
|
5
|
+
export function haversineMetres(
|
|
6
|
+
lat1: number, lng1: number,
|
|
7
|
+
lat2: number, lng2: number,
|
|
8
|
+
): number {
|
|
9
|
+
const R = 6_371_000; // Earth radius in metres
|
|
10
|
+
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
|
11
|
+
const dLat = toRad(lat2 - lat1);
|
|
12
|
+
const dLng = toRad(lng2 - lng1);
|
|
13
|
+
const a =
|
|
14
|
+
Math.sin(dLat / 2) ** 2 +
|
|
15
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
16
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Format metres as a human-readable distance badge string. */
|
|
20
|
+
export function formatDistance(metres: number): string {
|
|
21
|
+
if (metres < 1000) {
|
|
22
|
+
return `${Math.round(metres)}m`;
|
|
23
|
+
}
|
|
24
|
+
return `${(metres / 1000).toFixed(1)}km`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Estimate walking time in minutes (~80 m/min pedestrian pace). */
|
|
28
|
+
export function walkingMinutes(metres: number): number {
|
|
29
|
+
return Math.max(1, Math.round(metres / 80));
|
|
30
|
+
}
|
package/src/geo/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first candidate whose length is within [min, max], else the one that misses the range by the fewest
|
|
3
|
+
* characters (the earliest wins a tie).
|
|
4
|
+
*/
|
|
5
|
+
export function fitLength(candidates: string[], min: number, max: number): string {
|
|
6
|
+
const miss = (s: string) => (s.length < min ? min - s.length : s.length > max ? s.length - max : 0);
|
|
7
|
+
return candidates.find((s) => miss(s) === 0) ?? [...candidates].sort((a, b) => miss(a) - miss(b))[0];
|
|
8
|
+
}
|
package/src/text/index.ts
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
|
|
3
|
+
interface ArticleSection {
|
|
4
|
+
body?: string;
|
|
5
|
+
subsections?: Array<{ body?: string }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ArticleLike {
|
|
9
|
+
intro?: string;
|
|
10
|
+
sections?: ArticleSection[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Strip Markdown / HTML markup and collapse whitespace so we can count
|
|
15
|
+
* real words. Uses `marked` to normalize Markdown, then removes HTML tags.
|
|
16
|
+
*/
|
|
17
|
+
function stripMarkup(markdown: string): string {
|
|
18
|
+
const html = marked.parse(markdown, { breaks: true, gfm: true, async: false });
|
|
19
|
+
return html
|
|
20
|
+
.replace(/<[^>]+>/g, ' ')
|
|
21
|
+
.replace(/\[\^(\d+)\]/g, ' ')
|
|
22
|
+
.replace(/\s+/g, ' ')
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function countWords(text: string): number {
|
|
27
|
+
const cleaned = stripMarkup(text);
|
|
28
|
+
if (!cleaned) return 0;
|
|
29
|
+
return cleaned.split(/\s+/).filter((w) => w.length > 0).length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Estimate reading time for an article.
|
|
34
|
+
* Counts words from intro + section bodies + subsection bodies,
|
|
35
|
+
* strips Markdown, and divides by 220 WPM. Minimum 1 minute.
|
|
36
|
+
*/
|
|
37
|
+
export function readingTime(article: ArticleLike): number {
|
|
38
|
+
let words = 0;
|
|
39
|
+
|
|
40
|
+
if (article.intro) {
|
|
41
|
+
words += countWords(article.intro);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const section of article.sections ?? []) {
|
|
45
|
+
if (section.body) words += countWords(section.body);
|
|
46
|
+
for (const sub of section.subsections ?? []) {
|
|
47
|
+
if (sub.body) words += countWords(sub.body);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Math.max(1, Math.round(words / 220));
|
|
52
|
+
}
|