diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f2aefe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + push: + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run CI checks + run: bun run ci diff --git a/biome.json b/biome.json index e48f9ed..c2c0105 100644 --- a/biome.json +++ b/biome.json @@ -9,6 +9,7 @@ "!**/node_modules", "!build", "!.svelte-kit", + "!.vercel", "!package", "!coverage", "!**/.env", diff --git a/bun.lock b/bun.lock index 2656a98..bfd3ab3 100644 --- a/bun.lock +++ b/bun.lock @@ -11,10 +11,10 @@ "applesauce-loaders": "^5.0.0", "applesauce-relay": "^5.0.0", "carbon-icons-svelte": "^13.8.0", - "isomorphic-dompurify": "^2.35.0", "marked": "^17.0.1", "nostr-tools": "^2.19.4", "rxjs": "^7.8.2", + "sanitize-html": "^2.17.2", "ws": "^8.18.3", }, "devDependencies": { @@ -23,7 +23,7 @@ "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/vite": "^4.1.18", - "@types/dompurify": "^3.2.0", + "@types/sanitize-html": "^2.16.1", "@types/ws": "^8.18.1", "svelte": "^5.46.1", "svelte-check": "^4.3.5", @@ -253,13 +253,11 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], - "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/sanitize-html": ["@types/sanitize-html@2.16.1", "", { "dependencies": { "htmlparser2": "^10.1" } }, "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -351,7 +349,13 @@ "devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -359,12 +363,14 @@ "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esrap": ["esrap@2.2.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg=="], @@ -391,20 +397,22 @@ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isomorphic-dompurify": ["isomorphic-dompurify@2.35.0", "", { "dependencies": { "dompurify": "^3.3.1", "jsdom": "^27.4.0" } }, "sha512-a9+LQqylQCU8f1zmsYmg2tfrbdY2YS/Hc+xntcq/mDI2MY3Q108nq8K23BWDIg6YGC5JsUMC15fj2ZMqCzt/+A=="], - "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -479,6 +487,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -507,6 +517,8 @@ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "sanitize-html": ["sanitize-html@2.17.2", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], @@ -641,6 +653,8 @@ "applesauce-core/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "https-proxy-agent/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "light-bolt11-decoder/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], @@ -651,6 +665,8 @@ "nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], diff --git a/package.json b/package.json index b046957..8647693 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", + "ci": "biome ci . && bun run test && bun run check && bun run build", "lint": "biome check", "lint:write": "biome check --write", "format": "biome format --write", @@ -23,7 +24,7 @@ "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/vite": "^4.1.18", - "@types/dompurify": "^3.2.0", + "@types/sanitize-html": "^2.16.1", "@types/ws": "^8.18.1", "svelte": "^5.46.1", "svelte-check": "^4.3.5", @@ -39,10 +40,10 @@ "applesauce-loaders": "^5.0.0", "applesauce-relay": "^5.0.0", "carbon-icons-svelte": "^13.8.0", - "isomorphic-dompurify": "^2.35.0", "marked": "^17.0.1", "nostr-tools": "^2.19.4", "rxjs": "^7.8.2", + "sanitize-html": "^2.17.2", "ws": "^8.18.3" } } diff --git a/src/app.html b/src/app.html index c973bc7..d7695fd 100644 --- a/src/app.html +++ b/src/app.html @@ -11,7 +11,8 @@ - + + diff --git a/src/lib/blog-post-cache.test.ts b/src/lib/blog-post-cache.test.ts new file mode 100644 index 0000000..ee5dee9 --- /dev/null +++ b/src/lib/blog-post-cache.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBlogPostCache } from "./blog-post-cache"; +import type { BlogPost } from "./nostr"; + +function createPost(overrides: Partial = {}): BlogPost { + return { + id: "post-1", + pubkey: "pubkey", + content: "# Hello", + createdAt: 1_700_000_000, + title: "Hello", + image: null, + summary: null, + publishedAt: null, + dTag: "hello", + naddr: "naddr1hello", + tags: [], + ...overrides, + }; +} + +describe("createBlogPostCache", () => { + it("reuses cached blog posts within the ttl", async () => { + const fetchBlogPosts = vi + .fn<() => Promise>() + .mockResolvedValue([createPost({ dTag: "first" })]); + const fetchBlogPostByDTag = vi.fn<(dTag: string) => Promise>(); + let now = 10_000; + + const cache = createBlogPostCache({ + fetchBlogPostByDTag, + fetchBlogPosts, + now: () => now, + ttlMs: 1_000, + }); + + const first = await cache.fetchBlogPostsCached(); + now += 500; + const second = await cache.fetchBlogPostsCached(); + + expect(first).toEqual(second); + expect(fetchBlogPosts).toHaveBeenCalledTimes(1); + expect(fetchBlogPostByDTag).not.toHaveBeenCalled(); + }); + + it("refreshes cached blog posts after the ttl expires", async () => { + const fetchBlogPosts = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createPost({ dTag: "first" })]) + .mockResolvedValueOnce([createPost({ dTag: "second" })]); + const fetchBlogPostByDTag = vi.fn<(dTag: string) => Promise>(); + let now = 10_000; + + const cache = createBlogPostCache({ + fetchBlogPostByDTag, + fetchBlogPosts, + now: () => now, + ttlMs: 1_000, + }); + + const first = await cache.fetchBlogPostsCached(); + now += 1_000; + const second = await cache.fetchBlogPostsCached(); + + expect(first[0]?.dTag).toBe("first"); + expect(second[0]?.dTag).toBe("second"); + expect(fetchBlogPosts).toHaveBeenCalledTimes(2); + }); + + it("serves a single post from the cached posts list when available", async () => { + const fetchBlogPosts = vi + .fn<() => Promise>() + .mockResolvedValue([ + createPost({ dTag: "first" }), + createPost({ dTag: "second", id: "post-2", naddr: "naddr1second" }), + ]); + const fetchBlogPostByDTag = vi.fn<(dTag: string) => Promise>(); + + const cache = createBlogPostCache({ + fetchBlogPostByDTag, + fetchBlogPosts, + }); + + await cache.fetchBlogPostsCached(); + const post = await cache.fetchBlogPostCached("second"); + + expect(post?.dTag).toBe("second"); + expect(fetchBlogPostByDTag).not.toHaveBeenCalled(); + }); + + it("falls back to direct post fetching when a post is not in cache", async () => { + const fetchedPost = createPost({ dTag: "missing", id: "post-2", naddr: "naddr1missing" }); + const fetchBlogPosts = vi + .fn<() => Promise>() + .mockResolvedValue([createPost({ dTag: "first" })]); + const fetchBlogPostByDTag = vi + .fn<(dTag: string) => Promise>() + .mockResolvedValue(fetchedPost); + + const cache = createBlogPostCache({ + fetchBlogPostByDTag, + fetchBlogPosts, + }); + + await cache.fetchBlogPostsCached(); + const post = await cache.fetchBlogPostCached("missing"); + + expect(post).toEqual(fetchedPost); + expect(fetchBlogPostByDTag).toHaveBeenCalledWith("missing"); + }); +}); diff --git a/src/lib/blog-post-cache.ts b/src/lib/blog-post-cache.ts new file mode 100644 index 0000000..ae56aa7 --- /dev/null +++ b/src/lib/blog-post-cache.ts @@ -0,0 +1,78 @@ +import type { BlogPost } from "./nostr"; + +const BLOG_POSTS_CACHE_KEY = "blog_posts"; +const DEFAULT_CACHE_TTL = 5 * 60 * 1000; + +interface CacheEntry { + data: BlogPost[]; + timestamp: number; +} + +interface CreateBlogPostCacheOptions { + fetchBlogPostByDTag: (dTag: string) => Promise; + fetchBlogPosts: () => Promise; + now?: () => number; + ttlMs?: number; +} + +export function createBlogPostCache({ + fetchBlogPostByDTag, + fetchBlogPosts, + now = () => Date.now(), + ttlMs = DEFAULT_CACHE_TTL, +}: CreateBlogPostCacheOptions) { + const cache = new Map(); + + function getCachedPosts(): BlogPost[] | null { + const cached = cache.get(BLOG_POSTS_CACHE_KEY); + + if (!cached) { + return null; + } + + if (now() - cached.timestamp >= ttlMs) { + cache.delete(BLOG_POSTS_CACHE_KEY); + return null; + } + + return cached.data; + } + + function setCachedPosts(posts: BlogPost[]) { + cache.set(BLOG_POSTS_CACHE_KEY, { + data: posts, + timestamp: now(), + }); + } + + return { + clear() { + cache.clear(); + }, + + async fetchBlogPostCached(dTag: string): Promise { + const cachedPosts = getCachedPosts(); + + if (cachedPosts) { + const post = cachedPosts.find((entry) => entry.dTag === dTag); + if (post) { + return post; + } + } + + return fetchBlogPostByDTag(dTag); + }, + + async fetchBlogPostsCached(): Promise { + const cachedPosts = getCachedPosts(); + + if (cachedPosts) { + return cachedPosts; + } + + const posts = await fetchBlogPosts(); + setCachedPosts(posts); + return posts; + }, + }; +} diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index 4eda096..572b302 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -1,9 +1,9 @@