@orakl/shared 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.
Files changed (56) hide show
  1. package/LICENSE +662 -0
  2. package/dist/analytics.d.ts +21 -0
  3. package/dist/analytics.js +21 -0
  4. package/dist/avatar-helpers.d.ts +46 -0
  5. package/dist/avatar-helpers.js +69 -0
  6. package/dist/avatars.d.ts +6 -0
  7. package/dist/avatars.js +138 -0
  8. package/dist/badges.d.ts +126 -0
  9. package/dist/badges.js +667 -0
  10. package/dist/constants/auth.constants.d.ts +26 -0
  11. package/dist/constants/auth.constants.js +94 -0
  12. package/dist/constants/feature-flags.constants.d.ts +138 -0
  13. package/dist/constants/feature-flags.constants.js +119 -0
  14. package/dist/constants/font.constants.d.ts +9 -0
  15. package/dist/constants/font.constants.js +14 -0
  16. package/dist/constants/lobby.constants.d.ts +2 -0
  17. package/dist/constants/lobby.constants.js +438 -0
  18. package/dist/constants/patterns.constants.d.ts +4 -0
  19. package/dist/constants/patterns.constants.js +4 -0
  20. package/dist/constants/theme.constants.d.ts +24 -0
  21. package/dist/constants/theme.constants.js +69 -0
  22. package/dist/env.d.ts +3 -0
  23. package/dist/env.js +5 -0
  24. package/dist/font.d.ts +4 -0
  25. package/dist/font.js +8 -0
  26. package/dist/history-range.d.ts +13 -0
  27. package/dist/history-range.js +29 -0
  28. package/dist/index.d.ts +23 -0
  29. package/dist/index.js +26 -0
  30. package/dist/lobby.d.ts +16 -0
  31. package/dist/lobby.js +50 -0
  32. package/dist/manage-questions.d.ts +52 -0
  33. package/dist/manage-questions.js +26 -0
  34. package/dist/math.d.ts +27 -0
  35. package/dist/math.js +30 -0
  36. package/dist/murmurhash.d.ts +9 -0
  37. package/dist/murmurhash.js +52 -0
  38. package/dist/pricing.d.ts +32 -0
  39. package/dist/pricing.js +32 -0
  40. package/dist/profile-tabs.d.ts +8 -0
  41. package/dist/profile-tabs.js +18 -0
  42. package/dist/quiz-routes.d.ts +28 -0
  43. package/dist/quiz-routes.js +65 -0
  44. package/dist/sanitize.d.ts +38 -0
  45. package/dist/sanitize.js +109 -0
  46. package/dist/schemas/question.schema.d.ts +1 -0
  47. package/dist/schemas/question.schema.js +1 -0
  48. package/dist/solo-entitlements.d.ts +28 -0
  49. package/dist/solo-entitlements.js +56 -0
  50. package/dist/theme.d.ts +39 -0
  51. package/dist/theme.js +139 -0
  52. package/dist/types/auth.d.ts +143 -0
  53. package/dist/types/auth.js +28 -0
  54. package/dist/utils.d.ts +50 -0
  55. package/dist/utils.js +133 -0
  56. package/package.json +45 -0
