diff options
author | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-28 18:14:22 -0300 |
---|---|---|
committer | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-28 18:14:22 -0300 |
commit | 79fd506d30eef3d113f4a8e3ab9ebd9004f1e8cc (patch) | |
tree | 96ff57c92e897c3cc3331e23043d20f1665c7d0a | |
parent | a1eac976b20e39f86d5944fbec68e2a0f8ffb746 (diff) |
feat: index page
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
42 files changed, 1297 insertions, 554 deletions
diff --git a/astro.config.ts b/astro.config.ts index 499b7ec..3f16134 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -13,9 +13,11 @@ import { visit } from "unist-util-visit"; import rehypeSanitize from "rehype-sanitize"; import remarkToc from "remark-toc"; import { get } from "./src/utils/anonymous.ts"; +// import { env } from "./src/lib/env.ts"; // https://astro.build/config export default defineConfig({ + // site: new URL(env.PUBLIC_SITE_URL).href, site: "https://cravodeabril.pt", integrations: [sitemap({ serialize: async (item) => { @@ -2,6 +2,7 @@ "$schema": "https://deno.land/x/deno/cli/schemas/config-file.v1.json", "tasks": { "dev": "astro dev", + //"build": "astro check && astro build", "build": "astro build", "preview": "astro preview", "build:preview": { @@ -21,11 +21,10 @@ "npm:@astrojs/rss@4.0.12": "4.0.12", "npm:@astrojs/sitemap@3.4.1": "3.4.1", "npm:@openpgp/web-stream-tools@~0.1.3": "0.1.3_typescript@5.8.3", + "npm:@t3-oss/env-core@~0.13.8": "0.13.8_typescript@5.8.3_zod@3.25.67", "npm:@types/mdast@*": "4.0.4", "npm:@types/mdast@^4.0.4": "4.0.4", - "npm:@types/unist@*": "3.0.3", - "npm:@types/unist@^3.0.3": "3.0.3", - "npm:astro@5.9.3": "5.9.3_typescript@5.8.3_vite@6.3.5__picomatch@4.0.2_zod@3.25.64", + "npm:astro@5.10.1": "5.10.1_typescript@5.8.3_vite@6.3.5__picomatch@4.0.2_zod@3.25.67", "npm:openpgp@^6.1.1": "6.1.1", "npm:reading-time@^1.5.0": "1.5.0", "npm:rehype-external-links@3": "3.0.0", @@ -39,7 +38,8 @@ "npm:unified@^11.0.5": "11.0.5", "npm:unist-util-visit@5": "5.0.0", "npm:vfile@^6.0.3": "6.0.3", - "npm:yaqrcode@~0.2.1": "0.2.1" + "npm:yaqrcode@~0.2.1": "0.2.1", + "npm:zod@^3.25.67": "3.25.67" }, "jsr": { "@std/assert@1.0.13": { @@ -83,7 +83,6 @@ "integrity": "144b3737105b9071cb50c957681f58a1b8ec0f3e5b19ad830f401c5fa931e8f0", "dependencies": [ "jsr:@std/assert", - "jsr:@std/async", "jsr:@std/data-structures", "jsr:@std/fs@^1.0.18", "jsr:@std/internal@^1.0.8", @@ -115,7 +114,7 @@ "@astrojs/internal-helpers@0.6.1": { "integrity": "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A==" }, - "@astrojs/language-server@2.15.4_typescript@5.8.3_@volar+language-service@2.4.14": { + "@astrojs/language-server@2.15.4_typescript@5.8.3_@volar+language-service@2.4.15": { "integrity": "sha512-JivzASqTPR2bao9BWsSc/woPHH7OGSGc9aMxXL4U6egVTqBycB3ZHdBJPuOCVtcGLrzdWTosAqVPz1BVoxE0+A==", "dependencies": [ "@astrojs/compiler", @@ -550,116 +549,116 @@ "@oslojs/encoding@1.1.0": { "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" }, - "@rollup/pluginutils@5.1.4": { - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "@rollup/pluginutils@5.2.0": { + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", "dependencies": [ - "@types/estree@1.0.8", + "@types/estree", "estree-walker@2.0.2", "picomatch@4.0.2" ] }, - "@rollup/rollup-android-arm-eabi@4.43.0": { - "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", + "@rollup/rollup-android-arm-eabi@4.44.0": { + "integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.43.0": { - "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", + "@rollup/rollup-android-arm64@4.44.0": { + "integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.43.0": { - "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", + "@rollup/rollup-darwin-arm64@4.44.0": { + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.43.0": { - "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", + "@rollup/rollup-darwin-x64@4.44.0": { + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.43.0": { - "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "@rollup/rollup-freebsd-arm64@4.44.0": { + "integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.43.0": { - "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "@rollup/rollup-freebsd-x64@4.44.0": { + "integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.43.0": { - "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", + "@rollup/rollup-linux-arm-gnueabihf@4.44.0": { + "integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.43.0": { - "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", + "@rollup/rollup-linux-arm-musleabihf@4.44.0": { + "integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.43.0": { - "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", + "@rollup/rollup-linux-arm64-gnu@4.44.0": { + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.43.0": { - "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", + "@rollup/rollup-linux-arm64-musl@4.44.0": { + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loongarch64-gnu@4.43.0": { - "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "@rollup/rollup-linux-loongarch64-gnu@4.44.0": { + "integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-powerpc64le-gnu@4.43.0": { - "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "@rollup/rollup-linux-powerpc64le-gnu@4.44.0": { + "integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.43.0": { - "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "@rollup/rollup-linux-riscv64-gnu@4.44.0": { + "integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.43.0": { - "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", + "@rollup/rollup-linux-riscv64-musl@4.44.0": { + "integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.43.0": { - "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", + "@rollup/rollup-linux-s390x-gnu@4.44.0": { + "integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.43.0": { - "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "@rollup/rollup-linux-x64-gnu@4.44.0": { + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.43.0": { - "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", + "@rollup/rollup-linux-x64-musl@4.44.0": { + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-win32-arm64-msvc@4.43.0": { - "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", + "@rollup/rollup-win32-arm64-msvc@4.44.0": { + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.43.0": { - "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", + "@rollup/rollup-win32-ia32-msvc@4.44.0": { + "integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-msvc@4.43.0": { - "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", + "@rollup/rollup-win32-x64-msvc@4.44.0": { + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", "os": ["win32"], "cpu": ["x64"] }, - "@shikijs/core@3.6.0": { - "integrity": "sha512-9By7Xb3olEX0o6UeJyPLI1PE1scC4d3wcVepvtv2xbuN9/IThYN4Wcwh24rcFeASzPam11MCq8yQpwwzCgSBRw==", + "@shikijs/core@3.7.0": { + "integrity": "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==", "dependencies": [ "@shikijs/types", "@shikijs/vscode-textmate", @@ -667,35 +666,35 @@ "hast-util-to-html" ] }, - "@shikijs/engine-javascript@3.6.0": { - "integrity": "sha512-7YnLhZG/TU05IHMG14QaLvTW/9WiK8SEYafceccHUSXs2Qr5vJibUwsDfXDLmRi0zHdzsxrGKpSX6hnqe0k8nA==", + "@shikijs/engine-javascript@3.7.0": { + "integrity": "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==", "dependencies": [ "@shikijs/types", "@shikijs/vscode-textmate", "oniguruma-to-es" ] }, - "@shikijs/engine-oniguruma@3.6.0": { - "integrity": "sha512-nmOhIZ9yT3Grd+2plmW/d8+vZ2pcQmo/UnVwXMUXAKTXdi+LK0S08Ancrz5tQQPkxvjBalpMW2aKvwXfelauvA==", + "@shikijs/engine-oniguruma@3.7.0": { + "integrity": "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==", "dependencies": [ "@shikijs/types", "@shikijs/vscode-textmate" ] }, - "@shikijs/langs@3.6.0": { - "integrity": "sha512-IdZkQJaLBu1LCYCwkr30hNuSDfllOT8RWYVZK1tD2J03DkiagYKRxj/pDSl8Didml3xxuyzUjgtioInwEQM/TA==", + "@shikijs/langs@3.7.0": { + "integrity": "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==", "dependencies": [ "@shikijs/types" ] }, - "@shikijs/themes@3.6.0": { - "integrity": "sha512-Fq2j4nWr1DF4drvmhqKq8x5vVQ27VncF8XZMBuHuQMZvUSS3NBgpqfwz/FoGe36+W6PvniZ1yDlg2d4kmYDU6w==", + "@shikijs/themes@3.7.0": { + "integrity": "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==", "dependencies": [ "@shikijs/types" ] }, - "@shikijs/types@3.6.0": { - "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "@shikijs/types@3.7.0": { + "integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==", "dependencies": [ "@shikijs/vscode-textmate", "@types/hast" @@ -710,15 +709,32 @@ "tslib" ] }, + "@t3-oss/env-core@0.13.8_typescript@5.8.3": { + "integrity": "sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==", + "dependencies": [ + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, + "@t3-oss/env-core@0.13.8_typescript@5.8.3_zod@3.25.67": { + "integrity": "sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==", + "dependencies": [ + "typescript", + "zod" + ], + "optionalPeers": [ + "typescript", + "zod" + ] + }, "@types/debug@4.1.12": { "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "dependencies": [ "@types/ms" ] }, - "@types/estree@1.0.7": { - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" - }, "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, @@ -773,8 +789,8 @@ "@ungap/structured-clone@1.3.0": { "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" }, - "@volar/kit@2.4.14_typescript@5.8.3": { - "integrity": "sha512-kBcmHjEodtmYGJELHePZd2JdeYm4ZGOd9F/pQ1YETYIzAwy4Z491EkJ1nRSo/GTxwKt0XYwYA/dHSEgXecVHRA==", + "@volar/kit@2.4.15_typescript@5.8.3": { + "integrity": "sha512-y6PX5AFnvVqAWJ8JgstZ1MkSMn0zlOa+qZqZ5TS9SrPmRtQ0TzwRzNJCZnN5zwAro/SsYxecHx03aGH/7evJ/A==", "dependencies": [ "@volar/language-service", "@volar/typescript", @@ -784,14 +800,14 @@ "vscode-uri" ] }, - "@volar/language-core@2.4.14": { - "integrity": "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==", + "@volar/language-core@2.4.15": { + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", "dependencies": [ "@volar/source-map" ] }, - "@volar/language-server@2.4.14": { - "integrity": "sha512-P3mGbQbW0v40UYBnb3DAaNtRYx6/MGOVKzdOWmBCGwjUkCR2xBkGrCFt05XnPDwFS/cTWDh2U6Mc9lpZ8Aecfw==", + "@volar/language-server@2.4.15": { + "integrity": "sha512-aSzvL3lgQ+RPU3uWA9wW85sfZ0tb+oKplfnOwG/c1iRMuVEJRofmcnjyN0JEOKbBR7GuPSbeUdLAI0AIL+TFew==", "dependencies": [ "@volar/language-core", "@volar/language-service", @@ -804,8 +820,8 @@ "vscode-uri" ] }, - "@volar/language-service@2.4.14": { - "integrity": "sha512-vNC3823EJohdzLTyjZoCMPwoWCfINB5emusniCkW5CGoGHQov4VVmT6yI5ncgP/NpgAIUv2NEkJooXvLHA4VeQ==", + "@volar/language-service@2.4.15": { + "integrity": "sha512-o7ctGyQNQAZqT15xHamE0fTzPZHeDnHWz0m/KJekSPc2W4AHiEbJ2RNDLzEK4e0EjrpdeEe3FB9KQvOvjq+I6Q==", "dependencies": [ "@volar/language-core", "vscode-languageserver-protocol@3.17.5", @@ -813,11 +829,11 @@ "vscode-uri" ] }, - "@volar/source-map@2.4.14": { - "integrity": "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ==" + "@volar/source-map@2.4.15": { + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==" }, - "@volar/typescript@2.4.14": { - "integrity": "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==", + "@volar/typescript@2.4.15": { + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", "dependencies": [ "@volar/language-core", "path-browserify", @@ -890,8 +906,8 @@ "array-iterate@2.0.1": { "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==" }, - "astro@5.9.3_typescript@5.8.3_vite@6.3.5__picomatch@4.0.2_zod@3.25.64": { - "integrity": "sha512-VReZrpUa/3rfeiVvsQ1A2M3ujDPI+pDGIYOMtXPEZwut8tZoEyealXXLjitgCsJ+3dunKGZbg4Eak6i+r0vniw==", + "astro@5.10.1_typescript@5.8.3_vite@6.3.5__picomatch@4.0.2_zod@3.25.67": { + "integrity": "sha512-DJVmt+51jU1xmgmAHCDwuUgcG/5aVFSU+tcX694acAZqPVt8EMUAmUZcJDX36Z7/EztnPph9HR3pm72jS2EgHQ==", "dependencies": [ "@astrojs/compiler", "@astrojs/internal-helpers", @@ -1216,7 +1232,7 @@ "estree-walker@3.0.3": { "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dependencies": [ - "@types/estree@1.0.8" + "@types/estree" ] }, "eventemitter3@5.0.1": { @@ -2059,8 +2075,8 @@ "picomatch@4.0.2": { "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" }, - "postcss@8.5.5": { - "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", + "postcss@8.5.6": { + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dependencies": [ "nanoid", "picocolors", @@ -2270,10 +2286,10 @@ "reusify@1.1.0": { "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" }, - "rollup@4.43.0": { - "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", + "rollup@4.44.0": { + "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", "dependencies": [ - "@types/estree@1.0.7" + "@types/estree" ], "optionalDependencies": [ "@rollup/rollup-android-arm-eabi", @@ -2343,8 +2359,8 @@ ], "scripts": true }, - "shiki@3.6.0": { - "integrity": "sha512-tKn/Y0MGBTffQoklaATXmTqDU02zx8NYBGQ+F6gy87/YjKbizcLd+Cybh/0ZtOBX9r1NEnAy/GTRDKtOsc1L9w==", + "shiki@3.7.0": { + "integrity": "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==", "dependencies": [ "@shikijs/core", "@shikijs/engine-javascript", @@ -2523,10 +2539,11 @@ "vfile" ] }, - "unifont@0.5.0": { - "integrity": "sha512-4DueXMP5Hy4n607sh+vJ+rajoLu778aU3GzqeTCqsD/EaUcvqZT9wPC8kgK6Vjh22ZskrxyRCR71FwNOaYn6jA==", + "unifont@0.5.2": { + "integrity": "sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg==", "dependencies": [ "css-tree", + "ofetch", "ohash" ] }, @@ -2639,8 +2656,8 @@ ], "bin": true }, - "vitefu@1.0.6_vite@6.3.5__picomatch@4.0.2": { - "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "vitefu@1.0.7_vite@6.3.5__picomatch@4.0.2": { + "integrity": "sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q==", "dependencies": [ "vite" ], @@ -2648,7 +2665,7 @@ "vite" ] }, - "volar-service-css@0.0.62_@volar+language-service@2.4.14": { + "volar-service-css@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-JwNyKsH3F8PuzZYuqPf+2e+4CTU8YoyUHEHVnoXNlrLe7wy9U3biomZ56llN69Ris7TTy/+DEX41yVxQpM4qvg==", "dependencies": [ "@volar/language-service", @@ -2660,7 +2677,7 @@ "@volar/language-service" ] }, - "volar-service-emmet@0.0.62_@volar+language-service@2.4.14": { + "volar-service-emmet@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-U4dxWDBWz7Pi4plpbXf4J4Z/ss6kBO3TYrACxWNsE29abu75QzVS0paxDDhI6bhqpbDFXlpsDhZ9aXVFpnfGRQ==", "dependencies": [ "@emmetio/css-parser", @@ -2673,7 +2690,7 @@ "@volar/language-service" ] }, - "volar-service-html@0.0.62_@volar+language-service@2.4.14": { + "volar-service-html@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-Zw01aJsZRh4GTGUjveyfEzEqpULQUdQH79KNEiKVYHZyuGtdBRYCHlrus1sueSNMxwwkuF5WnOHfvBzafs8yyQ==", "dependencies": [ "@volar/language-service", @@ -2685,7 +2702,7 @@ "@volar/language-service" ] }, - "volar-service-prettier@0.0.62_@volar+language-service@2.4.14": { + "volar-service-prettier@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-h2yk1RqRTE+vkYZaI9KYuwpDfOQRrTEMvoHol0yW4GFKc75wWQRrb5n/5abDrzMPrkQbSip8JH2AXbvrRtYh4w==", "dependencies": [ "@volar/language-service", @@ -2695,7 +2712,7 @@ "@volar/language-service" ] }, - "volar-service-typescript-twoslash-queries@0.0.62_@volar+language-service@2.4.14": { + "volar-service-typescript-twoslash-queries@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-KxFt4zydyJYYI0kFAcWPTh4u0Ha36TASPZkAnNY784GtgajerUqM80nX/W1d0wVhmcOFfAxkVsf/Ed+tiYU7ng==", "dependencies": [ "@volar/language-service", @@ -2705,7 +2722,7 @@ "@volar/language-service" ] }, - "volar-service-typescript@0.0.62_@volar+language-service@2.4.14": { + "volar-service-typescript@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-p7MPi71q7KOsH0eAbZwPBiKPp9B2+qrdHAd6VY5oTo9BUXatsOAdakTm9Yf0DUj6uWBAaOT01BSeVOPwucMV1g==", "dependencies": [ "@volar/language-service", @@ -2720,7 +2737,7 @@ "@volar/language-service" ] }, - "volar-service-yaml@0.0.62_@volar+language-service@2.4.14": { + "volar-service-yaml@0.0.62_@volar+language-service@2.4.15": { "integrity": "sha512-k7gvv7sk3wa+nGll3MaSKyjwQsJjIGCHFjVkl3wjaSP2nouKyn9aokGmqjrl39mi88Oy49giog2GkZH526wjig==", "dependencies": [ "@volar/language-service", @@ -2908,29 +2925,26 @@ "yoctocolors@2.1.1": { "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==" }, - "zod-to-json-schema@3.24.5_zod@3.25.64": { + "zod-to-json-schema@3.24.5_zod@3.25.67": { "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", "dependencies": [ "zod" ] }, - "zod-to-ts@1.2.0_typescript@5.8.3_zod@3.25.64": { + "zod-to-ts@1.2.0_typescript@5.8.3_zod@3.25.67": { "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", "dependencies": [ "typescript", "zod" ] }, - "zod@3.25.64": { - "integrity": "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==" + "zod@3.25.67": { + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==" }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } }, - "redirects": { - "https://esm.sh/@types/mdast@latest": "https://esm.sh/@types/mdast@4.0.4/index.d.ts" - }, "workspace": { "dependencies": [ "jsr:@std/assert@^1.0.13", @@ -2950,8 +2964,9 @@ "npm:@astrojs/rss@4.0.12", "npm:@astrojs/sitemap@3.4.1", "npm:@openpgp/web-stream-tools@~0.1.3", + "npm:@t3-oss/env-core@~0.13.8", "npm:@types/mdast@^4.0.4", - "npm:astro@5.9.3", + "npm:astro@5.10.1", "npm:openpgp@^6.1.1", "npm:reading-time@^1.5.0", "npm:rehype-external-links@3", @@ -2965,7 +2980,8 @@ "npm:unified@^11.0.5", "npm:unist-util-visit@5", "npm:vfile@^6.0.3", - "npm:yaqrcode@~0.2.1" + "npm:yaqrcode@~0.2.1", + "npm:zod@^3.25.67" ] } } diff --git a/package.json b/package.json index 5bcaeec..e596222 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "@astrojs/markdown-remark": "^6.3.2", "@astrojs/rss": "4.0.12", "@astrojs/sitemap": "3.4.1", + "@t3-oss/env-core": "^0.13.8", "@types/mdast": "^4.0.4", - "astro": "5.9.3", + "astro": "5.10.1", "openpgp": "^6.1.1", "reading-time": "^1.5.0", "rehype-external-links": "^3.0.0", @@ -22,7 +23,8 @@ "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3", - "yaqrcode": "^0.2.1" + "yaqrcode": "^0.2.1", + "zod": "^3.25.67" }, "devDependencies": { "@openpgp/web-stream-tools": "^0.1.3" diff --git a/public/blog/TEMPLATE b/public/blog/TEMPLATE index a21288e..ea0bdd7 100644 --- a/public/blog/TEMPLATE +++ b/public/blog/TEMPLATE @@ -13,11 +13,11 @@ lang = 'pt-PT' # translationOf = '' # license = '' # -# [[signer]] +# [[signers]] # entity = '' # role = '' # -# [[signer]] +# [[signers]] # entity = '' # role = '' +++ diff --git a/public/blog/legislativas-2025.md b/public/blog/legislativas-2025.md index 89c48ff..f6baf82 100644 --- a/public/blog/legislativas-2025.md +++ b/public/blog/legislativas-2025.md @@ -1,4 +1,5 @@ +++ +kind = 'original' title = 'Eleições para a Assembleia da República 2025' subtitle = 'A minha opinião acerca dos resultados' description = ''' @@ -13,7 +14,7 @@ locationCreated = 'RJ, Brasil' lang = 'pt-PT' license = "CC-BY-NC-SA" -[[signer]] +[[signers]] entity = 'cravodeabril' role = 'author' +++ diff --git a/public/blog/micro-test.md b/public/blog/micro-test.md new file mode 100644 index 0000000..8d64b60 --- /dev/null +++ b/public/blog/micro-test.md @@ -0,0 +1,16 @@ ++++ +kind = 'micro' +title = 'Isto é um microblog post de teste' +keywords = ['test'] +dateCreated = 2025-06-25T23:21:00-03:00 +locationCreated = 'RJ, Brasil' +lang = 'pt-PT' + +[[signers]] +entity = 'cravodeabril' +role = 'author' ++++ + +Apenas testando uma coisa aqui. + +Como está a correr o teu dia? diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index 5ac0410..b4dbb74 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -1,8 +1,6 @@ --- -// Import the global.css file here so that it is included on -// all pages through the use of the <BaseHead /> component. +import { env } from "@lib/env"; import "../styles/global.css"; -import { SITE_AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from "../consts"; import { ClientRouter } from "astro:transitions"; export interface Props { @@ -12,11 +10,24 @@ export interface Props { keywords?: string[]; } +const { + PUBLIC_SITE_TITLE, + PUBLIC_SITE_DESCRIPTION, + PUBLIC_SITE_AUTHOR, + PUBLIC_TOR_URL, +} = env; + +const isOnion = Astro.url.origin.endsWith(".onion"); +const alternate = !isOnion ? PUBLIC_TOR_URL : Astro.site; + const canonicalURL = new URL(Astro.url.pathname, Astro.site); -const { title, description = SITE_DESCRIPTION, image, keywords = [] } = - Astro.props; -// const socialImage = image ?? Astro.site.href + 'assets/social.png' +const { + title, + description = PUBLIC_SITE_DESCRIPTION, + image = new URL("favicon.svg", Astro.site), + keywords = [], +} = Astro.props; --- <!-- Global Metadata --> @@ -28,24 +39,30 @@ const { title, description = SITE_DESCRIPTION, image, keywords = [] } = <link rel="alternate" type="application/rss+xml" - title={SITE_TITLE} + title={PUBLIC_SITE_TITLE} href={new URL("rss.xml", Astro.site)} /> <meta name="generator" content={Astro.generator} /> <!-- Canonical URL --> <link rel="canonical" href={canonicalURL} /> +<link + rel="alternate" + href={alternate} + type="text/html" + title={`${isOnion ? "Clearnet" : "Tor"} version`} +> <!-- Primary Meta Tags --> <title>{title}</title> <meta name="title" content={title} /> <meta name="description" content={description} /> -<meta name="author" content={SITE_AUTHOR} /> +<meta name="author" content={PUBLIC_SITE_AUTHOR} /> {keywords.length > 0 && <meta name="keywords" content={keywords.join(",")} />} -<meta name="theme-color" content="#a50026" /> +<meta name="theme-color" content="oklch(0.4564 0.1835 20.81)" /> <meta name="theme-color" - content="#f46d43" + content="oklch(0.6923 0.1759 37.7)" media="(prefers-color-scheme: dark)" /> @@ -54,26 +71,13 @@ const { title, description = SITE_DESCRIPTION, image, keywords = [] } = <meta property="og:url" content={Astro.url} /> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> -{image && <meta property="og:image" content={new URL(image, Astro.url)} />} +<meta property="og:image" content={image} /> <!-- Twitter --> <meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:url" content={Astro.url} /> <meta property="twitter:title" content={title} /> <meta property="twitter:description" content={description} /> -{image && <meta property="twitter:image" content={new URL(image, Astro.url)} />} +<meta property="twitter:image" content={image} /> <ClientRouter /> - -<script is:inline> - const root = document.documentElement; - const theme = localStorage.getItem("theme"); - if ( - theme === "dark" || - (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) - ) { - root.classList.add("theme-dark"); - } else { - root.classList.remove("theme-dark"); - } -</script> diff --git a/src/components/Commit.astro b/src/components/Commit.astro deleted file mode 100644 index 3ee284a..0000000 --- a/src/components/Commit.astro +++ /dev/null @@ -1,49 +0,0 @@ ---- -import type { Commit } from "@lib/git/types"; -import { gitDir } from "@lib/git"; - -type Props = Commit; - -const { hash, files, author, signature } = Astro.props; - -const git = await gitDir; ---- -<p>Git commit info:</p> -<dl> - <dt>Hash</dt> - <dd>{hash}</dd> - <dt>Files</dt> - {files.map((file) => <dd>{file.pathname.replace(git, "")}</dd>)} - <dt>Author</dt> - <dd>{author.name} <{author.email}></dd> - { - signature && ( - <dt>Commit Signature</dt> - <dd> - <dl> - <dt>Type</dt> - <dd>{signature.type}</dd> - <dt>Signer</dt> - <dd>{signature.signerName}</dd> - <dt>Key fingerprint</dt> - <dd>{signature.keyFingerPrint}</dd> - </dl> - </dd> - ) - } -</dl> - -<style> - dl { - display: grid; - grid-template-columns: 1fr 1fr; - } - - dl > dt, dd { - display: inline-block; - } - - dt::after { - content: ": "; - } -</style> diff --git a/src/components/CopyrightNotice.astro b/src/components/CopyrightNotice.astro index 2aa72ad..6b3bd48 100644 --- a/src/components/CopyrightNotice.astro +++ b/src/components/CopyrightNotice.astro @@ -1,7 +1,10 @@ --- +import { + CREATIVE_COMMONS_LICENSES, + type LICENSES, +} from "@lib/collection/schemas"; import CC from "./licenses/CC.astro"; import WTFPL from "./licenses/WTFPL.astro"; -import { CREATIVE_COMMONS_LICENSES, LICENSES } from "../consts.ts"; export interface Props { title: string; diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 11c62c4..c3dffca 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,62 +1,107 @@ --- +import { env } from "@lib/env"; +const { + PUBLIC_GIT_URL, + PUBLIC_TOR_URL, + PUBLIC_GIT_TOR_URL, + PUBLIC_SIMPLE_X_ADDRESS, +} = env; +const isOnion = Astro.url.origin.endsWith(".onion"); +const site = isOnion ? PUBLIC_TOR_URL : Astro.site; +const git = isOnion ? PUBLIC_GIT_TOR_URL ?? PUBLIC_GIT_URL : PUBLIC_GIT_URL; --- -<footer> +<footer class="small"> + { + !isOnion && PUBLIC_TOR_URL && ( + <p class="mute"> + Disponível também em: <a class="tor" href={PUBLIC_TOR_URL}>{ + PUBLIC_TOR_URL + }</a> + </p> + ) + } <address> - Sítio web de <a href={Astro.site} target="_blank" rel="author" - >João Augusto Costa Branco Marado Torres</a> + <p> + Sítio web de <a href={site} target="_blank" rel="author" + >João Augusto Costa Branco Marado Torres</a> + </p> + { + PUBLIC_SIMPLE_X_ADDRESS && ( + <p> + Contacte-me através do <a href={PUBLIC_SIMPLE_X_ADDRESS}>SimpleX</a>! + </p> + ) + } </address> - <section id="copying"> - <h2>Licença de <span lang="en">Software</span></h2> + <p> + Isto é <abbr title="Free Libre and Open Source Software">FLOSS</abbr>, <a + href={git} + >usa as tuas liberdades</a> + </p> + <section id="copying" class="mute"> + <h2 class="sr-only">Licença de <span lang="en">Software</span></h2> <div lang="en"> <p> - <small> - <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright - © 2025 João Augusto Costa Branco Marado Torres - </small> + <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright + © 2025 João Augusto Costa Branco Marado Torres </p> <p> - <small> - This program is free software: you can redistribute it and/or modify - it under the terms of the <a - href="https://www.gnu.org/licenses/agpl-3.0.html" - target="_blank" - rel="external license" - >GNU Affero General Public License</a> as published by the Free - Software Foundation, either version 3 of the License, or (at your - option) any later version. - </small> + This program is free software: you can redistribute it and/or modify it + under the terms of the <a + href="https://www.gnu.org/licenses/agpl-3.0.html" + target="_blank" + rel="external license" + >GNU Affero General Public License</a> as published by the Free Software + Foundation, either version 3 of the License, or (at your option) any + later version. </p> <p> - <small> - This program is distributed in the hope that it will be useful, but - WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Affero General Public License for more details. - </small> + This program is distributed in the hope that it will be useful, but + <strong>without any warranty</strong>; without even the implied warranty + of + <strong>merchantability</strong> or <strong>fitness for a particular + purpose</strong>. See the GNU Affero General Public License for more + details. </p> <p> - <small> - You should have received a copy of the GNU Affero General Public - License along with this program. If not, see <a - href="https://www.gnu.org/licenses/" - target="_blank" - rel="external" - >https://www.gnu.org/licenses</a> - </small> + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <a + href="https://www.gnu.org/licenses/" + target="_blank" + rel="external" + >https://www.gnu.org/licenses</a> </p> </div> </section> <nav> <ul> - <li><a>Código de Conduta</a></li> - <li><a>Declaração de Exoneração de Responsabilidade</a></li> - <li><a>Aviso sobre cookies</a></li> - <li><a>Declaração de acessibilidade</a></li> - <li><a>Apoio</a></li> - <li><a>Contacto</a></li> - <li><a>Código fonte</a></li> + <li><a href="/">Código de Conduta</a></li> + <li><a href="/">Declaração de Exoneração de Responsabilidade</a></li> + <li><a href="/">Aviso sobre cookies</a></li> + <li><a href="/">Declaração de acessibilidade</a></li> + <li><a href="/">Apoio</a></li> </ul> </nav> </footer> + +<style> + footer { + border-block-start: 1px solid var(--color-light); + padding-block-start: calc(var(--size-4) * 1em); + } + + .tor { + word-wrap: break-word; + } + + nav > ul { + display: flex; + flex-direction: column; + gap: calc(var(--size-1) * 1em); + & > li { + padding-block: calc(var(--size-1) * 1em); + } + } +</style> diff --git a/src/components/Header.astro b/src/components/Header.astro index 28ab542..496337f 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,26 +1,53 @@ --- -import HeaderLink from "./HeaderLink.astro"; -import Search from "./Search.astro"; - -export interface Props { - showSearch?: boolean; - showNav?: boolean; -} - -const { showSearch, showNav } = Astro.props; +import ActiveLink from "./organisms/ActiveLink.astro"; +import Search from "./templates/Search.astro"; --- <header> - <h1><<a href="/">cravodeabril.pt</a>></h1> - {showSearch && <Search />} - { - showNav && ( - <nav> - <ul> - <li><HeaderLink href="/blog">Publicações</HeaderLink></li> - <li><HeaderLink href="/blog/keywords">Palavras-Chave</HeaderLink></li> - </ul> - </nav> - ) - } + <h1> + <span class="bracket"><</span><a href="/">cravodeabril.pt</a><span + class="bracket" + >></span> + </h1> + <Search /> + <nav> + <ul> + <li class="small"><ActiveLink href="/blog">Publicações</ActiveLink></li> + <li class="small"> + <ActiveLink href="/blog/keywords">Palavras-Chave</ActiveLink> + </li> + <li class="small"> + <ActiveLink href="/blog/micro/1">Micro blogue</ActiveLink> + </li> + </ul> + </nav> </header> + +<style> + header { + margin-block-end: calc(var(--size-4) * 1em); + border-block-end: 1px solid var(--color-light); + } + .bracket { + color: var(--color-active); + } + nav { + display: flex; + max-width: max-content; + align-items: center; + justify-content: center; + } + ul { + display: flex; + flex: 1; + gap: calc(var(--size-0) * 1em); + list-style-type: none; + align-items: center; + justify-content: center; + padding: calc(var(--size-2) * 1em); + margin-block-start: 0; + } + li { + padding: calc(var(--size-1) * 1em); + } +</style> diff --git a/src/components/Search.astro b/src/components/Search.astro deleted file mode 100644 index 5ca4569..0000000 --- a/src/components/Search.astro +++ /dev/null @@ -1,32 +0,0 @@ ---- - ---- - -<search> - <form - action="https://www.google.com/search" - target="_blank" - rel="external noreferrer search" - role="search" - autocomplete="on" - name="search" - > - <p> - <label>Barra de pesquisa: <input - name="q" - type="search" - placeholder={`site:${Astro.site} consulta de pesquisa`} - value={`site:${Astro.site} `} - required - title={`"site:${Astro.site} " é usado para que os resultados da pesquisa fiquem restritos a este website`} - pattern={`site:${Astro.site} .+`} - size={`site:${Astro.site} .+`.length} - /></label> - </p> - <p><button type="submit">Pesquisar</button></p> - <p> - <small>Esta pesquisa é efectuada pelo Google e utiliza software - proprietário.</small> - </p> - </form> -</search> diff --git a/src/components/HeaderLink.astro b/src/components/organisms/ActiveLink.astro index 8c01f92..8c01f92 100644 --- a/src/components/HeaderLink.astro +++ b/src/components/organisms/ActiveLink.astro diff --git a/src/components/organisms/Date.astro b/src/components/organisms/Date.astro new file mode 100644 index 0000000..a8b643d --- /dev/null +++ b/src/components/organisms/Date.astro @@ -0,0 +1,14 @@ +--- +interface Props { + date: Date; + locales: Intl.LocalesArgument; + options: Intl.DateTimeFormatOptions; +} + +const { date, locales, options } = Astro.props; + +const datetime = date.toISOString(); +const format = new Intl.DateTimeFormat(locales, options).format(date); +--- + +<date {datetime}>{format}</date> diff --git a/src/components/organisms/KeywordsList.astro b/src/components/organisms/KeywordsList.astro new file mode 100644 index 0000000..4d4b140 --- /dev/null +++ b/src/components/organisms/KeywordsList.astro @@ -0,0 +1,31 @@ +--- +interface Props { + keywords: string[]; +} + +const { keywords } = Astro.props; +--- + +<p> + {keywords.map((x) => <span>#<b>{x}</b></span>)} +</p> + +<style> + p { + display: flex; + flex-direction: row-reverse; + flex-wrap: wrap; + gap: calc(var(--size-0) * 1em); + + & > * { + border-radius: calc(infinity * 1px); + background-color: color-mix( + in srgb, + var(--color-active) 10%, + transparent + ); + color: var(--color-active); + padding-inline: calc(var(--size-2) * 1em); + } + } +</style> diff --git a/src/components/signature/Authors.astro b/src/components/signature/Authors.astro index 43a2b36..71a3d62 100644 --- a/src/components/signature/Authors.astro +++ b/src/components/signature/Authors.astro @@ -3,16 +3,14 @@ import { toPK } from "@lib/pgp"; import { createKeyFromArmor } from "@lib/pgp/create"; import type { Verification } from "@lib/pgp/verify"; import { defined, get, instanciate } from "@utils/anonymous"; -import { type CollectionEntry, z } from "astro:content"; +import { z } from "astro:content"; import type { EntityTypesEnum } from "src/consts"; import qrcode from "yaqrcode"; +import type { getSigners } from "@lib/collection/helpers"; interface Props { verifications: NonNullable<Verification["verifications"]>; - expectedSigners: { - entity: CollectionEntry<"entity">; - role: z.infer<typeof EntityTypesEnum>; - }[]; + expectedSigners: Awaited<ReturnType<typeof getSigners>>; commitSignerKey?: string; } diff --git a/src/components/templates/MicroBlog.astro b/src/components/templates/MicroBlog.astro new file mode 100644 index 0000000..b7019c5 --- /dev/null +++ b/src/components/templates/MicroBlog.astro @@ -0,0 +1,114 @@ +--- +import Date from "@components/organisms/Date.astro"; +import KeywordsList from "@components/organisms/KeywordsList.astro"; +import { getFirstUserID, getLastUpdate } from "@lib/collection/helpers"; +import { Micro } from "@lib/collection/schemas"; +import type { CollectionEntry, z } from "astro:content"; + +interface Props extends CollectionEntry<"blog"> { + data: z.infer<typeof Micro>; +} + +const micro = Astro.props; +const { id, data, rendered } = micro; +const { title, lang, keywords } = data; +const date = getLastUpdate(micro); +const user = await getFirstUserID(micro); +const display = user?.name ?? user?.email ?? user?.entity ?? ""; +const [first, ...names] = display.split(/\s/); +const last = names.length > 0 ? names[names.length - 1] : ""; +const little = ((first?.[0] ?? "") + (last?.[0] ?? "")).slice(0, 2); +--- +<article> + <header> + <h3 class="title"> + <a href={`/blog/read/${id}`}>{title}</a> + </h3> + <span class="profile_picture">{ + user?.website ? <a href={user.website}>{little}</a> : ( + <span>{little}</span> + ) + }</span> + <div> + {first} {last} <small>· <Date + {date} + locales={lang} + options={{ month: "short", day: "numeric" }} + /></small> + </div> + </header> + <div class="content"> + <small {lang}> + <Fragment set:html={rendered?.html} /> + </small> + <footer> + <div class="keywords small"><KeywordsList {keywords} /></div> + </footer> + </div> + <aside> + <small><a href="/blog/micro/1">Ver todos os microposts</a></small> + </aside> +</article> + +<style is:inline> + .content > [lang] > *:first-child { + margin-block-start: 0; + } + .content > [lang] > *:last-child { + margin-block-end: 0; + } +</style> +<style> + article { + border-radius: calc(var(--size-1) * 1em); + box-shadow: 0 0 calc(var(--size-1) * 1em) var(--color-light); + padding: calc(var(--size-4) * 1em); + display: grid; + grid-template-rows: repeat(3, auto); + grid-template-columns: calc(var(--size-9) * 1em) auto; + gap: calc(var(--size-1) * 1em); + + & > header { + display: contents; + } + + & > aside { + grid-row: 3 / 4; + grid-column: 1 / 3; + border-block-start: 1px solid #e7e7e7; + padding-block-start: calc(var(--size-1) * 1em); + } + } + + .profile_picture { + grid-row: 1 / 3; + grid-column: 1 / 2; + + & > * { + display: inline-grid; + place-content: center; + width: calc(var(--size-9) * 1em); + height: calc(var(--size-9) * 1em); + aspect-ratio: 1 / 1; + background-color: var(--color-active); + color: #fff; + font-weight: 950; + font-size: smaller; + border-radius: calc(infinity * 1px); + text-align: center; + text-transform: uppercase; + } + } + + .title { + display: none; + } + .content { + grid-row: 2/3; + grid-column: 2 / 3; + } + + .keywords { + margin-block-end: 0; + } +</style> diff --git a/src/components/templates/Search.astro b/src/components/templates/Search.astro new file mode 100644 index 0000000..5245643 --- /dev/null +++ b/src/components/templates/Search.astro @@ -0,0 +1,67 @@ +--- +const { site } = Astro; +--- + +<search> + <link rel="dns-prefetch" href="https://www.google.com/search"> + <form + action="https://www.google.com/search" + target="_blank" + rel="external noreferrer search" + role="search" + autocomplete="on" + name="search" + > + <details> + <summary>Pesquisar no website</summary> + <div class="details"> + <p> + <label>Barra de pesquisa <input + name="q" + type="search" + placeholder={`site:${site} consulta de pesquisa`} + value={`site:${site} `} + required + title={`"site:${site} " é usado para que os resultados da pesquisa fiquem restritos a este website`} + pattern={`site:${site} .+`} + size={`site:${site} .+`.length} + /></label> + </p> + <p class="mute"> + <small>Esta pesquisa é efectuada pelo Google e <strong>utiliza + software proprietário.</strong></small> + </p> + <p><button type="submit">🔍 Pesquisar</button></p> + </div> + </details> + </form> +</search> + +<style> + search { + padding-block-end: calc(var(--size-4) * 1em); + } + + summary { + font-size: calc(var(--size-3) * 1rem); + font-weight: bolder; + } + + .details { + border-radius: calc(var(--size-1) * 1em); + border: 1px solid var(--color-light); + margin-block-start: calc(var(--size-1) * 1em); + font-size: calc(var(--size-3) * 1rem); + padding-inline: calc(var(--size-4) * 1em); + padding-block: calc(var(--size-2) * 1em); + + & > p { + margin-block: calc(var(--size-2) * 1em); + line-height: calc(var(--size-8) * 1rem); + } + + & input[type="search"] { + width: 100%; + } + } +</style> diff --git a/src/components/templates/SimplePostList.astro b/src/components/templates/SimplePostList.astro new file mode 100644 index 0000000..0ec33e3 --- /dev/null +++ b/src/components/templates/SimplePostList.astro @@ -0,0 +1,81 @@ +--- +import Date from "@components/organisms/Date.astro"; +import KeywordsList from "@components/organisms/KeywordsList.astro"; +import { getFirstUserID, getLastUpdate } from "@lib/collection/helpers"; +import type { Original } from "@lib/collection/schemas"; +import type { z } from "astro:content"; +import type { CollectionEntry } from "astro:content"; + +interface Props { + posts: (CollectionEntry<"blog"> & { data: z.infer<typeof Original> })[]; +} + +const { posts } = Astro.props; +--- +<ol> + { + await Promise.all(posts.map(async (post) => { + const { id, data } = post; + const { title, description, lang, keywords } = data; + const { name, email, entity } = await getFirstUserID(post); + const display = name ?? email ?? entity; + return ( + <li> + <article> + <h3><a href={`/blog/read/${id}`}>{title}</a></h3> + { + description && + description.split("\n\n").map((paragraph) => ( + <p class="small">{paragraph}</p> + )) + } + + <footer class="small"> + <Date + date={getLastUpdate(post)} + locales={lang} + options={{ + year: "numeric", + month: "long", + day: "numeric", + }} + />{display} + <KeywordsList {keywords} /> + </footer> + </article> + </li> + ); + })) + } +</ol> + +<style> + ol { + margin-inline-start: calc(var(--size-7) * 1em); + margin-block: calc(var(--size-7) * 1em); + & > li { + margin-block-start: calc(var(--size-2) * 1em); + & > article { + padding-inline-end: calc(var(--size-9) * 1em); + + & > p:not(:first-of-type) { + margin-block-start: 1.5em; + } + + & > footer { + display: flex; + flex-direction: column; + gap: calc(var(--size-1) * 1em); + } + } + } + } + + @media (width >= 40rem) { + ol > li > article > footer { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } +</style> diff --git a/src/consts.ts b/src/consts.ts index ee6c580..e69de29 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1,29 +0,0 @@ -import { z } from "astro/zod"; - -export const SITE_TITLE = "Cravo de Abril"; -export const SITE_DESCRIPTION = "Um domínio da liberdade!"; -export const SITE_AUTHOR = "João Augusto Costa Branco Marado Torres"; - -export const KEYWORDS = ["Portugal", "democracy"] as const; -export const KeywordsEnum = z.enum(KEYWORDS); - -export const ENTITY_TYPES = ["author", "co-author", "translator"] as const; -export const EntityTypesEnum = z.enum(ENTITY_TYPES); - -export const CREATIVE_COMMONS_LICENSES = [ - "CC0", - "CC-BY", - "CC-BY-SA", - "CC-BY-ND", - "CC-BY-NC", - "CC-BY-NC-SA", - "CC-BY-NC-ND", -] as const; -export const LICENSES = [ - ...CREATIVE_COMMONS_LICENSES, - "WTFPL", - "public domain", -] as const; -export const LicensesEnum = z.enum(LICENSES); - -export const TRUSTED_KEYS_DIR = new URL(`file://${Deno.cwd()}/public/keys/`); diff --git a/src/content.config.ts b/src/content.config.ts index f652cc3..821faf5 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,114 +1,17 @@ import { file, glob } from "astro/loaders"; -import { defineCollection, reference, z } from "astro:content"; +import { defineCollection, type z } from "astro:content"; //import { parse } from "@std/toml"; import { parse } from "toml"; -import { EntityTypesEnum, KeywordsEnum, LicensesEnum } from "./consts.ts"; -import { get, instanciate } from "./utils/anonymous.ts"; -import { isValidLocale } from "./utils/lang.ts"; - -const Blog = z.object({ - title: z.string().trim(), - subtitle: z.string().trim().optional(), - description: z.string().trim().optional(), - keywords: z.array(KeywordsEnum).optional().refine( - (keywords) => new Set(keywords).size === (keywords?.length ?? 0), - { - message: "Keywords must be unique", - }, - ).transform((keywords) => - keywords !== undefined ? new Set(keywords).values().toArray() : undefined - ), - dateCreated: z.coerce.date(), - dateUpdated: z.coerce.date().optional(), - locationCreated: z.string().trim().optional(), - relatedPosts: z.array(reference("blog")).default([]).refine( - (posts) => new Set(posts).size === (posts?.length ?? 0), - { - message: "Related posts referenced multiple times", - }, - ).transform((x) => new Set(x)).transform((set) => set.values().toArray()), - lang: z.string().trim().refine(isValidLocale), - translationOf: reference("blog").optional(), - signers: z.array( - z.object({ entity: reference("entity"), role: EntityTypesEnum }), - ).optional().refine( - (signers) => { - if (signers === undefined) return true; - return signers.filter((s) => s.role === "author").length <= 1; - }, - { - message: "There can only be one author", - }, - ).refine( - (signers) => { - const ids = signers?.map(get("entity")) ?? []; - return new Set(ids).size === ids.length; - }, - { - message: "Reusing signers", - }, - //).transform((signers) => - // Object.fromEntries(new Map(signers?.map(({ entity, ...rest }) => [entity, rest]) ?? [])) - ), - license: LicensesEnum, -}).refine( - ({ dateCreated, dateUpdated }) => - dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), - { message: "Update before creation" }, -).refine( - ({ translationOf, keywords }) => - translationOf !== undefined || (keywords?.length ?? 0) > 0, - { - message: "Originals must include at least one keyword", - path: ["keywords"], - }, -).refine( - ({ translationOf, keywords }) => - (translationOf === undefined) !== ((keywords?.length ?? 0) <= 0), - { - message: "we will use this information from the original, " + - "so no need to specify it for translations", - path: ["keywords"], - }, -).refine( - ({ translationOf, relatedPosts }) => - (translationOf === undefined) || (relatedPosts.length <= 0), - { - message: "we will use this information from the original, " + - "so no need to specify it for translations", - path: ["relatedPosts"], - }, -).refine( - ({ translationOf, signers = [] }) => - (translationOf === undefined) || - (signers.values().every(({ role }) => role !== "translator")), - { - message: "There can't be translator signers on non translated work", - path: ["signers"], - }, -); +import { Blog, Entity } from "./lib/collection/schemas.ts"; const blog = defineCollection({ loader: glob({ base: "./public/blog", pattern: "+([0-9a-z-]).md" }), schema: Blog, }); -export type Blog = z.infer<typeof Blog>; - -const Entity = z.object({ - websites: z.array(z.string().url().trim()).default([]).transform((websites) => - websites.map(instanciate(URL)) - ), - publickey: z.object({ - armor: z.string().trim(), - }), -}); - -type Entity = z.infer<typeof Entity>; - const entity = defineCollection({ loader: file("./src/content/entities.toml", { - parser: (text) => parse(text).entities as Entity[], + parser: (text) => parse(text).entities as z.infer<typeof Entity>[], }), schema: Entity, }); diff --git a/src/custom-attributes.d.ts b/src/custom-attributes.d.ts new file mode 100644 index 0000000..a9383ea --- /dev/null +++ b/src/custom-attributes.d.ts @@ -0,0 +1,8 @@ +declare namespace astroHTML.JSX { + // interface HTMLAttributes<"form"> { + // "rel"?: string; + // } + interface FormHTMLAttributes { + "rel"?: string; + } +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index d80d6a8..afee012 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -1,12 +1,12 @@ --- -import BaseHead, { - type Props as HeadProps, -} from "@components/BaseHead.astro"; -import { type Props as HeaderProps } from "@components/Header.astro"; +import BaseHead from "@components/BaseHead.astro"; import Footer from "@components/Footer.astro"; import Header from "@components/Header.astro"; +import type { ComponentProps } from "astro/types"; -interface Props extends HeadProps, HeaderProps {} +interface Props extends ComponentProps<typeof BaseHead> { + children: any; +} --- <!DOCTYPE html> @@ -32,7 +32,7 @@ interface Props extends HeadProps, HeaderProps {} <BaseHead {...Astro.props} /> </head> <body> - <Header {...Astro.props} /> + <Header /> <slot /> <Footer /> <noscript>I see, a man of culture :)</noscript> diff --git a/src/lib/collection/helpers.ts b/src/lib/collection/helpers.ts new file mode 100644 index 0000000..83eb21d --- /dev/null +++ b/src/lib/collection/helpers.ts @@ -0,0 +1,107 @@ +import type { CollectionEntry } from "astro:content"; +import { + Blog, + Entity, + type Entry, + type MicroEntry, + type OriginalEntry, + type TranslationEntry, +} from "./schemas.ts"; +import { getEntries, type z } from "astro:content"; +import { defined, get, identity } from "../../utils/anonymous.ts"; +import { createKeyFromArmor } from "../pgp/create.ts"; +import { getUserIDsFromKey } from "../pgp/user.ts"; +import type { UserIDPacket } from "openpgp"; +import { getCollection } from "astro:content"; + +export function getLastUpdate({ data }: CollectionEntry<"blog">): Date { + return data.dateUpdated ?? data.dateCreated; +} +export const sortLastCreated = ( + { data: a }: CollectionEntry<"blog">, + { data: b }: CollectionEntry<"blog">, +): number => b.dateCreated - a.dateCreated; +export const sortFirstCreated = ( + a: CollectionEntry<"blog">, + b: CollectionEntry<"blog">, +): number => sortLastCreated(b, a); +export const sortLastUpdated = ( + { data: a }: CollectionEntry<"blog">, + { data: b }: CollectionEntry<"blog">, +): number => + (b.dateUpdated ?? b.dateCreated) - (a.dateUpdated ?? a.dateCreated); +export const sortFirstUpdated = ( + a: CollectionEntry<"blog">, + b: CollectionEntry<"blog">, +): number => sortLastUpdated(b, a); + +export async function getSigners( + { data }: CollectionEntry<"blog">, +): Promise<{ + id: string; + entity: CollectionEntry<"entity">; + role: z.infer<typeof Blog>["signers"][number]["role"] | undefined; +}[]> { + const post = Blog.parse(data); + return await getEntries(post.signers.map(get("entity"))).then((x) => + x.map((x) => ({ + id: x.id, + entity: x, + role: post.signers?.find((y) => y.entity.id === x.id)?.role, + })).filter(({ role }) => defined(role)) + ); +} + +export async function getFirstAuthorEmail( + blog: CollectionEntry<"blog">, +): Promise<string | undefined> { + const signers = await getSigners(blog); + const emails = await Promise.all( + signers.filter(({ role }) => role === "author").map(async ({ entity }) => { + const { publickey } = Entity.parse(entity.data); + const key = await createKeyFromArmor(publickey.armor); + const users = getUserIDsFromKey(undefined, key); + return users.map(get("email")).filter(Boolean)?.[0]; + }), + ); + return emails.filter(defined)?.[0]; +} + +export async function getFirstUserID( + blog: CollectionEntry<"blog">, +): Promise< + (Partial<UserIDPacket> & { entity: string; website: string | undefined }) +> { + const signers = await getSigners(blog); + const userIDs = await Promise.all( + signers.filter(({ role }) => role === "author").map( + async ({ id, entity }) => { + const { publickey, websites } = Entity.parse(entity.data); + const website = websites?.[0]; + const key = await createKeyFromArmor(publickey.armor); + const users = getUserIDsFromKey(undefined, key); + return users.map((user) => { + return { ...user, entity: id, website }; + })?.[0]; + }, + ), + ); + return userIDs.filter(defined)?.[0]; +} + +export async function fromPosts<T extends Entry, U>( + filter: (entry: CollectionEntry<"blog">) => entry is T, + predicate: (entries: T[]) => U = identity as (entries: T[]) => U, +): Promise<U> { + const entries = await getCollection<"blog", T>("blog", filter); + return predicate(entries); +} +export const isOriginal = ( + entry: CollectionEntry<"blog">, +): entry is OriginalEntry => entry.data.kind === "original"; +export const isTranslation = ( + entry: CollectionEntry<"blog">, +): entry is TranslationEntry => entry.data.kind === "translation"; +export const isMicro = ( + entry: CollectionEntry<"blog">, +): entry is MicroEntry => entry.data.kind === "micro"; diff --git a/src/lib/collection/schemas.ts b/src/lib/collection/schemas.ts new file mode 100644 index 0000000..eca996f --- /dev/null +++ b/src/lib/collection/schemas.ts @@ -0,0 +1,133 @@ +import { reference, z } from "astro:content"; +import { isValidLocale } from "../../utils/lang.ts"; +import { get } from "../../utils/anonymous.ts"; +import type { CollectionEntry } from "astro:content"; + +export const KEYWORDS = ["Portugal", "democracy", "test"] as const; +export const KeywordsEnum = z.enum(KEYWORDS); + +export const ENTITY_TYPES = ["author", "co-author", "translator"] as const; +export const EntityTypesEnum = z.enum(ENTITY_TYPES); + +export const CREATIVE_COMMONS_LICENSES = [ + "CC0", + "CC-BY", + "CC-BY-SA", + "CC-BY-ND", + "CC-BY-NC", + "CC-BY-NC-SA", + "CC-BY-NC-ND", +] as const; +export const LICENSES = [ + ...CREATIVE_COMMONS_LICENSES, + "WTFPL", + "public domain", +] as const; +export const LicensesEnum = z.enum(LICENSES); + +export const Original = z.object({ + kind: z.literal("original"), + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + keywords: z.array(KeywordsEnum).refine( + (keywords) => new Set(keywords).size === keywords.length, + { message: "Keywords must be unique" }, + ), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + locationCreated: z.string().trim().optional(), + relatedPosts: z.array(reference("blog")).default([]).refine( + (posts) => new Set(posts).size === posts.length, + { message: "Related posts referenced multiple times" }, + ), + lang: z.string().trim().refine(isValidLocale), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => signers.filter((s) => s.role === "author").length <= 1, + { message: "There can only be one author" }, + ).refine((signers) => { + const ids = signers.map(get("entity")); + return new Set(ids).size === ids.length; + }, { message: "Reusing signers" }).refine( + (signers) => signers.every(({ role }) => role !== "translator"), + { message: "There can't be translator signers on non translated work" }, + ), + license: LicensesEnum.default("public domain"), +}); + +export const Translation = z.object({ + kind: z.literal("translation"), + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + lang: z.string().trim().refine(isValidLocale), + translationOf: reference("blog"), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => signers.filter((s) => s.role === "author").length <= 1, + { message: "There can only be one author" }, + ).refine((signers) => { + const ids = signers.map(get("entity")); + return new Set(ids).size === ids.length; + }, { message: "Reusing signers" }), + license: LicensesEnum.default("public domain"), +}); + +export const Micro = z.object({ + kind: z.literal("micro"), + title: z.string().trim(), + keywords: z.array(KeywordsEnum).refine( + (keywords) => new Set(keywords).size === keywords.length, + { message: "Keywords must be unique" }, + ), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + locationCreated: z.string().trim().optional(), + lang: z.string().trim().refine(isValidLocale), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).default([]).refine( + (signers) => signers.filter((s) => s.role === "author").length <= 1, + { message: "There can only be one author" }, + ).refine((signers) => { + const ids = signers.map(get("entity")); + return new Set(ids).size === ids.length; + }, { message: "Reusing signers" }).refine( + (signers) => signers.every(({ role }) => role !== "translator"), + { message: "There can't be translator signers on non translated work" }, + ), + license: LicensesEnum.default("public domain"), +}); + +export const Blog = z.discriminatedUnion("kind", [ + Original, + Translation, + Micro, +]).refine( + ({ dateCreated, dateUpdated }) => + dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), + { message: "Update before creation" }, +); + +export type OriginalEntry = CollectionEntry<"blog"> & { + data: z.infer<typeof Original>; +}; +export type TranslationEntry = CollectionEntry<"blog"> & { + data: z.infer<typeof Translation>; +}; +export type MicroEntry = CollectionEntry<"blog"> & { + data: z.infer<typeof Micro>; +}; +export type Entry = OriginalEntry | TranslationEntry | MicroEntry; + +export const Entity = z.object({ + websites: z.array(z.string().url().trim()).default([]).transform((websites) => + websites.map((x) => new URL(x).href) + ), + publickey: z.object({ armor: z.string().trim() }), +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..679c76f --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,68 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "astro:content"; + +export const env = createEnv({ + server: { + TRUSTED_KEYS_DIR: z.string().superRefine((val, ctx) => { + let url: URL; + const cwd = new URL(`file://${Deno.cwd()}/`); + try { + url = new URL(val, cwd); + } catch { + ctx.addIssue({ + code: "custom", + message: `${cwd}${val} doesn't exist`, + fatal: true, + }); + return; + } + + const { isDirectory } = Deno.statSync(url); + + if (isDirectory) return; + + ctx.addIssue({ + code: "custom", + message: `${url} it's not a directory`, + fatal: true, + }); + }).transform((val) => new URL(val, new URL(`file://${Deno.cwd()}/`))), + }, + + /** + * The prefix that client-side variables must have. This is enforced both at + * a type-level and at runtime. + */ + clientPrefix: "PUBLIC_", + client: { + PUBLIC_SITE_URL: z.string().url(), + PUBLIC_SITE_TITLE: z.string().trim().min(1), + PUBLIC_SITE_DESCRIPTION: z.string().trim().min(1), + PUBLIC_SITE_AUTHOR: z.string().trim().min(1), + PUBLIC_GIT_URL: z.string().url(), + PUBLIC_TOR_URL: z.string().url().optional(), + PUBLIC_GIT_TOR_URL: z.string().url().optional(), + PUBLIC_SIMPLE_X_ADDRESS: z.string().url().optional(), + }, + + /** + * What object holds the environment variables at runtime. This is usually + * `process.env` or `import.meta.env`. + */ + runtimeEnv: import.meta.env ?? Deno.env.toObject(), + + /** + * By default, this library will feed the environment variables directly to + * the Zod validator. + * + * This means that if you have an empty string for a value that is supposed + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag + * it as a type mismatch violation. Additionally, if you have an empty string + * for a value that is supposed to be a string with a default value (e.g. + * `DOMAIN=` in an ".env" file), the default value will never be applied. + * + * In order to solve these issues, we recommend that all new projects + * explicitly specify this option as true. + */ + emptyStringAsUndefined: true, +}); diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts index 86bbe7b..bcf6888 100644 --- a/src/lib/git/log.ts +++ b/src/lib/git/log.ts @@ -29,6 +29,7 @@ export async function getLastCommitForOneOfFiles( "-1", `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, "--", + // deno-lint-ignore no-undef ...Iterator.from(files).map((x) => x.pathname), ], }); @@ -59,6 +60,7 @@ export async function getLastCommitForOneOfFiles( const raw = rawLines.join("\n").trim(); const commit: Commit = { + // deno-lint-ignore no-undef files: await fileStatusFromCommit(hash, Iterator.from(files)), hash: { long: hash, short: abbrHash }, author: { @@ -112,6 +114,7 @@ async function fileStatusFromCommit( return result.map((line) => { const [status, path] = line.split("\t"); if ( + // deno-lint-ignore no-undef Iterator.from(files).some((file) => file.pathname.replace(dir.pathname, "").includes(path) ) diff --git a/src/lib/pgp/sign.ts b/src/lib/pgp/sign.ts index 5f7f5a8..6d1e78c 100644 --- a/src/lib/pgp/sign.ts +++ b/src/lib/pgp/sign.ts @@ -26,6 +26,7 @@ export class Signature { getPackets(key?: MaybeIterable<KeyID>): Packet[] { key ??= this.signingKeyIDs; + // deno-lint-ignore no-undef const iterator = Iterator.from(surelyIterable(key)); return iterator.map((key) => this.#packets.get(key.bytes)).filter(defined) .flatMap(identity).toArray(); diff --git a/src/lib/pgp/trust.ts b/src/lib/pgp/trust.ts index cf022b4..34d454b 100644 --- a/src/lib/pgp/trust.ts +++ b/src/lib/pgp/trust.ts @@ -1,19 +1,20 @@ import type { Key } from "npm:openpgp@^6.1.1"; -import { TRUSTED_KEYS_DIR } from "../../consts.ts"; import { createKeysFromDir } from "./create.ts"; import type { AsyncYieldType } from "../../utils/iterator.ts"; import { equal, getCall } from "../../utils/anonymous.ts"; +import { env } from "../env.ts"; let trusted: | Iterable<AsyncYieldType<ReturnType<typeof createKeysFromDir>>> | undefined = undefined; const fingerprints = () => + // deno-lint-ignore no-undef Iterator.from(trusted ?? []).map(getCall("getFingerprint")); export async function keyTrust(key: Key): Promise<number> { if (trusted === undefined) { - trusted = await Array.fromAsync(createKeysFromDir(TRUSTED_KEYS_DIR)); + trusted = await Array.fromAsync(createKeysFromDir(env.TRUSTED_KEYS_DIR)); } return fingerprints().some(equal(key.getFingerprint())) ? 255 : 0; } diff --git a/src/lib/pgp/user.ts b/src/lib/pgp/user.ts new file mode 100644 index 0000000..334fbde --- /dev/null +++ b/src/lib/pgp/user.ts @@ -0,0 +1,33 @@ +import { PublicKey, type Subkey, UserIDPacket } from "openpgp"; +import type { Signature } from "./sign.ts"; +import { defined, get } from "../../utils/anonymous.ts"; + +export function getUserIDsFromKey( + signature: Signature | undefined, + key: PublicKey | Subkey, +): UserIDPacket[] { + const packet = signature?.getPackets?.()?.[0]; + const userID = packet?.signersUserID; + + if (userID) { + return [UserIDPacket.fromObject(parseUserID(userID))]; + } + + key = key instanceof PublicKey ? key : key.mainKey; + return key.users.map(get("userID")).filter(defined); +} + +function parseUserID(input: string) { + const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/; + const match = input.match(regex); + + if (!match) return {}; + + const [, name, comment, email] = match; + + return { + name: name?.trim() || undefined, + comment: comment?.trim() || undefined, + email: email?.trim() || undefined, + }; +} diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts index da2de7f..f37c0bb 100644 --- a/src/lib/pgp/verify.ts +++ b/src/lib/pgp/verify.ts @@ -3,7 +3,7 @@ import { PublicKey, readSignature, type Subkey, - UserIDPacket, + type UserIDPacket, verify, } from "openpgp"; import { @@ -18,11 +18,12 @@ import { type KeyFileFormat, } from "./create.ts"; import { getLastCommitForOneOfFiles } from "../git/log.ts"; -import { defined, get, instanciate } from "../../utils/anonymous.ts"; +import { get, instanciate } from "../../utils/anonymous.ts"; import { Packet, Signature } from "./sign.ts"; import type { Commit } from "../git/types.ts"; -import { TRUSTED_KEYS_DIR } from "../../consts.ts"; import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts"; +import { getUserIDsFromKey } from "./user.ts"; +import { env } from "../env.ts"; type DataURL = [URL, URL?]; type Corrupted = [false] | [true, Error]; @@ -251,7 +252,7 @@ export class SignatureVerifier { public static async instance(): Promise<SignatureVerifier> { if (!SignatureVerifier.#instance) { SignatureVerifier.#instance = new SignatureVerifier(); - await SignatureVerifier.#instance.addKeysFromDir(TRUSTED_KEYS_DIR); + await SignatureVerifier.#instance.addKeysFromDir(env.TRUSTED_KEYS_DIR); } return SignatureVerifier.#instance; @@ -270,36 +271,6 @@ export class SignatureVerifier { export const verifier = SignatureVerifier.instance(); -function getUserIDsFromKey( - signature: Signature, - key: PublicKey | Subkey, -): UserIDPacket[] { - const packet = signature.getPackets()[0]; - const userID = packet.signersUserID; - - if (userID) { - return [UserIDPacket.fromObject(parseUserID(userID))]; - } - - key = key instanceof PublicKey ? key : key.mainKey; - return key.users.map(get("userID")).filter(defined); -} - -function parseUserID(input: string) { - const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/; - const match = input.match(regex); - - if (!match) return {}; - - const [, name, comment, email] = match; - - return { - name: name?.trim() || undefined, - comment: comment?.trim() || undefined, - email: email?.trim() || undefined, - }; -} - async function isSignatureCorrupted( verified: Awaited< ReturnType<typeof verify> diff --git a/src/pages/blog/[...year].astro b/src/pages/blog/[...year].astro index f148a76..1742baa 100644 --- a/src/pages/blog/[...year].astro +++ b/src/pages/blog/[...year].astro @@ -1,20 +1,16 @@ --- +import type { + GetStaticPaths, + InferGetStaticParamsType, + InferGetStaticPropsType, +} from "astro"; import { getCollection } from "astro:content"; -import type { CollectionEntry } from "astro:content"; import Base from "@layouts/Base.astro"; import DateSelector from "@components/DateSelector.astro"; import BlogCard from "@components/BlogCard.astro"; +import { sortLastCreated } from "@lib/collection/helpers"; -type Props = { - posts: CollectionEntry<"blog">[]; - next: string; - previous: string; - years: number[]; - months: number[]; - days?: number[]; -}; - -export async function getStaticPaths() { +export const getStaticPaths = (async () => { const posts = await getCollection("blog"); const archive = { @@ -128,7 +124,7 @@ export async function getStaticPaths() { paths.push({ params: { year: ymd }, props: { - posts: archive.postsByDate.get(ymd), + posts: archive.postsByDate.get(ymd) ?? [], next: archive.sortedDates?.[i + 1], previous: archive.sortedDates?.[i - 1], years: sortedYears, @@ -139,16 +135,16 @@ export async function getStaticPaths() { } return paths; -} +}) satisfies GetStaticPaths; + +export type Params = InferGetStaticParamsType<typeof getStaticPaths>; +export type Props = InferGetStaticPropsType<typeof getStaticPaths>; const title = "Blog"; const description = "Latest articles."; let { posts, previous, next, years, months, days } = Astro.props; -posts = posts.sort((a, b) => - new Date(b.data.dateCreated).valueOf() - - new Date(a.data.dateCreated).valueOf() -); +posts = posts.sort(sortLastCreated); const date = posts[0].data.dateCreated as Date; --- diff --git a/src/pages/blog/micro/[page].astro b/src/pages/blog/micro/[page].astro new file mode 100644 index 0000000..9fb04f1 --- /dev/null +++ b/src/pages/blog/micro/[page].astro @@ -0,0 +1,32 @@ +--- +import MicroBlog from "@components/templates/MicroBlog.astro"; +import Base from "@layouts/Base.astro"; +import { fromPosts, isMicro } from "@lib/collection/helpers"; +import { identity } from "@utils/anonymous"; +import type { + GetStaticPaths, + InferGetStaticParamsType, + InferGetStaticPropsType, +} from "astro"; + +export const getStaticPaths = (async ({ paginate }) => { + const micros = await fromPosts(isMicro, identity); + + return paginate(micros, { pageSize: 20 }); +}) satisfies GetStaticPaths; + +export type Params = InferGetStaticParamsType<typeof getStaticPaths>; +export type Props = InferGetStaticPropsType<typeof getStaticPaths>; + +const { page } = Astro.props; +--- +<Base title="Micro Blogue"> + <h1>Page {page.currentPage}</h1> + <ul> + {page.data.map((micro) => <li><MicroBlog {...micro} /></li>)} + </ul> + {page.url.first ? <a href={page.url.first}>First</a> : null} + {page.url.prev ? <a href={page.url.prev}>Previous</a> : null} + {page.url.next ? <a href={page.url.next}>Next</a> : null} + {page.url.last ? <a href={page.url.last}>Last</a> : null} +</Base> diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro index 05d68e8..348a976 100644 --- a/src/pages/blog/read/[...slug].astro +++ b/src/pages/blog/read/[...slug].astro @@ -9,9 +9,9 @@ import Keywords from "@components/Keywords.astro"; import Citations from "@components/Citations.astro"; import Signature from "@components/signature/Signature.astro"; import CopyrightNotice from "@components/CopyrightNotice.astro"; -import { getEntries } from "astro:content"; import { verifier as verifierPrototype } from "@lib/pgp/verify"; -import { defined, get } from "@utils/anonymous"; +import { getSigners } from "@lib/collection/helpers"; +import { get } from "@utils/anonymous"; import Authors from "@components/signature/Authors.astro"; import { getEntry } from "astro:content"; @@ -27,8 +27,9 @@ type Props = CollectionEntry<"blog">; const post = Astro.props; -if (defined(post.data.translationOf)) { - const original = await getEntry( +let original: CollectionEntry<"blog">; +if (post.data.kind === "translation") { + original = await getEntry( post.data.translationOf as CollectionEntry<"blog">, ); @@ -40,15 +41,15 @@ if (defined(post.data.translationOf)) { (s) => s.role === "author", ).map((s) => s.entity.id)?.[0]; const originalCoAuthors = new Set( - (original.data.signer ?? []).filter( + (original.data.signers ?? []).filter( (s) => s.role === "co-author", ).map((s) => s.entity.id), ); - const translationAuthor = (post.data.signer ?? []).filter( + const translationAuthor = (post.data.signers ?? []).filter( (s) => s.role === "author", ).map((s) => s.entity.id)?.[0]; const translationCoAuthors = new Set( - (post.data.signer ?? []).filter( + (post.data.signers ?? []).filter( (s) => s.role === "co-author", ).map((s) => s.entity.id), ); @@ -63,7 +64,7 @@ if (defined(post.data.translationOf)) { ); } - const translators = (post.data.signer ?? []).filter( + const translators = (post.data.signers ?? []).filter( (s) => s.role === "translator", ).map((s) => s.entity.id); @@ -77,7 +78,8 @@ if (defined(post.data.translationOf)) { } } } else { - if (post.data.signer?.some((x) => x.role === "translator")) { + original = post; + if (post.data.signers?.some((x) => x.role === "translator")) { throw new Error( `Post ${post.id} is not a translation but has translators defined`, ); @@ -89,8 +91,8 @@ const translationsSet = new Set( (await getCollection( "blog", (x) => - x.data.translationOf?.id === - (post.data.translationOf !== undefined + (x.data.kind === "translation") && x.data.translationOf.id === + (post.data.kind === "translation" ? post.data.translationOf.id : post.id), ) ?? []).map(({ id }) => id), @@ -102,16 +104,7 @@ const translations = [...translationsSet.values()].map((id) => ({ id, })); -const signers = await getEntries( - post.data.signer?.map(get("entity")) ?? [], -).then((x) => x.filter(defined)) - .then((x) => - x.map((x) => ({ - entity: x, - role: post.data.signer?.find((y) => y.entity.id === x.id)?.role, - })) - ) - .then((x) => x.filter((x) => x.role !== undefined)); +const signers = await getSigners(post); const verifier = await verifierPrototype.then((x) => x.clone()); @@ -137,7 +130,12 @@ const commit = await verification?.commit; <html lang="pt-PT"> <head> - <BaseHead title={post.data.title} description={post.data.description} /> + <BaseHead + title={post.data.title} + description={"description" in post.data + ? post.data.description + : post.data.title} + /> </head> <body> @@ -151,7 +149,7 @@ const commit = await verification?.commit; <hgroup> <h1 itemprop="headline">{post.data.title}</h1> { - post.data.subtitle && ( + "subtitle" in post.data && ( <p itemprop="alternativeHeadline" class="subtitle"> {post.data.subtitle} </p> @@ -159,7 +157,8 @@ const commit = await verification?.commit; } </hgroup> { - post.data.description && ( + "description" in post.data && post.data.description && + ( <section itemprop="abstract"> <h2>Resumo</h2> { @@ -181,7 +180,7 @@ const commit = await verification?.commit; <Authors verifications={verification.verifications} expectedSigners={signers} - commitSignerKey={commit?.signature?.keyFingerPrint} + commitSignerKey={commit?.signature?.signer} /> ) } @@ -201,7 +200,7 @@ const commit = await verification?.commit; post.data.dateUpdated && ( <dt>Última atualização</dt><dd> <time - itemprop="dateUpdated" + itemprop="dateModified" datetime={toIso8601Full(post.data.dateUpdated)} >{ new Intl.DateTimeFormat([lang], {}).format( @@ -212,7 +211,8 @@ const commit = await verification?.commit; ) } { - post.data.locationCreated && ( + "locationCreated" in post.data && + post.data.locationCreated && ( <dt itemprop="locationCreated" itemscope @@ -230,15 +230,23 @@ const commit = await verification?.commit; <hr /> <div itemprop="articleBody text"><Content /></div> <hr /> - <Keywords keywords={post.data.keywords} /> - <Citations citations={post.data.relatedPosts} /> + { + "keywords" in original.data && ( + <Keywords keywords={original.data.keywords} /> + ) + } + { + "relatedPosts" in original.data && ( + <Citations citations={original.data.relatedPosts} /> + ) + } <CopyrightNotice - author={signers[0]?.entity.data.website?.[0] ?? "Anonymous"} - website={signers[0]?.entity.data.website?.[0]} - email={signers[0]?.entity.data.website?.[0]} + author={signers[0]?.entity.data.websites?.[0] ?? "Anonymous"} + website={signers[0]?.entity.data.websites?.[0]} + email={signers[0]?.entity.data.websites?.[0]} title={post.data.title} dateCreated={post.data.dateCreated} - license={post.data.license as License} + license={post.data.license} /> </article> </main> diff --git a/src/pages/index.astro b/src/pages/index.astro index eea5205..7e506bd 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,28 +1,112 @@ --- +import MicroBlog from "@components/templates/MicroBlog.astro"; +import SimplePostList from "@components/templates/SimplePostList.astro"; import Base from "@layouts/Base.astro"; -import { SITE_TITLE } from "src/consts"; +import { + fromPosts, + isMicro, + isOriginal, + sortLastUpdated, +} from "@lib/collection/helpers"; +import { env } from "@lib/env"; + +const { PUBLIC_SITE_TITLE } = env; + +const originals = await fromPosts( + isOriginal, + (originals) => originals.sort(sortLastUpdated).slice(0, 10), +); +const micro = await fromPosts( + isMicro, + (originals) => originals.sort(sortLastUpdated)?.[0], +); --- -<Base title={SITE_TITLE} showSearch={true} showNav={true}> +<Base title={PUBLIC_SITE_TITLE}> <main> <article> <h2>Viva abril!</h2> <figure> <blockquote lang="es-VE" translate="no"> - «Los que le cierran el camino a la revolución - pacífica le abren al mismo tiempo el camino a la - revolución violenta». + <i>«Los que le cierran el camino a la revolución pacífica le abren al + mismo tiempo el camino a la revolución violenta.»</i> </blockquote> <figcaption> - — Hugo Chávez. + — Hugo Chávez. <p> - Tradução: “Aqueles que fecham o caminho para a - revolução pacífica abrem, ao mesmo tempo, o - caminho para a revolução violenta.” + <small>Tradução: “Aqueles que fecham o caminho para a + revolução pacífica abrem, ao mesmo tempo, o caminho para a + revolução violenta”.</small> </p> </figcaption> </figure> - <p><em>Portugal <em>fez</em> diferente!</em></p> + <p class="lead"><em>Portugal <em>fez</em> diferente!</em></p> </article> + { + (originals.length > 0 || micro) && ( + <section id="posts"> + <h2>Últimas aplicações atualizadas</h2> + {micro && <div id="last-micro"><MicroBlog {...micro} /></div>} + <div id="last-originals"><SimplePostList posts={originals} /></div> + </section> + ) + } </main> </Base> + +<style> + figure:has(blockquote) { + border-inline-start: 2px solid var(--color-active); + padding-inline-start: calc(var(--size-7) * 1em); + + & > blockquote { + margin-block-start: calc(var(--size-7) * 1em); + margin-inline-start: 0; + border-inline-start: 2px solid var(--color-light); + padding-inline-start: calc(var(--size-7) * 1em); + } + } + + #posts { + position: relative; + + & > h2 { + float: inline-start; + } + } + + #last-micro { + clear: inline-start; + max-width: 40ch; + margin-inline: auto; + } + + #last-originals { + clear: inline-start; + } + + @media (width >= 30rem) { + #posts { + & > h2 { + float: inline-start; + max-width: calc( + 100svw + - calc( + 50svw + + calc( + calc(2 * calc(var(--size-4) * 1em)) + calc(var(--size-2) * 1em) + ) + ) + ); + } + } + + #last-micro { + clear: none; + float: inline-end; + width: 50svw; + margin-inline-start: calc(var(--size-2) * 1em); + margin-block-end: calc(var(--size-2) * 1em); + } + } +</style> diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts index 4edef8b..78c9fdf 100644 --- a/src/pages/robots.txt.ts +++ b/src/pages/robots.txt.ts @@ -1,4 +1,4 @@ -import type { APIRoute } from "astro"; +import type { APIContext, APIRoute } from "astro"; const getRobotsTxt = (sitemapURL: URL) => ` User-agent: * @@ -7,7 +7,7 @@ Allow: / Sitemap: ${sitemapURL.href} `; -export const GET: APIRoute = ({ site }) => { +export const GET: APIRoute = ({ site }: APIContext): Response => { const sitemapURL = new URL("sitemap-index.xml", site); return new Response(getRobotsTxt(sitemapURL)); }; diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js deleted file mode 100644 index de5685b..0000000 --- a/src/pages/rss.xml.js +++ /dev/null @@ -1,16 +0,0 @@ -import rss from "@astrojs/rss"; -import { getCollection } from "astro:content"; -import { SITE_DESCRIPTION, SITE_TITLE } from "../consts"; - -export async function GET(context) { - const posts = await getCollection("blog"); - return rss({ - title: SITE_TITLE, - description: SITE_DESCRIPTION, - site: context.site, - items: posts.map((post) => ({ - ...post.data, - link: `/blog/${post.id}/`, - })), - }); -} diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts new file mode 100644 index 0000000..c07f3bd --- /dev/null +++ b/src/pages/rss.xml.ts @@ -0,0 +1,32 @@ +import rss, { type RSSFeedItem } from "@astrojs/rss"; +import { getCollection } from "astro:content"; +import type { APIContext, APIRoute } from "astro"; +import { Blog } from "../lib/collection/schemas.ts"; +import { getFirstAuthorEmail } from "../lib/collection/helpers.ts"; +import { env } from "../lib/env.ts"; + +const { PUBLIC_SITE_TITLE, PUBLIC_SITE_DESCRIPTION, PUBLIC_SITE_URL } = env; + +export const GET: APIRoute = async (context: APIContext): Promise<Response> => { + const posts = await getCollection("blog"); + return rss({ + title: PUBLIC_SITE_TITLE, + description: PUBLIC_SITE_DESCRIPTION, + site: context.site ?? PUBLIC_SITE_URL, + items: await Promise.all(posts.map(async (post): Promise<RSSFeedItem> => { + const { id, rendered } = post; + const blog = Blog.parse(post.data); + + const { title, dateUpdated, dateCreated } = blog; + return { + description: "description" in blog ? blog.description : undefined, + title, + author: await getFirstAuthorEmail(post), + content: rendered?.html, + pubDate: dateUpdated ?? dateCreated, + categories: "keywords" in blog ? blog.keywords : undefined, + link: `/blog/read/${id}/`, + }; + })), + }); +}; diff --git a/src/styles/global.css b/src/styles/global.css index b7ee55d..47dc065 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3,16 +3,40 @@ --ff-sans: ui-sans-serif, sans-serif; --ff-mono: ui-monospace, monospace; --ff-icons: "glyphicons", emoji; - --color-link: #106535; - --color-visited: #00331b; - --color-active: #a50026; + + --color-background: white; + --color-foreground: contrast-color(var(--color-background)); + --color-foreground: black; + --color-link: oklch(0.4539 0.0946 153.93); + --color-visited: color-mix(in oklch, var(--color-link), black); + --color-active: oklch(0.4564 0.1835 20.81); + --color-mute: oklch(0.46 0 0); + --color-light: oklch(0.66 0 0); + --color-dark: oklch(0.47 0 0); + + --size-13: 7.4375rem; + --size-12: 5.5625rem; + --size-11: 4.1875; + --size-10: 3.125; + --size-9: 2.3125; + --size-8: 1.75; + --size-7: 1.5; + --size-6: 1.3125rem; + --size-5: 1.15625rem; + --size-4: 1; + --size-3: 0.75; + --size-2: 0.5625; + --size-1: 0.4375; + --size-0: 0.3125; } body { - margin: 1rem auto; + background: var(--color-background); + color: var(--color-foreground); + margin: calc(var(--size-4) * 1em) auto; max-width: 80ch; font-family: var(--ff-sans); - padding: 0 0.62em 3.24em; + padding: 0 calc(var(--size-2) * 1em) calc(var(--size-10) * 1em); } a:link { @@ -29,14 +53,14 @@ a:active { @media (prefers-color-scheme: dark) { :root { - --color-link: #66bd63; - --color-visited: #a6d96a; - --color-active: #f46d43; - } - - body { - background: #000; - color: #fff; + --color-background: black; + --color-foreground: white; + --color-link: oklch(0.7223 0.1514 143.16); + --color-visited: color-mix(in oklch, var(--color-link), white); + --color-active: oklch(0.6923 0.1759 37.7); + --color-mute: oklch(0.67 0 0); + --color-dark: oklch(0.66 0 0); + --color-light: oklch(0.47 0 0); } } @@ -47,6 +71,14 @@ a:active { } } +[lang="pt-PT"] * { + hyphens: auto; +} + +[lang]:not([lang="pt-PT"]) * { + hyphens: initial; +} + .emoji { font-family: var(--ff-icons); } @@ -55,6 +87,41 @@ a:active { border-bottom: thin dashed; } +h1 { + text-align: center; + font-size: calc(var(--size-9) * 1rem); + font-weight: 800; +} +h1, h2, h3 { + scroll-margin: calc(var(--size-12) * 1rem); +} + +.lead { + font-size: calc(var(--size-6) * 1rem); +} + +.small { + font-size: calc(var(--size-3) * 1rem); + font-weight: 500; +} + +.mute { + color: var(--color-mute); + font-size: calc(var(--size-3) * 1rem); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + dt::after { content: ":"; } @@ -63,7 +130,7 @@ dl { display: grid; grid-template-columns: max-content 1fr; grid-auto-rows: auto; - gap: 0.25rem 1rem; + gap: calc(var(--size-0) * 1em) calc(var(--size-3) * 1em); align-items: start; } @@ -88,11 +155,11 @@ dl.divider dl { gap: 0; } dl.divider dt { - padding-inline-end: 1em; + padding-inline-end: calc(var(--size-4) * 1em); } dl.divider dt + dd:not(:first-of-type) { - border-block-start: 1px solid #181818; + border-block-start: 1px solid var(--color-dark); } dl.divide dd + dt { - border-block-start: 1px solid #181818; + border-block-start: 1px solid var(--color-dark); } diff --git a/src/utils/datetime.test.ts b/src/utils/datetime.test.ts index dd239b2..5f0749d 100644 --- a/src/utils/datetime.test.ts +++ b/src/utils/datetime.test.ts @@ -1,7 +1,6 @@ import { assertEquals, assertMatch } from "@std/assert"; import { describe, it } from "@std/testing/bdd"; import { toIso8601Full, toIso8601FullUTC } from "./datetime.ts"; -import { FakeTime } from "@std/testing/time"; describe("toIso8601Full", () => { it("formats current local time with offset", () => { diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts index 0b23ec8..fdcc3bb 100644 --- a/tests/fixtures/setup.ts +++ b/tests/fixtures/setup.ts @@ -2,13 +2,13 @@ import { createMessage, decryptKey, generateKey, - PrivateKey, + type PrivateKey, sign, } from "npm:openpgp@^6.1.1"; import { passphrase } from "./test_data.ts"; -import { MaybeIterable } from "../../src/utils/iterator.ts"; +import type { MaybeIterable } from "../../src/utils/iterator.ts"; import { afterEach, beforeEach } from "@std/testing/bdd"; -import { Stub, stub } from "@std/testing/mock"; +import { type Stub, stub } from "@std/testing/mock"; export async function generateKeyPair( name: string, @@ -50,6 +50,7 @@ export async function createDetachedSignature( const signature = await sign({ message, signingKeys: Symbol.iterator in signingKeys + // deno-lint-ignore no-undef ? Iterator.from(signingKeys).toArray() : signingKeys, detached: true, |