diff --git a/.gitignore b/.gitignore index 81ab24e..39a7a37 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,10 @@ seeds/ # OS .DS_Store + +# wrangler files +.wrangler +.dev.vars* +!.dev.vars.example +.env* +!.env.example diff --git a/opengraph/README.md b/opengraph/README.md new file mode 100644 index 0000000..1776968 --- /dev/null +++ b/opengraph/README.md @@ -0,0 +1,19 @@ +# OpenGraph Worker + +Cloudflare Worker that injects dynamic `og:title`, `og:description`, `og:image`, and `og:url` into the atbbs SPA for link previews on social platforms. + +Handles two route patterns: + +- `/bbs/*` — rewrites the HTML `` tags with BBS/board/thread metadata from Slingshot +- `/og/bbs/*.png` — generates a branded 1200x630 preview image on the fly + +## Deploy + +```bash +cd opengraph +npm install +npx wrangler login +npx wrangler deploy -c wrangler.toml +``` + +Requires `atbbs.xyz` to be proxied through Cloudflare. diff --git a/opengraph/hero-light.svg b/opengraph/hero-light.svg new file mode 100644 index 0000000..bc12127 --- /dev/null +++ b/opengraph/hero-light.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/opengraph/index.ts b/opengraph/index.ts new file mode 100644 index 0000000..d1b36a2 --- /dev/null +++ b/opengraph/index.ts @@ -0,0 +1,340 @@ +import { ImageResponse, loadGoogleFont } from "workers-og"; +import heroLogoSvg from "./hero-light.svg"; + +const SLINGSHOT_URL = "https://slingshot.microcosm.blue/xrpc"; + +const DEFAULT_TITLE = "atbbs"; +const DEFAULT_DESCRIPTION = "Decentralized forums on the AT Protocol."; + +// Tailwind neutral palette — matches the site's light theme. +const COLORS = { + background: "#fafafa", // neutral-50 + title: "#171717", // neutral-900 + subtitle: "#525252", // neutral-600 + description: "#525252", // neutral-600 +}; + +// Types + +interface Route { + type: "bbs" | "board" | "thread" | "news"; + handle: string; + slug?: string; + did?: string; + rkey?: string; +} + +interface Metadata { + title: string; + subtitle: string; + description: string; +} + +interface SlingshotIdentity { + did: string; + handle: string; + pds?: string; +} + +interface SlingshotRecord { + uri: string; + cid: string; + value: Record; +} + +// Utils + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + "..."; +} + +// Slingshot + +async function resolveIdentity( + handle: string, +): Promise { + const response = await fetch( + `${SLINGSHOT_URL}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`, + ); + if (!response.ok) return null; + return (await response.json()) as SlingshotIdentity; +} + +async function fetchRecord( + did: string, + collection: string, + recordKey: string, +): Promise { + const response = await fetch( + `${SLINGSHOT_URL}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(recordKey)}`, + ); + if (!response.ok) return null; + return (await response.json()) as SlingshotRecord; +} + +async function fetchSiteName(did: string, fallback: string): Promise { + const siteRecord = await fetchRecord(did, "xyz.atbbs.site", "self"); + return siteRecord ? siteRecord.value.name : fallback; +} + +// --- Route parsing --- + +function parseRoute(path: string): Route | null { + // Strip /og prefix and .png suffix so this works for both HTML and image routes. + const normalizedPath = path.replace(/^\/og/, "").replace(/\.png$/, ""); + + const bbsMatch = normalizedPath.match(/^\/bbs\/([^/]+)$/); + if (bbsMatch) return { type: "bbs", handle: bbsMatch[1] }; + + const boardMatch = normalizedPath.match(/^\/bbs\/([^/]+)\/board\/([^/]+)$/); + if (boardMatch) + return { type: "board", handle: boardMatch[1], slug: boardMatch[2] }; + + const threadMatch = normalizedPath.match( + /^\/bbs\/([^/]+)\/thread\/([^/]+)\/([^/]+)$/, + ); + if (threadMatch) + return { + type: "thread", + handle: threadMatch[1], + did: threadMatch[2], + rkey: threadMatch[3], + }; + + const newsMatch = normalizedPath.match(/^\/bbs\/([^/]+)\/news\/([^/]+)$/); + if (newsMatch) + return { type: "news", handle: newsMatch[1], rkey: newsMatch[2] }; + + return null; +} + +// Metadata + +async function fetchMetadata(route: Route): Promise { + const identity = await resolveIdentity(route.handle); + if (!identity) return null; + + if (route.type === "bbs") { + const siteRecord = await fetchRecord( + identity.did, + "xyz.atbbs.site", + "self", + ); + if (siteRecord) { + return { + title: siteRecord.value.name, + subtitle: "", + description: siteRecord.value.description || "", + }; + } + } else if (route.type === "board") { + const siteName = await fetchSiteName(identity.did, route.handle); + const boardRecord = await fetchRecord( + identity.did, + "xyz.atbbs.board", + route.slug!, + ); + if (boardRecord) { + return { + title: boardRecord.value.name, + subtitle: siteName, + description: boardRecord.value.description || "", + }; + } + } else if (route.type === "thread") { + const siteName = await fetchSiteName(identity.did, route.handle); + const postRecord = await fetchRecord( + route.did!, + "xyz.atbbs.post", + route.rkey!, + ); + if (postRecord) { + return { + title: postRecord.value.title || "Thread", + subtitle: siteName, + description: postRecord.value.body || "", + }; + } + } else if (route.type === "news") { + const siteName = await fetchSiteName(identity.did, route.handle); + const postRecord = await fetchRecord( + identity.did, + "xyz.atbbs.post", + route.rkey!, + ); + if (postRecord) { + return { + title: postRecord.value.title || "News", + subtitle: siteName, + description: postRecord.value.body || "", + }; + } + } + + return null; +} + +// Generate OG Images + +const HERO_LOGO_DATA_URI = + "data:image/svg+xml," + encodeURIComponent(heroLogoSvg); + +async function renderOgImage( + title: string, + subtitle: string, + description: string, +): Promise { + const displayTitle = escapeHtml(truncate(title, 40)); + const displaySubtitle = escapeHtml(subtitle); + const displayDescription = escapeHtml(truncate(description, 120)); + + const fontData = await loadGoogleFont({ + family: "Geist Mono", + weight: 400, + }); + + const subtitleHtml = displaySubtitle + ? `
${displaySubtitle}
` + : ""; + + const html = ` +
+ +
+ ${subtitleHtml} +
${displayTitle}
+
${displayDescription}
+
+
+ `; + + return new ImageResponse(html, { + width: 1200, + height: 630, + fonts: [ + { + name: "Geist Mono", + data: fontData, + style: "normal", + weight: 400, + }, + ], + }); +} + +// Inject HTML + +function injectMetadata( + html: string, + title: string, + description: string, + pageUrl: string, + imageUrl: string, +): string { + const safeTitle = escapeHtml(title); + const safeDescription = escapeHtml(description.substring(0, 200)); + const safePageUrl = escapeHtml(pageUrl); + const safeImageUrl = escapeHtml(imageUrl); + + html = html.replace( + "atbbs", + `${safeTitle}`, + ); + html = html.replace( + '', + ``, + ); + html = html.replace( + '', + ``, + ); + html = html.replace( + '', + ``, + ); + + if (!html.includes("og:url")) { + html = html.replace( + '\n { + const url = new URL(request.url); + const path = url.pathname; + + // Dynamic og:image at /og/bbs/... + if (path.startsWith("/og/bbs/")) { + const route = parseRoute(path); + if (!route) { + return renderOgImage(DEFAULT_TITLE, "", DEFAULT_DESCRIPTION); + } + + try { + const metadata = await fetchMetadata(route); + if (metadata) { + return renderOgImage( + metadata.title, + metadata.subtitle, + metadata.description, + ); + } + } catch { + // Fall through to default image. + } + + return renderOgImage(DEFAULT_TITLE, "", DEFAULT_DESCRIPTION); + } + + // Inject metadata into HTML for /bbs/... routes. + const route = parseRoute(path); + const originResponse = await fetch(request); + const contentType = originResponse.headers.get("content-type") || ""; + + if (!route || !contentType.includes("text/html")) { + return originResponse; + } + + let html = await originResponse.text(); + + try { + const metadata = await fetchMetadata(route); + if (metadata) { + const fullTitle = metadata.subtitle + ? `${metadata.title} \u2014 ${metadata.subtitle}` + : metadata.title; + const imageUrl = `${url.origin}/og${path}.png`; + html = injectMetadata( + html, + fullTitle, + metadata.description, + url.toString(), + imageUrl, + ); + } + } catch { + // On any error, serve the original HTML unmodified. + } + + return new Response(html, { + status: originResponse.status, + headers: originResponse.headers, + }); + }, +}; diff --git a/opengraph/package-lock.json b/opengraph/package-lock.json new file mode 100644 index 0000000..26c68b5 --- /dev/null +++ b/opengraph/package-lock.json @@ -0,0 +1,237 @@ +{ + "name": "atbbs-opengraph", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atbbs-opengraph", + "version": "1.0.0", + "dependencies": { + "workers-og": "^0.0.27" + } + }, + "node_modules/@resvg/resvg-wasm": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz", + "integrity": "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/just-camel-case": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-camel-case/-/just-camel-case-6.2.0.tgz", + "integrity": "sha512-ICenRLXwkQYLk3UyvLQZ+uKuwFVJ3JHFYFn7F2782G2Mv2hW8WPePqgdhpnjGaqkYtSVWnyCESZhGXUmY3/bEg==", + "license": "MIT" + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/workers-og": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/workers-og/-/workers-og-0.0.27.tgz", + "integrity": "sha512-QvwptQ0twmouQHiITUi3kYxEPCLdueC/U4msQ2xMz2iktd+iseSs7zlREw3T1dAsPxPw73FQlw8cXFsfANZPlw==", + "dependencies": { + "@resvg/resvg-wasm": "2.4.0", + "just-camel-case": "^6.2.0", + "satori": "^0.15.2", + "yoga-wasm-web": "0.3.3" + } + }, + "node_modules/workers-og/node_modules/css-gradient-parser": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.16.tgz", + "integrity": "sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/workers-og/node_modules/satori": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.15.2.tgz", + "integrity": "sha512-vu/49vdc8MzV5jUchs3TIRDCOkOvMc1iJ11MrZvhg9tE4ziKIEIBjBZvies6a9sfM2vQ2gc3dXeu6rCK7AztHA==", + "license": "MPL-2.0", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.16", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-wasm-web": "^0.3.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/yoga-wasm-web": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", + "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==", + "license": "MIT" + } + } +} diff --git a/opengraph/package.json b/opengraph/package.json new file mode 100644 index 0000000..8f6a7f5 --- /dev/null +++ b/opengraph/package.json @@ -0,0 +1,8 @@ +{ + "name": "atbbs-opengraph", + "version": "1.0.0", + "private": true, + "dependencies": { + "workers-og": "^0.0.27" + } +} diff --git a/opengraph/wrangler.toml b/opengraph/wrangler.toml new file mode 100644 index 0000000..070aa2d --- /dev/null +++ b/opengraph/wrangler.toml @@ -0,0 +1,15 @@ +name = "atbbs-opengraph" +main = "index.ts" +compatibility_date = "2024-01-01" + +rules = [ + { type = "Text", globs = ["**/*.svg"] } +] + +[[routes]] +pattern = "*atbbs.xyz/bbs/*" +zone_name = "atbbs.xyz" + +[[routes]] +pattern = "*atbbs.xyz/og/*" +zone_name = "atbbs.xyz" diff --git a/web/public/og.png b/web/public/og.png index cbe46ede8805bd1f0d98984e1d7e8155fe61135c..eaad66e555faa2342558c8c888e1b029e452ba32 100644 GIT binary patch delta 402 zcmbO(H(zdonf(<{7srr_Id5-n%zbRY(01|k!KKp+&WpxYx_*m0G<8+9f`tl)j@z$$ z(GB|*%;bM;%)Kqoz`#)TZ=Dmf zwrrP3ep7aBPVx557GmE88j3$SO}uzm;!5gQhQ242``?vad;9U$+cld%?Pp;4VXr^g zk@1LjY#4vT*LmMx=cV73TYvZ5qvyvYR;$b0n^XL?r5-*fJRIFMWZ_O`Y6Kq18xw^@*B h5h$o)8O8oGE2ji`#Hn51!oa}5;OXk;vd$@?2>^8X#`ORI delta 390 zcmbO)H(hRmnZp@R7srr_Id5+s%zbPi;BfJ*lls?#OffT77bfa8to^Zpm!+{;;gQ$> zy=)dt?(O__Z+`z{XJBZUw|_M|0|P?>?`A{p8_bgfm{S$Eu9ai3`@Uy?Sbss)_T#4Y zA1t3IruQ21S$7w2Y-fmR7N6L7L~Pqt<^r|zzh7_IoX3CqXYf02hKBW(ijyBQ9oEc> zwq;oJeb0VA>(}2tT%LP!PVrZc<>tpCY!AJ>d3gTjcKdYx^GDBpw$q;+$aq98cQt#% zwC8`fR9-Hf%rQ45 z9&gzEY)<>!(opw%;(N|>Pi|y7B6a%}W5V9shx-{A;w6*U$UXc5Qt@u|*_+@70(fq}u()z4*}Q$iB}XkNO>