@@ -0,0 +1,56 @@
1
+ const GUEST = {
2
+ maxCategories: 3,
3
+ maxQuestions: 10,
4
+ difficulty: "all",
5
+ builtInOnly: true,
6
+ leaderboardEligible: false,
7
+ };
8
+ const AUTH = {
9
+ maxCategories: null,
10
+ maxQuestions: null,
11
+ difficulty: "all",
12
+ builtInOnly: false,
13
+ leaderboardEligible: true,
14
+ };
15
+ export function getEntitlements(isGuest) {
16
+ return isGuest ? GUEST : AUTH;
17
+ }
18
+ export function checkEntitlements(config, entitlements) {
19
+ // Endless is a signed-in mode: it's variable-length (no question cap) and
20
+ // would otherwise let a guest bypass the per-session question limit.
21
+ if (config.mode === "endless" && entitlements.builtInOnly) {
22
+ return {
23
+ code: "entitlement_exceeded",
24
+ message: "Endless mode is for signed-in players.",
25
+ upgradeHint: "Sign in to brave the endless gauntlet.",
26
+ };
27
+ }
28
+ if (entitlements.maxCategories !== null &&
29
+ config.categoryIds.length > entitlements.maxCategories) {
30
+ return {
31
+ code: "entitlement_exceeded",
32
+ message: `Guests may select up to ${entitlements.maxCategories} categories.`,
33
+ upgradeHint: "Sign in to unlock all categories.",
34
+ };
35
+ }
36
+ if (entitlements.maxQuestions !== null &&
37
+ config.mode !== "endless" &&
38
+ config.questionCount > entitlements.maxQuestions) {
39
+ return {
40
+ code: "entitlement_exceeded",
41
+ message: `Guests may play up to ${entitlements.maxQuestions} questions per session.`,
42
+ upgradeHint: "Sign in to unlock longer sessions.",
43
+ };
44
+ }
45
+ if (entitlements.difficulty !== "all" ||
46
+ (entitlements.maxCategories !== null && config.difficulty !== "all")) {
47
+ if (config.difficulty !== "all" && entitlements.builtInOnly) {
48
+ return {
49
+ code: "entitlement_exceeded",
50
+ message: "Guests cannot filter by difficulty.",
51
+ upgradeHint: "Sign in to target specific difficulty levels.",
52
+ };
53
+ }
54
+ }
55
+ return null;
56
+ }
@@ -0,0 +1,39 @@
1
+ export { DEFAULT_THEME, LOGO_SCHEDULE, PHASE_COLORS, PHASE_CONFIG, PHASE_TIMES, SKY_PHASES, SKY_SCHEDULE, SYSTEM_PREF_COOKIE, THEME_COOKIE, THEME_LABELS, } from "./constants/theme.constants.ts";
2
+ export type SkyPhase = "night" | "dawn" | "morning" | "noon" | "evening" | "sunset" | "dusk";
3
+ /** Core modes plus every sky phase as a fixed time-of-day override. */
4
+ export type ThemeMode = "dark" | "light" | "system" | "dynamic" | SkyPhase;
5
+ export type ResolvedTheme = {
6
+ readonly mode: ThemeMode;
7
+ /** Effective dark/light for body & CSS token switching. */
8
+ readonly effective: "dark" | "light";
9
+ readonly phase: SkyPhase;
10
+ readonly showStars: boolean;
11
+ readonly showSky: boolean;
12
+ readonly showBirds: boolean;
13
+ /** Sky gradient name passed to Sky component. */
14
+ readonly skyTimeOfDay: "dawn" | "morning" | "noon" | "evening" | "sunset" | "dusk";
15
+ /**
16
+ * For system mode the SSR renders both backgrounds in the DOM so the
17
+ * ThemeScript / CSS can show the correct one without a full reload.
18
+ */
19
+ readonly renderBoth: boolean;
20
+ };
21
+ /** A sky phase is dark once its star opacity rises above zero. */
22
+ export declare function effectiveThemeForPhase(phase: SkyPhase): "dark" | "light";
23
+ export declare function getSkyPhase(hour: number): SkyPhase;
24
+ /**
25
+ * Compact lookup for the inline blocking script in app.html.
26
+ * Comma-separated "phase:effective" for hours 0–23, derived from
27
+ * HOUR_TO_PHASE + PHASE_CONFIG so it stays in sync automatically.
28
+ */
29
+ export declare const DYNAMIC_LOOKUP_STRING: string;
30
+ /**
31
+ * Resolve what background to show.
32
+ * Optimized with O(1) lookups and zero allocations on the hot path.
33
+ * @param mode User-chosen theme mode (including optional sky-phase override)
34
+ * @param hour Current local 24h hour (0–23)
35
+ * @param sysPref 'dark' | 'light' from client hint or cookie, system mode only
36
+ */
37
+ export declare function resolveTheme(mode: ThemeMode, hour: number, sysPref?: "dark" | "light"): ResolvedTheme;
38
+ export declare function parseThemeMode(value: string | undefined): ThemeMode;
39
+ export declare function parseSystemPref(value: string | undefined): "dark" | "light" | undefined;
package/dist/theme.js ADDED
@@ -0,0 +1,139 @@
1
+ import { DEFAULT_THEME, PHASE_CONFIG, SKY_PHASES, SKY_SCHEDULE, } from "./constants/theme.constants.js";
2
+ export { DEFAULT_THEME, LOGO_SCHEDULE, PHASE_COLORS, PHASE_CONFIG, PHASE_TIMES, SKY_PHASES, SKY_SCHEDULE, SYSTEM_PREF_COOKIE, THEME_COOKIE, THEME_LABELS, } from "./constants/theme.constants.js";
3
+ function toSkyTimeOfDay(phase) {
4
+ if (phase === "night")
5
+ return "morning";
6
+ return phase;
7
+ }
8
+ /** A sky phase is dark once its star opacity rises above zero. */
9
+ export function effectiveThemeForPhase(phase) {
10
+ // A phase outside the table (a stale stored mode) reads as light.
11
+ const config = PHASE_CONFIG[phase];
12
+ return config && config.starOpacity > 0 ? "dark" : "light";
13
+ }
14
+ const SKY_PHASE_SET = new Set(SKY_PHASES);
15
+ /** Pre-calculated 24-hour phase table for O(1) lookup. */
16
+ const HOUR_TO_PHASE = Array.from({ length: 24 }, (_, hour) => {
17
+ for (const [phase, [start, end]] of Object.entries(SKY_SCHEDULE)) {
18
+ if (start > end) {
19
+ if (hour >= start || hour < end)
20
+ return phase;
21
+ }
22
+ else if (hour >= start && hour < end) {
23
+ return phase;
24
+ }
25
+ }
26
+ return "night";
27
+ });
28
+ export function getSkyPhase(hour) {
29
+ return HOUR_TO_PHASE[hour % 24] ?? "night";
30
+ }
31
+ /** Pre-calculated theme results for all fixed sky phases. */
32
+ const THEME_BY_SKY_PHASE = Object.fromEntries(SKY_PHASES.map((p) => [
33
+ p,
34
+ {
35
+ mode: p,
36
+ effective: effectiveThemeForPhase(p),
37
+ phase: p,
38
+ showStars: PHASE_CONFIG[p].starOpacity > 0,
39
+ showSky: PHASE_CONFIG[p].showSky,
40
+ showBirds: PHASE_CONFIG[p].birdOpacity > 0,
41
+ skyTimeOfDay: toSkyTimeOfDay(p),
42
+ renderBoth: false,
43
+ },
44
+ ]));
45
+ const THEME_DARK = {
46
+ mode: "dark",
47
+ effective: "dark",
48
+ phase: "night",
49
+ showStars: true,
50
+ showSky: false,
51
+ showBirds: false,
52
+ skyTimeOfDay: "morning",
53
+ renderBoth: false,
54
+ };
55
+ const THEME_LIGHT = {
56
+ mode: "light",
57
+ effective: "light",
58
+ phase: "morning",
59
+ showStars: false,
60
+ showSky: true,
61
+ showBirds: true,
62
+ skyTimeOfDay: "morning",
63
+ renderBoth: false,
64
+ };
65
+ const THEME_SYSTEM_DARK = {
66
+ mode: "system",
67
+ effective: "dark",
68
+ phase: "night",
69
+ showStars: true,
70
+ showSky: true,
71
+ showBirds: true,
72
+ skyTimeOfDay: "morning",
73
+ renderBoth: true,
74
+ };
75
+ const THEME_SYSTEM_LIGHT = {
76
+ mode: "system",
77
+ effective: "light",
78
+ phase: "morning",
79
+ showStars: true,
80
+ showSky: true,
81
+ showBirds: true,
82
+ skyTimeOfDay: "morning",
83
+ renderBoth: true,
84
+ };
85
+ /** Pre-calculated theme results for dynamic mode by hour. */
86
+ const THEME_DYNAMIC_BY_HOUR = HOUR_TO_PHASE.map((p) => ({
87
+ ...THEME_BY_SKY_PHASE[p],
88
+ mode: "dynamic",
89
+ }));
90
+ /**
91
+ * Compact lookup for the inline blocking script in app.html.
92
+ * Comma-separated "phase:effective" for hours 0–23, derived from
93
+ * HOUR_TO_PHASE + PHASE_CONFIG so it stays in sync automatically.
94
+ */
95
+ export const DYNAMIC_LOOKUP_STRING = HOUR_TO_PHASE.map((p) => `${p}:${effectiveThemeForPhase(p)}`).join(",");
96
+ /**
97
+ * Resolve what background to show.
98
+ * Optimized with O(1) lookups and zero allocations on the hot path.
99
+ * @param mode User-chosen theme mode (including optional sky-phase override)
100
+ * @param hour Current local 24h hour (0–23)
101
+ * @param sysPref 'dark' | 'light' from client hint or cookie, system mode only
102
+ */
103
+ export function resolveTheme(mode, hour, sysPref) {
104
+ // 1. Check fixed sky phase overrides first (e.g. mode === 'sunset')
105
+ if (SKY_PHASE_SET.has(mode)) {
106
+ return THEME_BY_SKY_PHASE[mode];
107
+ }
108
+ // 2. Main mode switching
109
+ switch (mode) {
110
+ case "dark":
111
+ return THEME_DARK;
112
+ case "light":
113
+ return THEME_LIGHT;
114
+ case "system":
115
+ return sysPref === "light" ? THEME_SYSTEM_LIGHT : THEME_SYSTEM_DARK;
116
+ case "dynamic":
117
+ return THEME_DYNAMIC_BY_HOUR[hour % 24] ?? THEME_DYNAMIC_BY_HOUR[0];
118
+ default:
119
+ // Unreachable given parseThemeMode() validation
120
+ return THEME_DARK;
121
+ }
122
+ }
123
+ const VALID_MODES = new Set([
124
+ "dark",
125
+ "light",
126
+ "system",
127
+ "dynamic",
128
+ ...SKY_PHASES,
129
+ ]);
130
+ export function parseThemeMode(value) {
131
+ if (value && VALID_MODES.has(value))
132
+ return value;
133
+ return DEFAULT_THEME;
134
+ }
135
+ export function parseSystemPref(value) {
136
+ if (value === "dark" || value === "light")
137
+ return value;
138
+ return undefined;
139
+ }
@@ -0,0 +1,143 @@
1
+ import type { PowerShape } from "../constants/auth.constants.ts";
2
+ import { ALL_POWERS, ANON_USER, powerDescription, powerShape, ROLE_HIERARCHY } from "../constants/auth.constants.ts";
3
+ export type { PowerShape };
4
+ export { ALL_POWERS, ANON_USER, powerDescription, powerShape, ROLE_HIERARCHY };
5
+ /**
6
+ * Account role. `anonymous`/`display` hold no account. `member` is the base
7
+ * signed-in tier. `curator` and `admin` hold elevated grants.
8
+ */
9
+ export type UserRole = "anonymous" | "display" | "member" | "curator" | "admin";
10
+ /**
11
+ * A capability granted to an account, independent of its role. Every name here
12
+ * is declared at a site that reads it: most are access gates, and the one that
13
+ * is not (`is-power-user`) changes what the UI renders. A grantable name that
14
+ * nothing reads at all is not access control, however much it looks like it.
15
+ *
16
+ * A gating power either **supplements** a role gate or **replaces** it — each
17
+ * is annotated below, and the enforcement site is the authority.
18
+ */
19
+ export type UserPower =
20
+ /**
21
+ * Replaces the role gate on `/mimic/*` in production, which 404s for
22
+ * everyone else. Dev is unconditional.
23
+ */
24
+ "can-access-mimic"
25
+ /**
26
+ * Replaces the role gate on the feedback widget — both the FAB's visibility
27
+ * and `POST /api/feedback`. The `FEEDBACK_WIDGET` flag applies on top.
28
+ */
29
+ | "can-send-feedback"
30
+ /**
31
+ * Replaces the role gate on `/manage/import`, `POST /api/manage/import-questions`
32
+ * and `POST /api/manage/check-fingerprints`. The `ENABLE_IMPORTS` flag applies
33
+ * on top.
34
+ */
35
+ | "can-import-quizzes"
36
+ /** Supplements the admin role on `/admin/feature-flags` and its endpoint. */
37
+ | "can-manage-features"
38
+ /**
39
+ * Supplements the curator role on question authoring: `/curator/questions`,
40
+ * `/api/custom-questions` (every method), `POST /api/upload-image`, and the
41
+ * authoring form's inline category create (`POST /api/categories` and its
42
+ * `/api/topics` alias). The `/curator` row in `ROUTE_GUARDS` refuses a
43
+ * non-curator first, so this narrows who among curators may author — it
44
+ * opens the surface to nobody else. Category *management* is a different
45
+ * trust: `can-manage-categories`.
46
+ */
47
+ | "can-add-questions"
48
+ /**
49
+ * Replaces the role gate on the quiz taxonomy: `/manage/categories` and
50
+ * `/api/manage/categories` (every method).
51
+ */
52
+ | "can-manage-categories"
53
+ /**
54
+ * Replaces the role gate on review of player-reported questions:
55
+ * `/manage/flagged-questions` and `/api/manage/flagged-questions/*`. Separate
56
+ * from `can-manage-categories` — reviewing a report is not the same trust as
57
+ * editing what every quiz draws from.
58
+ */
59
+ | "can-moderate-questions"
60
+ /**
61
+ * Replaces the role gate on the authored-questions dashboard:
62
+ * `/manage/questions`, `GET /api/manage/questions`,
63
+ * `GET /api/manage/questions/[id]`,
64
+ * `POST /api/manage/questions/[id]/publish`,
65
+ * `POST /api/manage/questions/[id]/unpublish`,
66
+ * `POST /api/manage/questions/[id]/republish`,
67
+ * `POST /api/manage/questions/publish` and
68
+ * `GET /api/manage/questions/[id]/revisions`. Every curator's questions,
69
+ * with their author; promotes one or several into the shared bank,
70
+ * withdraws one, or reconfirms one an author edited since promotion; reads
71
+ * a question's revision history with each edit's actor.
72
+ */
73
+ | "can-publish-questions"
74
+ /**
75
+ * Collapses every `FieldDescription` info-i to nothing, for an operator who
76
+ * knows the fields and wants the setup forms bare. Gates no route and no
77
+ * data — the only power that grants nothing.
78
+ */
79
+ | "is-power-user"
80
+ /**
81
+ * Replaces the role gate on avatar curation: `/manage/avatars`,
82
+ * `PUT /api/avatars` and `POST /api/manage/sync-avatars`.
83
+ */
84
+ | "can-manage-avatars"
85
+ /**
86
+ * Supplements the member role on the curation-request card and
87
+ * `POST /api/profile/curation-request`. A member without it never sees the
88
+ * apply control.
89
+ */
90
+ | "can-apply-for-curation"
91
+ /** Lets a curator start a game with fewer than two other players. */
92
+ | "can-1v1";
93
+ export interface AuthUser {
94
+ id: string;
95
+ email: string | null;
96
+ nickname: string | null;
97
+ avatar: string | null;
98
+ role: UserRole;
99
+ powers: UserPower[];
100
+ /** Lobby a player token was issued for; null for accounts/anonymous. */
101
+ lobbyCode: string | null;
102
+ /**
103
+ * Set when the game token behind this identity was minted as the curator
104
+ * token of `lobbyCode` (lobby creation re-mints it, map #1184 decision on
105
+ * #1210). `role` stays the account role, so a member hosting on the free
106
+ * allowance is a curator for their lobby and a member everywhere else.
107
+ */
108
+ lobbyRole?: "curator";
109
+ /**
110
+ * The account role as the DB row or the token's `accountRole` claim
111
+ * states it — never the fail-safe fallback to the lobby role. Absent when
112
+ * neither is known, so a re-mint carries no claim rather than a guess.
113
+ */
114
+ accountRole?: UserRole;
115
+ /** Account email confirmed via OTP. Always false for anonymous/rowless tokens. */
116
+ emailVerified: boolean;
117
+ /** Session id of the account token backing this identity (#824); null for
118
+ * rowless/anonymous. Lets routes that re-mint an account token (lobby
119
+ * create) keep it bound to the same session row. */
120
+ sid: string | null;
121
+ /** The `users.id` this identity resolves to: the row behind a
122
+ * session-backed token, or the account a rowless lobby player token was
123
+ * minted for (a signed claim carried from the join, #953 — not a session;
124
+ * session-only gates read `sid`). Absent for anonymous, display and guest
125
+ * players. */
126
+ accountUserId?: string;
127
+ }
128
+ /**
129
+ * Narrow an untrusted value — a DB column, a token claim, a query param — to a
130
+ * declared role. Both predicates read the same constants the unions are built
131
+ * from, so neither can accept a name the type does not.
132
+ */
133
+ export declare function isUserRole(value: unknown): value is UserRole;
134
+ export declare function isUserPower(value: unknown): value is UserPower;
135
+ /** An untrusted role, or `anonymous` — an undeclared name grants nothing. */
136
+ export declare function parseRole(value: unknown): UserRole;
137
+ /** Every declared power in an untrusted list; undeclared names are dropped. */
138
+ export declare function parsePowerList(value: unknown): UserPower[];
139
+ export declare function hasRole(userRole: UserRole, required: UserRole): boolean;
140
+ /** Takes anything carrying powers, so nav config and route guards can share it. */
141
+ export declare function hasPower(user: {
142
+ powers: UserPower[];
143
+ }, power: UserPower): boolean;
@@ -0,0 +1,28 @@
1
+ import { ALL_POWERS, ANON_USER, powerDescription, powerShape, ROLE_HIERARCHY, } from "../constants/auth.constants.js";
2
+ export { ALL_POWERS, ANON_USER, powerDescription, powerShape, ROLE_HIERARCHY };
3
+ /**
4
+ * Narrow an untrusted value — a DB column, a token claim, a query param — to a
5
+ * declared role. Both predicates read the same constants the unions are built
6
+ * from, so neither can accept a name the type does not.
7
+ */
8
+ export function isUserRole(value) {
9
+ return typeof value === "string" && Object.hasOwn(ROLE_HIERARCHY, value);
10
+ }
11
+ export function isUserPower(value) {
12
+ return typeof value === "string" && ALL_POWERS.includes(value);
13
+ }
14
+ /** An untrusted role, or `anonymous` — an undeclared name grants nothing. */
15
+ export function parseRole(value) {
16
+ return isUserRole(value) ? value : "anonymous";
17
+ }
18
+ /** Every declared power in an untrusted list; undeclared names are dropped. */
19
+ export function parsePowerList(value) {
20
+ return Array.isArray(value) ? value.filter(isUserPower) : [];
21
+ }
22
+ export function hasRole(userRole, required) {
23
+ return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[required];
24
+ }
25
+ /** Takes anything carrying powers, so nav config and route guards can share it. */
26
+ export function hasPower(user, power) {
27
+ return user.powers.includes(power);
28
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Create a URL-friendly slug from a string.
3
+ * Converts to lowercase, removes diacritics, and replaces spaces with hyphens.
4
+ * @param text - The text to slugify.
5
+ * @returns A URL-friendly slug.
6
+ */
7
+ export declare function slugify(text: string): string;
8
+ /**
9
+ * Get a formatted date for a given date.
10
+ * @param date - The date to display.
11
+ * @returns The formatted date.
12
+ */
13
+ export declare function getFormattedDate(date: string): string;
14
+ /**
15
+ * Get a formatted time for a given date.
16
+ * @param date - The date to display.
17
+ * @returns The formatted time.
18
+ */
19
+ export declare function getFormattedTime(date: string): string;
20
+ /**
21
+ * Get a relative date for a given date.
22
+ * @param date - The date to display.
23
+ * @returns The relative date.
24
+ */
25
+ export declare function getRelativeDate(date: string): string;
26
+ /**
27
+ * Get a display date for a given date.
28
+ * @param date - The date to display.
29
+ * @returns The display date.
30
+ */
31
+ export declare function getDisplayDate(date: string): string;
32
+ /** Fisher-Yates shuffle (returns new array, does not mutate). */
33
+ export declare function shuffleArray<T>(arr: T[]): T[];
34
+ /** Throws if URL is not http/https. */
35
+ export declare function validateMediaUrl(url: string): void;
36
+ /** Returns validated URL or undefined if invalid (logs security warning). */
37
+ export declare function sanitizeMediaUrl(url: string | undefined): string | undefined;
38
+ /**
39
+ * Parse **bold** markdown syntax into <strong> tags.
40
+ * Strips HTML first so no raw markup can pass through.
41
+ * Optimized with an early exit.
42
+ */
43
+ export declare function parseBold(text: string): string;
44
+ /**
45
+ * A timestamp column as an ISO string. SQLite's CURRENT_TIMESTAMP and
46
+ * datetime('now') write "YYYY-MM-DD HH:MM:SS" in UTC, which `new Date()`
47
+ * reads as local time in V8 and refuses outright in Safari; an ISO string
48
+ * (JS-written created_at) passes through unchanged.
49
+ */
50
+ export declare function toIsoTimestamp(value: string): string;
package/dist/utils.js ADDED
@@ -0,0 +1,133 @@
1
+ import { addDays, formatRelative, parseISO } from "date-fns";
2
+ import { HTML_TAGS_RE } from "./constants/patterns.constants.js";
3
+ // Hoisted to avoid recompiling on every slugify() call (called 100s of times during bulk imports)
4
+ const RE_DIACRITICS = /[\u0300-\u036F]/g;
5
+ const RE_WHITESPACE = /\s+/g;
6
+ const RE_NON_SLUG = /[^a-z0-9-]/g;
7
+ const RE_MULTI_HYPHEN = /-+/g;
8
+ const RE_TRIM_HYPHEN = /^-|-$/g;
9
+ /**
10
+ * Create a URL-friendly slug from a string.
11
+ * Converts to lowercase, removes diacritics, and replaces spaces with hyphens.
12
+ * @param text - The text to slugify.
13
+ * @returns A URL-friendly slug.
14
+ */
15
+ export function slugify(text) {
16
+ return text
17
+ .toLowerCase()
18
+ .normalize("NFD")
19
+ .replaceAll(RE_DIACRITICS, "")
20
+ .replaceAll(RE_WHITESPACE, "-")
21
+ .replaceAll(RE_NON_SLUG, "")
22
+ .replaceAll(RE_MULTI_HYPHEN, "-")
23
+ .replaceAll(RE_TRIM_HYPHEN, "");
24
+ }
25
+ // Hoist formatters to avoid expensive re-initialization on every call
26
+ const DATE_FORMATTER = new Intl.DateTimeFormat("ro-RO", {
27
+ month: "long",
28
+ day: "numeric",
29
+ year: "numeric",
30
+ });
31
+ const TIME_FORMATTER = new Intl.DateTimeFormat("ro-RO", {
32
+ hour: "numeric",
33
+ minute: "numeric",
34
+ });
35
+ /**
36
+ * Get a formatted date for a given date.
37
+ * @param date - The date to display.
38
+ * @returns The formatted date.
39
+ */
40
+ export function getFormattedDate(date) {
41
+ return DATE_FORMATTER.format(parseISO(date));
42
+ }
43
+ /**
44
+ * Get a formatted time for a given date.
45
+ * @param date - The date to display.
46
+ * @returns The formatted time.
47
+ */
48
+ export function getFormattedTime(date) {
49
+ return TIME_FORMATTER.format(parseISO(date));
50
+ }
51
+ /**
52
+ * Get a relative date for a given date.
53
+ * @param date - The date to display.
54
+ * @returns The relative date.
55
+ */
56
+ export function getRelativeDate(date) {
57
+ return formatRelative(parseISO(date), new Date());
58
+ }
59
+ /**
60
+ * Get a display date for a given date.
61
+ * @param date - The date to display.
62
+ * @returns The display date.
63
+ */
64
+ export function getDisplayDate(date) {
65
+ const parsedDate = parseISO(date);
66
+ const today = new Date();
67
+ const weekAgo = addDays(today, -7);
68
+ return parsedDate < weekAgo
69
+ ? formatRelative(parsedDate, today)
70
+ : getFormattedDate(date);
71
+ }
72
+ /** Fisher-Yates shuffle (returns new array, does not mutate). */
73
+ export function shuffleArray(arr) {
74
+ const a = [...arr];
75
+ for (let i = a.length - 1; i > 0; i--) {
76
+ const j = Math.floor(Math.random() * (i + 1));
77
+ [a[i], a[j]] = [a[j], a[i]];
78
+ }
79
+ return a;
80
+ }
81
+ /** Throws if URL is not http/https. */
82
+ export function validateMediaUrl(url) {
83
+ const trimmed = url.trim();
84
+ if (!trimmed)
85
+ throw new Error("Media URL is required");
86
+ try {
87
+ const parsed = new URL(trimmed);
88
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
89
+ throw new Error("Media URL must use http or https protocol");
90
+ }
91
+ }
92
+ catch (e) {
93
+ if (e instanceof Error && e.message.includes("protocol"))
94
+ throw e;
95
+ throw new Error("Invalid media URL");
96
+ }
97
+ }
98
+ /** Returns validated URL or undefined if invalid (logs security warning). */
99
+ export function sanitizeMediaUrl(url) {
100
+ if (!url)
101
+ return undefined;
102
+ try {
103
+ validateMediaUrl(url);
104
+ return url;
105
+ }
106
+ catch {
107
+ console.warn(`[Security] Blocked non-http media URL: ${url}`);
108
+ return undefined;
109
+ }
110
+ }
111
+ /**
112
+ * Parse **bold** markdown syntax into <strong> tags.
113
+ * Strips HTML first so no raw markup can pass through.
114
+ * Optimized with an early exit.
115
+ */
116
+ export function parseBold(text) {
117
+ if (!text.includes("<") && !text.includes("**"))
118
+ return text;
119
+ return text
120
+ .replace(HTML_TAGS_RE, "")
121
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
122
+ }
123
+ /**
124
+ * A timestamp column as an ISO string. SQLite's CURRENT_TIMESTAMP and
125
+ * datetime('now') write "YYYY-MM-DD HH:MM:SS" in UTC, which `new Date()`
126
+ * reads as local time in V8 and refuses outright in Safari; an ISO string
127
+ * (JS-written created_at) passes through unchanged.
128
+ */
129
+ export function toIsoTimestamp(value) {
130
+ return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value)
131
+ ? `${value.replace(" ", "T")}Z`
132
+ : value;
133
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@orakl/shared",
3
+ "version": "0.1.0",
4
+ "description": "Orakl runtime helpers shared by the web client and the engine.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/robcsaszar/orakl.git",
9
+ "directory": "packages/shared"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "types": "./dist/index.d.ts",
19
+ "dependencies": {
20
+ "@orakl/protocol": "0.1.0",
21
+ "date-fns": "^4.4.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^26.4.1",
25
+ "typescript": "^6.0.3",
26
+ "vitest": "^5.0.0",
27
+ "effect": "^3.22.1"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "LICENSE"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "peerDependencies": {
37
+ "effect": "^3.22.1"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "generate:avatars": "node scripts/generate-avatars.mjs",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }