@softure-ai/core 0.1.10 → 0.1.11

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 (52) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +31 -1
  3. package/dist/calendar-day.d.ts +3 -0
  4. package/dist/calendar-day.d.ts.map +1 -1
  5. package/dist/calendar-day.js +5 -0
  6. package/dist/calendar-day.js.map +1 -1
  7. package/dist/config.d.ts +7 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +2 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/format.d.ts +26 -3
  12. package/dist/format.d.ts.map +1 -1
  13. package/dist/format.js +82 -10
  14. package/dist/format.js.map +1 -1
  15. package/dist/index.d.ts +3 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/memoize.d.ts +16 -0
  20. package/dist/memoize.d.ts.map +1 -0
  21. package/dist/memoize.js +81 -0
  22. package/dist/memoize.js.map +1 -0
  23. package/dist/messages/en.d.ts +15 -0
  24. package/dist/messages/en.d.ts.map +1 -1
  25. package/dist/messages/en.js +5 -0
  26. package/dist/messages/en.js.map +1 -1
  27. package/dist/messages/index.d.ts +1 -1
  28. package/dist/messages/index.d.ts.map +1 -1
  29. package/dist/messages/index.js +1 -1
  30. package/dist/messages/index.js.map +1 -1
  31. package/dist/messages/pl.d.ts.map +1 -1
  32. package/dist/messages/pl.js +5 -0
  33. package/dist/messages/pl.js.map +1 -1
  34. package/dist/og/index.d.ts +2 -0
  35. package/dist/og/index.d.ts.map +1 -0
  36. package/dist/og/index.js +4 -0
  37. package/dist/og/index.js.map +1 -0
  38. package/dist/og/og-fonts.d.ts +37 -0
  39. package/dist/og/og-fonts.d.ts.map +1 -0
  40. package/dist/og/og-fonts.js +95 -0
  41. package/dist/og/og-fonts.js.map +1 -0
  42. package/package.json +6 -1
  43. package/src/calendar-day.ts +4 -0
  44. package/src/config.ts +9 -0
  45. package/src/format.ts +89 -11
  46. package/src/index.ts +14 -1
  47. package/src/memoize.ts +89 -0
  48. package/src/messages/en.ts +5 -0
  49. package/src/messages/index.ts +1 -1
  50. package/src/messages/pl.ts +5 -0
  51. package/src/og/index.ts +12 -0
  52. package/src/og/og-fonts.ts +127 -0
@@ -3,5 +3,20 @@ export declare const en: {
3
3
  database_failed: string;
4
4
  unexpected: string;
5
5
  };
6
+ duration: {
7
+ years: {
8
+ one: string;
9
+ few: string;
10
+ many: string;
11
+ other: string;
12
+ };
13
+ months: {
14
+ one: string;
15
+ few: string;
16
+ many: string;
17
+ other: string;
18
+ };
19
+ both: string;
20
+ };
6
21
  };
7
22
  //# sourceMappingURL=en.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,EAAE;;;;;CAKd,CAAC"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;CAUd,CAAC"}
@@ -3,5 +3,10 @@ export const en = {
3
3
  database_failed: "Something went wrong while saving or loading data. Please try again.",
4
4
  unexpected: "Something went wrong. Please try again.",
5
5
  },
6
+ duration: {
7
+ years: { one: "{count} year", few: "{count} years", many: "{count} years", other: "{count} years" },
8
+ months: { one: "{count} month", few: "{count} months", many: "{count} months", other: "{count} months" },
9
+ both: "{years} and {months}",
10
+ },
6
11
  };
7
12
  //# sourceMappingURL=en.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"en.js","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,EAAE,GAAG;IAChB,MAAM,EAAE;QACN,eAAe,EAAE,sEAAsE;QACvF,UAAU,EAAE,yCAAyC;KACtD;CACF,CAAC"}
1
+ {"version":3,"file":"en.js","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,EAAE,GAAG;IAChB,MAAM,EAAE;QACN,eAAe,EAAE,sEAAsE;QACvF,UAAU,EAAE,yCAAyC;KACtD;IACD,QAAQ,EAAE;QACR,KAAK,EAAE,EAAE,GAAG,EAAE,cAAc,EAAE,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,eAAe,EAAE;QACnG,MAAM,EAAE,EAAE,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,EAAE,IAAI,EAAE,gBAAgB,EAAE,KAAK,EAAE,gBAAgB,EAAE;QACxG,IAAI,EAAE,sBAAsB;KAC7B;CACF,CAAC"}
@@ -1,6 +1,6 @@
1
1
  import type { Dictionaries } from "../i18n.js";
2
2
  import { en } from "./en.js";
3
3
  export type CoreMessages = typeof en;
4
- /** Copy for core's own error codes (`core.<key>` → `errors.<key>`). */
4
+ /** Copy for core's own error codes (`core.<key>` → `errors.<key>`) and formatters (`duration`). */
5
5
  export declare const coreMessages: Dictionaries<CoreMessages>;
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/messages/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAG7B,MAAM,MAAM,YAAY,GAAG,OAAO,EAAE,CAAC;AAErC,uEAAuE;AACvE,eAAO,MAAM,YAAY,EAAE,YAAY,CAAC,YAAY,CAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/messages/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAG7B,MAAM,MAAM,YAAY,GAAG,OAAO,EAAE,CAAC;AAErC,mGAAmG;AACnG,eAAO,MAAM,YAAY,EAAE,YAAY,CAAC,YAAY,CAAc,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { en } from "./en.js";
2
2
  import { pl } from "./pl.js";
3
- /** Copy for core's own error codes (`core.<key>` → `errors.<key>`). */
3
+ /** Copy for core's own error codes (`core.<key>` → `errors.<key>`) and formatters (`duration`). */
4
4
  export const coreMessages = { en, pl };
5
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/messages/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAI7B,uEAAuE;AACvE,MAAM,CAAC,MAAM,YAAY,GAA+B,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/messages/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAI7B,mGAAmG;AACnG,MAAM,CAAC,MAAM,YAAY,GAA+B,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"pl.d.ts","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,eAAO,MAAM,EAAE,EAAE,OAAO,EAKvB,CAAC"}
1
+ {"version":3,"file":"pl.d.ts","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,eAAO,MAAM,EAAE,EAAE,OAAO,EAUvB,CAAC"}
@@ -3,5 +3,10 @@ export const pl = {
3
3
  database_failed: "Coś poszło nie tak przy zapisie lub odczycie danych. Spróbuj ponownie.",
4
4
  unexpected: "Coś poszło nie tak. Spróbuj ponownie.",
5
5
  },
6
+ duration: {
7
+ years: { one: "{count} rok", few: "{count} lata", many: "{count} lat", other: "{count} roku" },
8
+ months: { one: "{count} miesiąc", few: "{count} miesiące", many: "{count} miesięcy", other: "{count} miesiąca" },
9
+ both: "{years} i {months}",
10
+ },
6
11
  };
7
12
  //# sourceMappingURL=pl.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"pl.js","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,EAAE,GAAc;IAC3B,MAAM,EAAE;QACN,eAAe,EAAE,wEAAwE;QACzF,UAAU,EAAE,uCAAuC;KACpD;CACF,CAAC"}
1
+ {"version":3,"file":"pl.js","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,EAAE,GAAc;IAC3B,MAAM,EAAE;QACN,eAAe,EAAE,wEAAwE;QACzF,UAAU,EAAE,uCAAuC;KACpD;IACD,QAAQ,EAAE;QACR,KAAK,EAAE,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,cAAc,EAAE;QAC9F,MAAM,EAAE,EAAE,GAAG,EAAE,iBAAiB,EAAE,GAAG,EAAE,kBAAkB,EAAE,IAAI,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,EAAE;QAChH,IAAI,EAAE,oBAAoB;KAC3B;CACF,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { createOgFontLoader, loadOgFonts, OG_FONT_WEIGHTS, type OgFont, type OgFontLoaderOptions, type OgFontSource, type OgFontsResult, type OgFontWeight, } from "./og-fonts.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/og/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,KAAK,MAAM,EACX,KAAK,mBAAmB,EACxB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,eAAe,CAAC"}
@@ -0,0 +1,4 @@
1
+ // The OG card font loader: server only (it reads files), no `next/*` imports. `@softure-ai/seo/server` re-exports it
2
+ // for an app's own `opengraph-image` routes, the blog for its article card.
3
+ export { createOgFontLoader, loadOgFonts, OG_FONT_WEIGHTS, } from "./og-fonts.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/og/index.ts"],"names":[],"mappings":"AAAA,qHAAqH;AACrH,4EAA4E;AAC5E,OAAO,EACL,kBAAkB,EAClB,WAAW,EACX,eAAe,GAMhB,MAAM,eAAe,CAAC"}
@@ -0,0 +1,37 @@
1
+ /** The weights Satori (`next/og`) draws: static fonts only, in steps of 100. */
2
+ export declare const OG_FONT_WEIGHTS: readonly [100, 200, 300, 400, 500, 600, 700, 800, 900];
3
+ export type OgFontWeight = (typeof OG_FONT_WEIGHTS)[number];
4
+ /** A font to read: a path (from the loader's root, or absolute) or an https URL, and how the card names it. */
5
+ export interface OgFontSource {
6
+ readonly name: string;
7
+ readonly src: string;
8
+ readonly weight?: OgFontWeight;
9
+ readonly style?: "normal" | "italic";
10
+ }
11
+ /** A font for the card, as `ImageResponse` takes it. */
12
+ export interface OgFont {
13
+ readonly name: string;
14
+ readonly data: ArrayBuffer;
15
+ readonly weight?: OgFontWeight;
16
+ readonly style?: "normal" | "italic";
17
+ }
18
+ export type OgFontsResult = {
19
+ readonly ok: true;
20
+ readonly fonts: readonly OgFont[];
21
+ } | {
22
+ readonly ok: false;
23
+ readonly error: string;
24
+ };
25
+ export interface OgFontLoaderOptions {
26
+ /** Where a relative path starts: the app's root. */
27
+ readonly root: string;
28
+ readonly readFile?: (path: string) => Promise<Uint8Array>;
29
+ readonly fetchImpl?: typeof fetch;
30
+ /** What a message names the list by, before `[index]`. Default: `OG card: fonts`. */
31
+ readonly label?: string;
32
+ }
33
+ /** A loader with its own cache, keyed by the resolved path or URL. */
34
+ export declare function createOgFontLoader(options: OgFontLoaderOptions): (fonts: readonly OgFontSource[]) => Promise<OgFontsResult>;
35
+ /** The fonts for an app's own OG card, read from the working directory (the app's root, where Next runs) and cached for the process. */
36
+ export declare function loadOgFonts(fonts: readonly OgFontSource[]): Promise<OgFontsResult>;
37
+ //# sourceMappingURL=og-fonts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"og-fonts.d.ts","sourceRoot":"","sources":["../../src/og/og-fonts.ts"],"names":[],"mappings":"AAOA,gFAAgF;AAChF,eAAO,MAAM,eAAe,wDAAyD,CAAC;AAEtF,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,+GAA+G;AAC/G,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CACtC;AAED,wDAAwD;AACxD,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CACtC;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAItI,MAAM,WAAW,mBAAmB;IAClC,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAClC,qFAAqF;IACrF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AA0BD,sEAAsE;AACtE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,KAAK,OAAO,CAAC,aAAa,CAAC,CAoD3H;AAID,wIAAwI;AACxI,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAGlF"}
@@ -0,0 +1,95 @@
1
+ // The loader of an OG card's fonts: each source is read once, checked to be a font Satori draws (.ttf,
2
+ // .otf, .woff; not .woff2), and kept for the life of the loader. A failed read is not kept, so a fixed
3
+ // file is picked up by the next call. It imports no Next code, so a command-line check can run it too.
4
+ // `@softure-ai/seo/server` and the blog re-export it.
5
+ import { readFile } from "node:fs/promises";
6
+ import { isAbsolute, resolve } from "node:path";
7
+ /** The weights Satori (`next/og`) draws: static fonts only, in steps of 100. */
8
+ export const OG_FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
9
+ const DEFAULT_LABEL = "OG card: fonts";
10
+ /** The first four bytes of each font format Satori parses. */
11
+ const FONT_SIGNATURES = ["wOFF", "OTTO", "\u0000\u0001\u0000\u0000", "true"];
12
+ const WOFF2_SIGNATURE = "wOF2";
13
+ function toArrayBuffer(bytes) {
14
+ const copy = new Uint8Array(bytes.byteLength);
15
+ copy.set(bytes);
16
+ return copy.buffer;
17
+ }
18
+ function checkFontBytes(bytes) {
19
+ const signature = String.fromCharCode(...bytes.subarray(0, 4));
20
+ if (signature === WOFF2_SIGNATURE)
21
+ return { ok: false, reason: "a .woff2 font, which the card cannot read; use the .woff or .ttf file" };
22
+ if (!FONT_SIGNATURES.includes(signature))
23
+ return { ok: false, reason: "not a .ttf, .otf or .woff font" };
24
+ return { ok: true, data: toArrayBuffer(bytes) };
25
+ }
26
+ function getErrorCode(error) {
27
+ if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string")
28
+ return error.code;
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+ /** A loader with its own cache, keyed by the resolved path or URL. */
32
+ export function createOgFontLoader(options) {
33
+ const read = options.readFile ?? readFile;
34
+ const fetchImpl = options.fetchImpl ?? fetch;
35
+ const label = options.label ?? DEFAULT_LABEL;
36
+ const cache = new Map();
37
+ async function readSource(location, isUrl) {
38
+ if (!isUrl) {
39
+ try {
40
+ return checkFontBytes(await read(location));
41
+ }
42
+ catch (error) {
43
+ return { ok: false, reason: `the file cannot be read (${getErrorCode(error)})` };
44
+ }
45
+ }
46
+ let response;
47
+ try {
48
+ response = await fetchImpl(location);
49
+ }
50
+ catch (error) {
51
+ return { ok: false, reason: `the request failed (${getErrorCode(error)})` };
52
+ }
53
+ if (!response.ok)
54
+ return { ok: false, reason: `the server answered ${response.status}` };
55
+ try {
56
+ return checkFontBytes(new Uint8Array(await response.arrayBuffer()));
57
+ }
58
+ catch (error) {
59
+ return { ok: false, reason: `the answer could not be read (${getErrorCode(error)})` };
60
+ }
61
+ }
62
+ function getBytes(location, isUrl) {
63
+ const cached = cache.get(location);
64
+ if (cached !== undefined)
65
+ return cached;
66
+ const pending = readSource(location, isUrl).then((result) => {
67
+ if (!result.ok)
68
+ cache.delete(location);
69
+ return result;
70
+ });
71
+ cache.set(location, pending);
72
+ return pending;
73
+ }
74
+ return async (fonts) => {
75
+ const located = fonts.map((font) => {
76
+ const isUrl = font.src.startsWith("https://");
77
+ return { font, isUrl, location: isUrl || isAbsolute(font.src) ? font.src : resolve(options.root, font.src) };
78
+ });
79
+ const results = await Promise.all(located.map(async (entry) => ({ ...entry, result: await getBytes(entry.location, entry.isUrl) })));
80
+ const loaded = [];
81
+ for (const [index, { font, location, result }] of results.entries()) {
82
+ if (!result.ok)
83
+ return { ok: false, error: `${label}[${index}] "${font.src}": ${result.reason} (${location}).` };
84
+ loaded.push({ name: font.name, data: result.data, weight: font.weight, style: font.style });
85
+ }
86
+ return { ok: true, fonts: loaded };
87
+ };
88
+ }
89
+ let loadFromWorkingDirectory;
90
+ /** The fonts for an app's own OG card, read from the working directory (the app's root, where Next runs) and cached for the process. */
91
+ export function loadOgFonts(fonts) {
92
+ loadFromWorkingDirectory ??= createOgFontLoader({ root: process.cwd() });
93
+ return loadFromWorkingDirectory(fonts);
94
+ }
95
+ //# sourceMappingURL=og-fonts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"og-fonts.js","sourceRoot":"","sources":["../../src/og/og-fonts.ts"],"names":[],"mappings":"AAAA,uGAAuG;AACvG,uGAAuG;AACvG,uGAAuG;AACvG,sDAAsD;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEhD,gFAAgF;AAChF,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAU,CAAC;AAiCtF,MAAM,aAAa,GAAG,gBAAgB,CAAC;AAEvC,8DAA8D;AAC9D,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,0BAA0B,EAAE,MAAM,CAAC,CAAC;AAC7E,MAAM,eAAe,GAAG,MAAM,CAAC;AAE/B,SAAS,aAAa,CAAC,KAAiB;IACtC,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IAC9C,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAChB,OAAO,IAAI,CAAC,MAAM,CAAC;AACrB,CAAC;AAED,SAAS,cAAc,CAAC,KAAiB;IACvC,MAAM,SAAS,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/D,IAAI,SAAS,KAAK,eAAe;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,uEAAuE,EAAE,CAAC;IACzI,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,gCAAgC,EAAE,CAAC;IACzG,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC;AAClD,CAAC;AAED,SAAS,YAAY,CAAC,KAAc;IAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,MAAM,IAAI,KAAK,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC;IACxH,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAChE,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB,CAAC,OAA4B;IAC7D,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,IAAI,QAAQ,CAAC;IAC1C,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,KAAK,CAAC;IAC7C,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,aAAa,CAAC;IAC7C,MAAM,KAAK,GAAG,IAAI,GAAG,EAAgC,CAAC;IAEtD,KAAK,UAAU,UAAU,CAAC,QAAgB,EAAE,KAAc;QACxD,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,IAAI,CAAC;gBACH,OAAO,cAAc,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC9C,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,4BAA4B,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;YACnF,CAAC;QACH,CAAC;QACD,IAAI,QAAkB,CAAC;QACvB,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAC;QACvC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,uBAAuB,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;QAC9E,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,uBAAuB,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC;QACzF,IAAI,CAAC;YACH,OAAO,cAAc,CAAC,IAAI,UAAU,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;QACtE,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iCAAiC,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;QACxF,CAAC;IACH,CAAC;IAED,SAAS,QAAQ,CAAC,QAAgB,EAAE,KAAc;QAChD,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACnC,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO,MAAM,CAAC;QACxC,MAAM,OAAO,GAAG,UAAU,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE;YAC1D,IAAI,CAAC,MAAM,CAAC,EAAE;gBAAE,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACvC,OAAO,MAAM,CAAC;QAChB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAC7B,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,OAAO,KAAK,EAAE,KAAK,EAAE,EAAE;QACrB,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;YACjC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;YAC9C,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/G,CAAC,CAAC,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACrI,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,KAAK,MAAM,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;YACpE,IAAI,CAAC,MAAM,CAAC,EAAE;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC,GAAG,MAAM,MAAM,CAAC,MAAM,KAAK,QAAQ,IAAI,EAAE,CAAC;YACjH,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;QAC9F,CAAC;QACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IACrC,CAAC,CAAC;AACJ,CAAC;AAED,IAAI,wBAAkG,CAAC;AAEvG,wIAAwI;AACxI,MAAM,UAAU,WAAW,CAAC,KAA8B;IACxD,wBAAwB,KAAK,kBAAkB,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACzE,OAAO,wBAAwB,CAAC,KAAK,CAAC,CAAC;AACzC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/core",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "The SOFTURE AI module contract: app config, module definitions, Result, Clock and messages.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,6 +39,11 @@
39
39
  "@softure-ai/source": "./src/cli/index.ts",
40
40
  "types": "./dist/cli/index.d.ts",
41
41
  "default": "./dist/cli/index.js"
42
+ },
43
+ "./og": {
44
+ "@softure-ai/source": "./src/og/index.ts",
45
+ "types": "./dist/og/index.d.ts",
46
+ "default": "./dist/og/index.js"
42
47
  }
43
48
  },
44
49
  "scripts": {
@@ -1,5 +1,6 @@
1
1
  // The calendar day of an instant in a given IANA time zone, as `YYYY-MM-DD`. "Today" computed in the
2
2
  // process zone is a classic date bug: on a server in UTC, an evening in New York is already tomorrow.
3
+ import { z } from "zod";
3
4
  import type { Clock } from "./clock.js";
4
5
 
5
6
  const formatters = new Map<string, Intl.DateTimeFormat>();
@@ -59,6 +60,9 @@ export function isCalendarDay(value: unknown): value is string {
59
60
  return typeof value === "string" && parseCivilDate(value) !== null;
60
61
  }
61
62
 
63
+ /** A `YYYY-MM-DD` calendar day in tool and form input: `isCalendarDay` as a zod schema. */
64
+ export const calendarDaySchema = z.string().refine(isCalendarDay, "must be a YYYY-MM-DD calendar day, e.g. 2026-10-04");
65
+
62
66
  /** `day` moved by `days` calendar days (negative goes back). Throws a `RangeError` for a malformed day or count. */
63
67
  export function addCalendarDays(day: string, days: number): string {
64
68
  const operation = "addCalendarDays";
package/src/config.ts CHANGED
@@ -63,6 +63,11 @@ export interface SoftureConfig {
63
63
  readonly timezone: string;
64
64
  /** Scheme, host and port of the app, without a path: `https://app.example.com`. */
65
65
  readonly appOrigin: string;
66
+ /**
67
+ * The public landing site (`https://example.com`) when the app lives on a subdomain beside it; `appOrigin` when
68
+ * the app does not name one. For links from the app to its marketing pages, not for the app's own URLs.
69
+ */
70
+ readonly landingOrigin: string;
66
71
  /** Every origin the app is served under besides `appOrigin`, read by `resolveAppOrigin`. */
67
72
  readonly origins: SoftureOrigins;
68
73
  /** Enabled modules, in the order the app listed them. */
@@ -75,6 +80,8 @@ export interface SoftureConfigInput {
75
80
  readonly locale: Locale;
76
81
  readonly timezone: string;
77
82
  readonly appOrigin: string;
83
+ /** The public landing site beside the app; defaults to `appOrigin`. */
84
+ readonly landingOrigin?: string;
78
85
  readonly origins?: SoftureOriginsInput;
79
86
  readonly modules: readonly AnySoftureModule[];
80
87
  }
@@ -98,6 +105,7 @@ const configSchema = z.object({
98
105
  locale: z.enum(LOCALES),
99
106
  timezone: z.string().refine(isTimeZone, "must be an IANA time zone, e.g. Europe/Warsaw"),
100
107
  appOrigin: z.string().refine(isOrigin, ORIGIN_MESSAGE),
108
+ landingOrigin: z.string().refine(isOrigin, ORIGIN_MESSAGE).optional(),
101
109
  origins: z
102
110
  .strictObject({
103
111
  trustedOrigins: z.array(z.string().refine(isOrigin, ORIGIN_MESSAGE)).max(16, "takes at most 16 origins").default([]),
@@ -126,6 +134,7 @@ export function defineSoftureConfig(input: SoftureConfigInput): SoftureConfig {
126
134
 
127
135
  return Object.freeze({
128
136
  ...config,
137
+ landingOrigin: config.landingOrigin ?? config.appOrigin,
129
138
  database: config.database === null ? null : Object.freeze({ ...config.database }),
130
139
  origins: Object.freeze({ ...config.origins, trustedOrigins: Object.freeze([...config.origins.trustedOrigins]) }),
131
140
  modules: Object.freeze([...config.modules]),
package/src/format.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  // is formatted at UTC midnight in UTC, so neither the server's nor the reader's zone moves it.
3
3
  import { isCalendarDay } from "./calendar-day.js";
4
4
  import { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
5
- import type { Locale } from "./i18n.js";
5
+ import { formatMessage, selectPlural, type Locale } from "./i18n.js";
6
+ import { coreMessages } from "./messages/index.js";
6
7
 
7
8
  /** `long`: October 4, 2026 (pl names the month in the genitive); `medium`: Oct 4, 2026; `numeric`: 10/04/2026 (pl: 04.10.2026). */
8
9
  export type CalendarDayStyle = "long" | "medium" | "numeric";
@@ -16,6 +17,19 @@ export interface FormatMoneyOptions {
16
17
  readonly compact?: boolean;
17
18
  }
18
19
 
20
+ /** `long`: October 2026 (pl: the month in the nominative); `genitive`: pl's genitive month, as inside a date; `roman`: X 2026. */
21
+ export type CalendarMonthStyle = "long" | "genitive" | "roman";
22
+
23
+ export interface FormatPercentOptions {
24
+ /** A `+` before a positive value (a `-` before a negative one is always there). Zero has neither in any mode. */
25
+ readonly signed?: boolean;
26
+ /** No space between the number and `%`, whatever the locale's pattern: `12,5%`, not `12,5 %`. */
27
+ readonly tight?: boolean;
28
+ }
29
+
30
+ const MONTHS_PER_YEAR = 12;
31
+ const MONTH_PATTERN = /^(\d{4})-(\d{2})$/;
32
+ const ROMAN_MONTHS = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"] as const;
19
33
  const BASIS_POINTS_PER_UNIT = 10_000;
20
34
  const MAX_PERCENT_FRACTION_DIGITS = 2;
21
35
 
@@ -31,13 +45,51 @@ const DAY_STYLE_OPTIONS: Readonly<Record<CalendarDayStyle, Intl.DateTimeFormatOp
31
45
  /** A `YYYY-MM-DD` day as `locale` writes it. Throws a `RangeError` for a malformed day. */
32
46
  export function formatCalendarDay(day: string, locale: Locale, style: CalendarDayStyle = "long"): string {
33
47
  if (!isCalendarDay(day)) throw new RangeError(`formatCalendarDay: "${String(day)}" is not a YYYY-MM-DD calendar day`);
34
- const key = `${locale}:${style}`;
35
- let format = dayFormatters.get(key);
36
- if (format === undefined) {
37
- format = new Intl.DateTimeFormat(locale, { ...DAY_STYLE_OPTIONS[style], timeZone: "UTC" });
38
- dayFormatters.set(key, format);
48
+ return getDayFormatter(`${locale}:${style}`, locale, DAY_STYLE_OPTIONS[style]).format(new Date(`${day}T00:00:00Z`));
49
+ }
50
+
51
+ /** Whether `value` is a real calendar month written `YYYY-MM` (`2026-13` and `2026-1` are not). */
52
+ export function isCalendarMonth(value: unknown): value is string {
53
+ if (typeof value !== "string") return false;
54
+ const match = MONTH_PATTERN.exec(value);
55
+ if (match === null) return false;
56
+ const month = Number(match[2]);
57
+ return month >= 1 && month <= MONTHS_PER_YEAR;
58
+ }
59
+
60
+ /**
61
+ * The month of a `YYYY-MM` month or a `YYYY-MM-DD` day, with its year, as `locale` writes it alone. Throws a
62
+ * `RangeError` for anything else.
63
+ */
64
+ export function formatCalendarMonth(month: string, locale: Locale, style: CalendarMonthStyle = "long"): string {
65
+ const firstDay = isCalendarMonth(month) ? `${month}-01` : month;
66
+ if (!isCalendarDay(firstDay)) throw new RangeError(`formatCalendarMonth: "${String(month)}" is not a YYYY-MM month or a YYYY-MM-DD day`);
67
+ const date = new Date(`${firstDay}T00:00:00Z`);
68
+ const year = firstDay.slice(0, 4);
69
+ if (style === "roman") return `${ROMAN_MONTHS[date.getUTCMonth()] ?? ""} ${year}`;
70
+ if (style === "long") return getDayFormatter(`${locale}:month`, locale, { month: "long", year: "numeric" }).format(date);
71
+ // The genitive is the form a full date uses, so it is read from a day-month-year date's month part.
72
+ const parts = getDayFormatter(`${locale}:day-month`, locale, { day: "numeric", month: "long", year: "numeric" }).formatToParts(date);
73
+ const name = parts.find((part) => part.type === "month")?.value ?? "";
74
+ return `${name} ${year}`;
75
+ }
76
+
77
+ /**
78
+ * Whole months as years and months in `locale`, with its plural forms: 38 is "3 years and 2 months"; a part that is
79
+ * zero is left out, and zero itself is "0 months". Throws a `RangeError` for a negative or fractional count.
80
+ */
81
+ export function formatDuration(months: number, locale: Locale): string {
82
+ if (!Number.isSafeInteger(months) || months < 0) {
83
+ throw new RangeError(`formatDuration: ${String(months)} is not a whole, non-negative number of months`);
39
84
  }
40
- return format.format(new Date(`${day}T00:00:00Z`));
85
+ const copy = coreMessages[locale].duration;
86
+ const years = Math.floor(months / MONTHS_PER_YEAR);
87
+ const rest = months % MONTHS_PER_YEAR;
88
+ const yearsText = formatMessage(selectPlural(locale, years, copy.years), { count: years });
89
+ const monthsText = formatMessage(selectPlural(locale, rest, copy.months), { count: rest });
90
+ if (years === 0) return monthsText;
91
+ if (rest === 0) return yearsText;
92
+ return formatMessage(copy.both, { years: yearsText, months: monthsText });
41
93
  }
42
94
 
43
95
  /**
@@ -62,11 +114,37 @@ export function formatMoney(minor: number, currency: string, locale: Locale, opt
62
114
  return format.format(minor / 10 ** digits);
63
115
  }
64
116
 
65
- /** Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits. Throws a `RangeError` for a non-finite value. */
66
- export function formatPercent(basisPoints: number, locale: Locale): string {
117
+ /**
118
+ * Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits, and zero is never signed (`-0`
119
+ * and a value that rounds to zero show as `0%`). Throws a `RangeError` for a non-finite value.
120
+ */
121
+ export function formatPercent(basisPoints: number, locale: Locale, options: FormatPercentOptions = {}): string {
67
122
  if (!Number.isFinite(basisPoints)) throw new RangeError(`formatPercent: ${String(basisPoints)} is not a finite number of basis points`);
68
- const format = getNumberFormatter(locale, { style: "percent", maximumFractionDigits: MAX_PERCENT_FRACTION_DIGITS });
69
- return format.format(basisPoints / BASIS_POINTS_PER_UNIT);
123
+ const format = getNumberFormatter(locale, {
124
+ style: "percent",
125
+ maximumFractionDigits: MAX_PERCENT_FRACTION_DIGITS,
126
+ signDisplay: options.signed === true ? "exceptZero" : "negative",
127
+ });
128
+ const value = basisPoints / BASIS_POINTS_PER_UNIT;
129
+ if (options.tight !== true) return format.format(value);
130
+ const parts = format.formatToParts(value);
131
+ return parts
132
+ .filter((part, index) => !(part.type === "literal" && part.value.trim() === "" && isNextToPercentSign(parts, index)))
133
+ .map((part) => part.value)
134
+ .join("");
135
+ }
136
+
137
+ function isNextToPercentSign(parts: readonly Intl.NumberFormatPart[], index: number): boolean {
138
+ return parts[index - 1]?.type === "percentSign" || parts[index + 1]?.type === "percentSign";
139
+ }
140
+
141
+ function getDayFormatter(key: string, locale: Locale, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
142
+ let format = dayFormatters.get(key);
143
+ if (format === undefined) {
144
+ format = new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" });
145
+ dayFormatters.set(key, format);
146
+ }
147
+ return format;
70
148
  }
71
149
 
72
150
  function getNumberFormatter(locale: Locale, options: Intl.NumberFormatOptions): Intl.NumberFormat {
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  addCalendarDays,
5
5
  addCalendarMonths,
6
6
  calendarDaysBetween,
7
+ calendarDaySchema,
7
8
  getCalendarDay,
8
9
  isCalendarDay,
9
10
  toCalendarDay,
@@ -11,7 +12,19 @@ export {
11
12
  type EndOfMonth,
12
13
  } from "./calendar-day.js";
13
14
  export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
14
- export { formatCalendarDay, formatMoney, formatPercent, type CalendarDayStyle, type FormatMoneyOptions } from "./format.js";
15
+ export {
16
+ formatCalendarDay,
17
+ formatCalendarMonth,
18
+ formatDuration,
19
+ formatMoney,
20
+ formatPercent,
21
+ isCalendarMonth,
22
+ type CalendarDayStyle,
23
+ type CalendarMonthStyle,
24
+ type FormatMoneyOptions,
25
+ type FormatPercentOptions,
26
+ } from "./format.js";
27
+ export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
15
28
  export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
16
29
  export {
17
30
  formatMessage,
package/src/memoize.ts ADDED
@@ -0,0 +1,89 @@
1
+ // A cache for pure functions of plain data: the arguments' content is the key, so two equal objects built apart hit
2
+ // the same entry. Least recently used entries go beyond `max`; nothing expires by time.
3
+
4
+ export interface MemoizeByContentOptions {
5
+ /** The most entries kept; the least recently used one goes first. */
6
+ readonly max: number;
7
+ }
8
+
9
+ /** `fn` with a cache, plus `clear()` to empty it. */
10
+ export type MemoizedFunction<Args extends readonly unknown[], Value> = ((...args: Args) => Value) & { readonly clear: () => void };
11
+
12
+ /**
13
+ * `fn` cached by the content of its arguments: primitives, arrays, `Date`s and plain objects (key order does not
14
+ * matter). An argument holding anything else (a function, a `Map`, a class instance, a cycle) throws a `TypeError`.
15
+ * A promise that rejects is dropped, so the next call tries again. Throws a `RangeError` for a `max` that is not a
16
+ * positive whole number.
17
+ */
18
+ export function memoizeByContent<Args extends readonly unknown[], Value>(
19
+ fn: (...args: Args) => Value,
20
+ options: MemoizeByContentOptions,
21
+ ): MemoizedFunction<Args, Value> {
22
+ const { max } = options;
23
+ if (!Number.isSafeInteger(max) || max < 1) throw new RangeError(`memoizeByContent: max must be a positive whole number, got ${String(max)}`);
24
+ const cache = new Map<string, Value>();
25
+
26
+ const memoized = (...args: Args): Value => {
27
+ const key = toContentKey(args, new Set());
28
+ if (cache.has(key)) {
29
+ // Not `?? fn(...)`: a cached `undefined` is a hit too.
30
+ const hit = cache.get(key) as Value;
31
+ cache.delete(key);
32
+ cache.set(key, hit);
33
+ return hit;
34
+ }
35
+ const value = fn(...args);
36
+ cache.set(key, value);
37
+ if (cache.size > max) {
38
+ const oldest = cache.keys().next();
39
+ if (oldest.done !== true) cache.delete(oldest.value);
40
+ }
41
+ if (value instanceof Promise) {
42
+ value.catch(() => {
43
+ if (cache.get(key) === value) cache.delete(key);
44
+ });
45
+ }
46
+ return value;
47
+ };
48
+ return Object.assign(memoized, { clear: () => cache.clear() });
49
+ }
50
+
51
+ function toContentKey(value: unknown, path: Set<object>): string {
52
+ switch (typeof value) {
53
+ case "undefined":
54
+ return "u";
55
+ case "boolean":
56
+ return value ? "t" : "f";
57
+ case "number":
58
+ return `n${Object.is(value, -0) ? "-0" : String(value)}`;
59
+ case "bigint":
60
+ return `b${String(value)}`;
61
+ case "string":
62
+ return JSON.stringify(value);
63
+ case "object":
64
+ return value === null ? "null" : toObjectKey(value, path);
65
+ default:
66
+ throw new TypeError(`memoizeByContent: an argument holds a ${typeof value}, which has no content key`);
67
+ }
68
+ }
69
+
70
+ function toObjectKey(value: object, path: Set<object>): string {
71
+ if (value instanceof Date) return `d${String(value.getTime())}`;
72
+ if (path.has(value)) throw new TypeError("memoizeByContent: an argument holds a cycle");
73
+ path.add(value);
74
+ let key: string;
75
+ if (Array.isArray(value)) {
76
+ key = `[${value.map((item: unknown) => toContentKey(item, path)).join(",")}]`;
77
+ } else {
78
+ const prototype: unknown = Object.getPrototypeOf(value);
79
+ if (prototype !== Object.prototype && prototype !== null) {
80
+ throw new TypeError(`memoizeByContent: an argument holds a ${value.constructor.name}, which has no content key`);
81
+ }
82
+ const entries = Object.keys(value)
83
+ .sort()
84
+ .map((name) => `${JSON.stringify(name)}:${toContentKey((value as Record<string, unknown>)[name], path)}`);
85
+ key = `{${entries.join(",")}}`;
86
+ }
87
+ path.delete(value);
88
+ return key;
89
+ }
@@ -3,4 +3,9 @@ export const en = {
3
3
  database_failed: "Something went wrong while saving or loading data. Please try again.",
4
4
  unexpected: "Something went wrong. Please try again.",
5
5
  },
6
+ duration: {
7
+ years: { one: "{count} year", few: "{count} years", many: "{count} years", other: "{count} years" },
8
+ months: { one: "{count} month", few: "{count} months", many: "{count} months", other: "{count} months" },
9
+ both: "{years} and {months}",
10
+ },
6
11
  };
@@ -4,5 +4,5 @@ import { pl } from "./pl.js";
4
4
 
5
5
  export type CoreMessages = typeof en;
6
6
 
7
- /** Copy for core's own error codes (`core.<key>` → `errors.<key>`). */
7
+ /** Copy for core's own error codes (`core.<key>` → `errors.<key>`) and formatters (`duration`). */
8
8
  export const coreMessages: Dictionaries<CoreMessages> = { en, pl };
@@ -5,4 +5,9 @@ export const pl: typeof en = {
5
5
  database_failed: "Coś poszło nie tak przy zapisie lub odczycie danych. Spróbuj ponownie.",
6
6
  unexpected: "Coś poszło nie tak. Spróbuj ponownie.",
7
7
  },
8
+ duration: {
9
+ years: { one: "{count} rok", few: "{count} lata", many: "{count} lat", other: "{count} roku" },
10
+ months: { one: "{count} miesiąc", few: "{count} miesiące", many: "{count} miesięcy", other: "{count} miesiąca" },
11
+ both: "{years} i {months}",
12
+ },
8
13
  };
@@ -0,0 +1,12 @@
1
+ // The OG card font loader: server only (it reads files), no `next/*` imports. `@softure-ai/seo/server` re-exports it
2
+ // for an app's own `opengraph-image` routes, the blog for its article card.
3
+ export {
4
+ createOgFontLoader,
5
+ loadOgFonts,
6
+ OG_FONT_WEIGHTS,
7
+ type OgFont,
8
+ type OgFontLoaderOptions,
9
+ type OgFontSource,
10
+ type OgFontsResult,
11
+ type OgFontWeight,
12
+ } from "./og-fonts.js";