A simple, folder-driven static-site engine.
bun ssg fs
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(core): revamp image pipeline routing, fallback copying, resizing using sharp

Artwo 9e715e54 0a29a31c

+1041 -139
+34
-verbose/_public/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>My Webette site</title> 6 + </head> 7 + <body> 8 + <h1>My Webette site</h1> 9 + <p></p> 10 + <p>Site generated by Webette v0.0.1</p> 11 + 12 + <script> 13 + (function () { 14 + let lastVersion = null; 15 + async function check() { 16 + try { 17 + const res = await fetch("/__webette-version", { cache: "no-store" }); 18 + if (!res.ok) return; 19 + const text = await res.text(); 20 + if (lastVersion === null) { 21 + lastVersion = text; 22 + } else if (lastVersion !== text) { 23 + location.reload(); 24 + } 25 + } catch (e) { 26 + // Non-fatal: we will retry later 27 + } 28 + } 29 + setInterval(check, 1000); 30 + check(); 31 + })(); 32 + </script> 33 + </body> 34 + </html>
+1
README.md
··· 141 141 - Bun (CLI/runtime) 142 142 - TypeScript 5 (peer dep for types) 143 143 - Markdown pipeline uses unified/remark + rehype (remark-parse, remark-gfm, remark-rehype, rehype-stringify) bundled as dependencies. 144 + - Images: metadata resolution, optional sharp-based variants (small/medium/large), routes under `assets/images/…`.
+63
bun.lock
··· 15 15 "devDependencies": { 16 16 "@types/bun": "latest", 17 17 }, 18 + "optionalDependencies": { 19 + "sharp": "^0.33.2", 20 + }, 18 21 "peerDependencies": { 19 22 "typescript": "^5", 20 23 }, 21 24 }, 22 25 }, 23 26 "packages": { 27 + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], 28 + 29 + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], 30 + 31 + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], 32 + 33 + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], 34 + 35 + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], 36 + 37 + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], 38 + 39 + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], 40 + 41 + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], 42 + 43 + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], 44 + 45 + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], 46 + 47 + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], 48 + 49 + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], 50 + 51 + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], 52 + 53 + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], 54 + 55 + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], 56 + 57 + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], 58 + 59 + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], 60 + 61 + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], 62 + 63 + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], 64 + 65 + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], 66 + 24 67 "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], 25 68 26 69 "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], ··· 51 94 52 95 "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], 53 96 97 + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 98 + 99 + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 100 + 101 + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 102 + 103 + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 104 + 54 105 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], 55 106 56 107 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], ··· 60 111 "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], 61 112 62 113 "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], 114 + 115 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 63 116 64 117 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], 65 118 ··· 76 129 "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], 77 130 78 131 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 132 + 133 + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], 79 134 80 135 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], 81 136 ··· 179 234 180 235 "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], 181 236 237 + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 238 + 239 + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], 240 + 241 + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], 242 + 182 243 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], 183 244 184 245 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], ··· 186 247 "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], 187 248 188 249 "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], 250 + 251 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 189 252 190 253 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 191 254
+1 -1
docs/README.md
··· 5 5 - [project-vision.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/project-vision.md) – high-level goals and development rules. 6 6 - [glossary.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/glossary.md) – Webette terminology. 7 7 - [build-pipeline-and-hooks.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/build-pipeline-and-hooks.md) - description of the pipeline and plugin hooks. 8 - - [tool-config.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/tool-config.md) – how the tool-wide config (`webette.tool.json`) is loaded and used. 8 + - [tool-config.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/tool-config.md) – how the tool-wide config (`webette.tool.ts`) is loaded and used. 9 9 - [logging.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/logging.md) – structured logging, locales, and persistence. 10 10 - [site-config.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/site-config.md) – how `webette.config.ts` is loaded and which fields are consumed. 11 11 - [templates.md](https://tangled.org/artwo.xyz/webette/blob/main/docs/templates.md) – fallback wrapper, template lookup, and the minimal template engine.
+4 -8
docs/build-pipeline-and-hooks.md
··· 12 12 13 13 A Webette build runs through these major phases: 14 14 15 - 1. Load **tool configuration** (`webette.tool.json`) 15 + 1. Load **tool configuration** (`webette.tool.ts`) 16 16 2. Load **site configuration** (`webette.config.ts`) 17 17 3. Raw scan: discover **collections** 18 18 4. Raw scan: discover **entries** 19 19 5. Raw scan: discover **blocks** (type/ext only) 20 20 6. Apply **site config** overrides (collections) 21 21 7. Enrich missing **names/slugs/ids** 22 - 8. Resolve **routes** (site/collection/entry/block) 22 + 8. Resolve **routes** (site/collection/entry; blocks later, by resolvers when needed) 23 23 9. Parse Markdown into an **AST** 24 24 10. Transform content (AST transforms, block transforms, enrichment plugins), render Markdown to HTML, and enrich block metadata 25 25 11. Render **templates** (layouts + partials) ··· 35 35 36 36 ## 1. Tool configuration 37 37 38 - `webette.tool.json` defines settings used by the Webette **tool**, not by a specific site: 38 + `webette.tool.ts` defines settings used by the Webette **tool**, not by a specific site: 39 39 40 40 - Logging (level, locale, folder) 41 41 - Template fallback root and default wrapper ··· 191 191 192 192 ## 12. Output 193 193 194 - Generated HTML and assets are written to the folder defined by: 195 - 196 - ``` 197 - build.outputDirName (from webette.tool.json) 198 - ``` 194 + Generated HTML and assets are written to the folder defined by `build.outputDirName` (from `webette.tool.ts`). 199 195 200 196 Ignored names (e.g. `.webette`, `_public`, custom patterns) follow tool configuration rules. 201 197
+1 -1
docs/glossary.md
··· 152 152 153 153 ## Tool configuration (environment) 154 154 155 - A configuration file dedicated to Webette as a tool (`webette.tool.json`). 155 + A configuration file dedicated to Webette as a tool (`webette.tool.ts`). 156 156 157 157 It can contain, for example: 158 158
+69 -11
docs/images.md
··· 1 1 # Image Handling 2 2 3 - This note summarizes how images are handled today and the intended next steps for optimization. 4 - 5 - ## Current behavior 3 + ## Defaults (engine off) 6 4 7 5 - Images are detected by extension and resolved with lightweight metadata only. 8 6 - Stored on each image block under `content.meta`: 9 7 - `size` (bytes) and `mime` inferred from the extension. 10 8 - `width` and `height` extracted via `image-size` when available; failure is non-fatal (warn only). 11 9 - Resolution is skipped entirely when `readContent` is `false`. 12 - - No resizing, format conversion, or HTML integration is performed yet; images are not emitted/copied during the fallback build. 10 + - Files are copied to the public assets path even when the engine is off; no variants are generated. 13 11 14 - ## Planned optimization (opt-in, sharp) 12 + ## Opt-in generation (sharp) 15 13 16 - When we wire image optimization, it should remain opt-in and resilient: 14 + - Controlled by the `images` section in config (tool defaults in `webette.tool.ts`, site overrides in `webette.config.ts` under `images`). 15 + - Sizes are limited to `small`, `medium`, `large` (+ `default` = original): 16 + - Defaults: `small=480`, `medium=960`, `large=1440` (px, width max), `format=webp`, `quality=80`, `fit=cover`. 17 + - Paths: `outputDir` (default `assets/images` under the build dir) is also the public route prefix for generated files. Route format: `assets/images/<collection-slug>/<entry-slug>/<block-slug>@<variant>.<ext>`. 18 + - `reencodeDefault` (default `false`): when `true` and sharp is present, the `default` variant is re-encoded without resizing; otherwise the original is copied. 19 + - Set `requiresImageEngine: true` to generate variants; otherwise only the original is copied. 20 + - Config priority: tool defaults → site overrides (site `images.*` wins over tool settings). 17 21 18 - - **Config-driven variants**: a site/tool config section (e.g. `images.variants`) will define target sizes/formats/quality, plus a base path for generated assets (default idea: `_public/assets/images/<block-route>/` with URLs like `/assets/images/<block-route>/<slug>@<variant>.<ext>`). 19 - - **Generator**: prefer `sharp` when installed; if absent and variants are requested, log a warning and skip generation rather than failing the build. 20 - - **Outputs in the model**: expose `content.variants[]` with URLs, dimensions, format, and size; keep the original route unchanged. 21 - - **Safety**: deduplicate by hash+variant spec to avoid needless rebuilds; limit variant count/size via config; errors are non-fatal (warn and continue). 22 + ## Outputs 22 23 23 - This keeps the default build lightweight while allowing opt-in image optimization for “web-light” assets. 24 + - When enabled, files are written under `<root>/<outputDirName>/<images.outputDir>/<collection>/<entry>/`. 25 + - Filenames: `<block-slug>@<variant>.<ext>` where `<variant>` is `default|small|medium|large` and `<ext>` follows the target format (`jpeg` → `jpg`). 26 + - `content.variants[]` is attached to the image block with: 27 + - `name`, `route`, `width`, `height`, `format`, `size` 28 + - Optional `aliasOf` + `reason` when a variant reuses `default`. 29 + - Anti-upscale rule: if the target size exceeds the source (or dimensions are unknown), the variant is aliased to `default` with `reason: "source-too-small"` or `"dimensions-unknown"`. 30 + - Missing sharp: warn once, copy only the `default` variant, alias `small/medium/large` to `default` with `reason: "sharp-missing"`. 31 + - Variant errors are non-fatal: warn and fall back to `aliasOf: "default"`. 32 + - Routes: block routes are only set by resolvers when needed; for images, the block route mirrors the default asset route (or the copied original when the engine is off), and variants always carry their own `route`. The scanned `path` remains the source reference. 33 + 34 + ## Example config (site override) 35 + 36 + ```ts 37 + // webette.config.ts 38 + export default { 39 + images: { 40 + requiresImageEngine: true, 41 + sizes: { small: 480, medium: 960, large: 1440 }, 42 + format: "webp", 43 + quality: 80, 44 + fit: "cover", 45 + outputDir: "assets/images", 46 + reencodeDefault: false, 47 + }, 48 + }; 49 + ``` 50 + 51 + ## Example output (content) 52 + 53 + ```json 54 + { 55 + "meta": { "size": 123456, "mime": "image/jpeg", "width": 1200, "height": 800 }, 56 + "variants": [ 57 + { 58 + "name": "default", 59 + "route": "assets/images/posts/my-entry/photo@default.jpg", 60 + "width": 1200, 61 + "height": 800, 62 + "format": "jpg", 63 + "size": 123456 64 + }, 65 + { 66 + "name": "small", 67 + "route": "assets/images/posts/my-entry/photo@small.webp", 68 + "width": 480, 69 + "height": 320, 70 + "format": "webp", 71 + "size": 23456 72 + }, 73 + { 74 + "name": "large", 75 + "route": "assets/images/posts/my-entry/photo@default.jpg", 76 + "aliasOf": "default", 77 + "reason": "source-too-small" 78 + } 79 + ] 80 + } 81 + ```
+1 -1
docs/logging.md
··· 9 9 10 10 ## Configuration 11 11 12 - - Locale and min level come from the tool config (`webette.tool.json`) via `getEnv()` / `resolveLocale()`. 12 + - Locale and min level come from the tool config (`webette.tool.ts`) via `getEnv()` / `resolveLocale()`. 13 13 - `logging.dirName` controls where logs are written inside the **site** root (default `.webette`). 14 14 - Verbose flag (`-v`) forces `debug` min level. 15 15
+2 -2
docs/templates.md
··· 6 6 7 7 ## Where templates come from 8 8 9 - - Tool defaults are configured in `webette.tool.json` (`templates.root`, `templates.default`). 9 + - Tool defaults are configured in `webette.tool.ts` (`templates.root`, `templates.default`). 10 10 - The default wrapper lives at `path.join(process.cwd(), templates.root, templates.default)` (relative to the CLI working dir unless `templates.root` is absolute). 11 11 - When a site provides its own layout, it should live under its project root (e.g. `site/_templates` or `site/templates`). The build should prefer site templates over the tool fallback. 12 12 ··· 28 28 29 29 To inspect the in-memory model before rendering templates, enable the export: 30 30 31 - - Tool config: set `build.exportModel.enabled` (and optionally `dir`, `only`) in `webette.tool.json`. 31 + - Tool config: set `build.exportModel.enabled` (and optionally `dir`, `only`) in `webette.tool.ts`. 32 32 - CLI: `--export-model-dir [path]` or `--export-model-only` (overrides the tool config for this run). 33 33 34 34 When enabled, the model is serialized right after scanning, before HTML rendering, into a folder (default `.webette/_model` at the site root). Export is split: `site.json` contains site/config + collections (sans entries), and each entry is written to `entries/<collection>/<entry>.json`. With the `only` option, the HTML step is skipped so you can focus on the data.
+26 -34
docs/tool-config.md
··· 1 - # Tool Configuration (`webette.tool.json`) 1 + # Tool Configuration (`webette.tool.ts`) 2 2 3 - Webette has a small tool-level configuration file, independent from any site config. It lives next to the CLI (default: current working directory) and is resolved in this order: 3 + Webette has a small tool-level configuration file in TS, independent from any site config. It lives next to the CLI (default: current working directory) and is resolved in this order: 4 4 5 5 1. Explicit path via `WEBETTE_TOOL_CONFIG` (absolute or relative to `process.cwd()`). 6 - 2. `process.cwd()/webette.tool.json` (typical when running from the repo root). 6 + 2. `process.cwd()/webette.tool.ts` (typical when running from the repo root). 7 7 3. Fallback: two levels above `src/config/env.ts` (for bundled installs). 8 8 9 - The file is plain JSON. A UTF-8 BOM is ignored if present. 9 + The file is transpiled (TS). A UTF-8 BOM is ignored if present. 10 10 11 - ## Shape 11 + ## Shape (example) 12 12 13 - ```json 14 - { 15 - "logging": { 16 - "level": "info", 17 - "locale": "fr", 18 - "dirName": ".webette" 19 - }, 20 - "serve": { 21 - "port": 4173, 22 - "liveReloadIntervalMs": 1000, 23 - "watcherDebounceMs": 150 24 - }, 25 - "build": { 26 - "ignoreNames": ["_public", ".webette"], 27 - "outputDirName": "_public", 28 - "overwrite": "replace-files", 29 - "readContent": true, 30 - "exportModel": { 31 - "enabled": false, 32 - "dir": ".webette/_model", 33 - "only": false 34 - } 13 + ```ts 14 + export default { 15 + logging: { level: "info", locale: "fr", dirName: ".webette" }, 16 + serve: { port: 4173, liveReloadIntervalMs: 1000, watcherDebounceMs: 150 }, 17 + build: { 18 + outputDirName: "_public", 19 + overwrite: "replace-files", 20 + readContent: true, 21 + exportModel: { enabled: false, dir: ".webette/_model", only: false }, 35 22 }, 36 - "markdown": { 37 - "remarkPlugins": [] 23 + markdown: { remarkPlugins: [] }, 24 + templates: { root: "templates", default: "wrapper.html" }, 25 + images: { 26 + requiresImageEngine: false, 27 + sizes: { small: 480, medium: 960, large: 1440 }, 28 + format: "webp", 29 + quality: 80, 30 + fit: "cover", 31 + outputDir: "assets/images", 32 + reencodeDefault: false, 38 33 }, 39 - "templates": { 40 - "root": "templates", 41 - "default": "wrapper.html" 42 - } 43 - } 34 + }; 44 35 ``` 45 36 46 37 ### Fields ··· 61 52 - `markdown.remarkPlugins`: list of remark plugins applied during Markdown parsing/rendering (e.g. `["remark-gfm", ["remark-footnotes", { inlineNotes: true }]]`) 62 53 - `templates.root`: base folder for internal/fallback templates (resolved from `process.cwd()` unless absolute) 63 54 - `templates.default`: default wrapper used when the site does not provide its own layout (relative to `templates.root`) 55 + - `images.*`: defaults for the image pipeline (engine on/off, sizes, format/quality/fit, `outputDir`, `reencodeDefault`); site config (`webette.config.ts`) can override. 64 56 65 57 ## Consumption 66 58
+36
docs/webette.tool.md
··· 1 + # webette.tool.ts — fields and expected values 2 + 3 + This file configures the tool (not the site). It is a TS module (`export default { ... }`). All fields are optional; defaults below apply when absent. 4 + 5 + ## logging 6 + - `level`: `"debug" | "info" | "warn" | "error"` (minimum level). 7 + - `locale`: `"en" | "fr"` (log language). 8 + - `dirName`: log folder name (default `.webette` under the site root). 9 + 10 + ## serve 11 + - `port`: dev server port. 12 + - `liveReloadIntervalMs`: live reload polling interval (ms). 13 + - `watcherDebounceMs`: watcher debounce (ms). 14 + 15 + ## build 16 + - `outputDirName`: output folder name (default `_public`). 17 + - `overwrite`: `"none" | "replace-all" | "replace-files"` (overwrite policy). 18 + - `readContent`: `true|false` (skip content resolution). 19 + - `exportModel`: object with `enabled` (`bool`), `dir` (dump folder), `only` (`bool` to export model only). 20 + 21 + ## templates 22 + - `root`: templates folder (relative to the project). 23 + - `default`: default wrapper file (relative to `root`). 24 + 25 + ## images 26 + - `requiresImageEngine`: `true|false` (enable generation via sharp when available). 27 + - `sizes`: object `{ small, medium, large }` in pixels (max width; optional height if provided). 28 + - `format`: `"webp" | "avif" | "jpeg" | "png"` (target format). 29 + - `quality`: number 1–100 (target quality). 30 + - `fit`: `"cover" | "contain" | "fill" | "inside" | "outside"` (resize strategy). 31 + - `outputDir`: relative path under the output folder (and URL) where files are written (default `assets/images` under `_public`). 32 + - `reencodeDefault`: `true|false` (re-encode the `default` variant without resizing when sharp is present; otherwise copy). 33 + 34 + Notes: 35 + - The site config (`webette.config.ts`, `images` section) can override these values and wins over the tool (notably `requiresImageEngine`). 36 + - For images, `outputDir` is both the target folder and the public route prefix (`assets/images/<collection>/<entry>/<block>@<variant>.<ext>`).
+6 -1
locales/en/logs.json
··· 18 18 "build.exportModel.start": "Exporting in-memory model to {path}…", 19 19 "build.exportModel.success": "Model exported to {path}.", 20 20 "build.exportModel.error": "Failed to export model to {path}: {error}", 21 - "build.exportOnly.success": "Export-only build completed (model: {path})." 21 + "build.exportOnly.success": "Export-only build completed (model: {path}).", 22 + "resolve.image.dimensionsFailed": "Could not read dimensions for {path}: {error}", 23 + "resolve.image.sharpMissing": "Image engine (sharp) not available: {error}", 24 + "resolve.image.defaultReencodeSkipped": "Default variant left untouched for {path} (sharp missing).", 25 + "resolve.image.defaultFailed": "Failed to prepare default image for {path}: {error}", 26 + "resolve.image.variantFailed": "Failed to generate variant {variant} for {path}: {error}" 22 27 }
+6 -1
locales/fr/logs.json
··· 18 18 "build.exportModel.start": "Export du modèle en mémoire vers {path}…", 19 19 "build.exportModel.success": "Modèle exporté vers {path}.", 20 20 "build.exportModel.error": "Échec de l'export du modèle vers {path} : {error}", 21 - "build.exportOnly.success": "Export seul terminé (modèle : {path})." 21 + "build.exportOnly.success": "Export seul terminé (modèle : {path}).", 22 + "resolve.image.dimensionsFailed": "Impossible de lire les dimensions pour {path} : {error}", 23 + "resolve.image.sharpMissing": "Moteur d'images (sharp) indisponible : {error}", 24 + "resolve.image.defaultReencodeSkipped": "Variante default laissée intacte pour {path} (sharp absent).", 25 + "resolve.image.defaultFailed": "Échec de la variante default pour {path} : {error}", 26 + "resolve.image.variantFailed": "Échec de la variante {variant} pour {path} : {error}" 22 27 }
+3
package.json
··· 15 15 "remark-rehype": "^11.1.0", 16 16 "unified": "^11.0.0" 17 17 }, 18 + "optionalDependencies": { 19 + "sharp": "^0.33.2" 20 + }, 18 21 "devDependencies": { 19 22 "@types/bun": "latest" 20 23 },
+80 -6
src/config/env.ts
··· 6 6 7 7 export type LogLevel = "debug" | "info" | "warn" | "error"; 8 8 export type OverwritePolicy = "none" | "replace-all" | "replace-files"; 9 + export type ImageFormat = "webp" | "avif" | "jpeg" | "png"; 10 + export type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside"; 11 + 12 + export interface ImagesEnv { 13 + requiresImageEngine: boolean; 14 + sizes: { 15 + small: number; 16 + medium: number; 17 + large: number; 18 + }; 19 + format: ImageFormat; 20 + quality: number; 21 + fit: ImageFit; 22 + outputDir: string; // relative to the output directory (e.g. "assets/images") 23 + reencodeDefault: boolean; 24 + } 9 25 10 26 export interface WebetteEnv { 11 27 logging: { ··· 35 51 markdown: { 36 52 remarkPlugins: unknown[]; 37 53 }; 54 + images: ImagesEnv; 38 55 } 39 56 40 57 type WebetteEnvOverrides = Partial<{ ··· 47 64 >; 48 65 templates: Partial<Pick<WebetteEnv["templates"], "root" | "default">>; 49 66 markdown: Partial<Pick<WebetteEnv["markdown"], "remarkPlugins">>; 67 + images: Partial<ImagesEnv>; 50 68 }>; 51 69 52 70 const defaultEnv: WebetteEnv = { ··· 77 95 markdown: { 78 96 remarkPlugins: [], 79 97 }, 98 + images: { 99 + requiresImageEngine: false, 100 + sizes: { 101 + small: 480, 102 + medium: 960, 103 + large: 1440, 104 + }, 105 + format: "webp", 106 + quality: 80, 107 + fit: "cover", 108 + outputDir: "assets/images", 109 + reencodeDefault: false, 110 + }, 80 111 }; 81 112 82 113 const validLogLevels: LogLevel[] = ["debug", "info", "warn", "error"]; ··· 161 192 : {}), 162 193 }; 163 194 164 - return { logging, serve, build, templates, markdown }; 195 + const images = { 196 + ...defaults.images, 197 + ...(typeof overrides.images?.requiresImageEngine === "boolean" 198 + ? { requiresImageEngine: overrides.images.requiresImageEngine } 199 + : {}), 200 + sizes: { 201 + ...defaults.images.sizes, 202 + ...(overrides.images?.sizes && 203 + typeof overrides.images.sizes === "object" 204 + ? (["small", "medium", "large"] as const).reduce( 205 + (acc, key) => { 206 + const value = (overrides.images?.sizes as Record<string, unknown>)[key]; 207 + if (typeof value === "number" && value > 0) { 208 + acc[key] = value; 209 + } 210 + return acc; 211 + }, 212 + {} as Partial<ImagesEnv["sizes"]> 213 + ) 214 + : {}), 215 + }, 216 + ...(typeof overrides.images?.format === "string" && overrides.images.format 217 + ? { format: overrides.images.format } 218 + : {}), 219 + ...(typeof overrides.images?.quality === "number" 220 + ? { quality: overrides.images.quality } 221 + : {}), 222 + ...(typeof overrides.images?.fit === "string" && overrides.images.fit 223 + ? { fit: overrides.images.fit } 224 + : {}), 225 + ...(typeof overrides.images?.outputDir === "string" && overrides.images.outputDir 226 + ? { outputDir: overrides.images.outputDir } 227 + : {}), 228 + ...(typeof overrides.images?.reencodeDefault === "boolean" 229 + ? { reencodeDefault: overrides.images.reencodeDefault } 230 + : {}), 231 + }; 232 + 233 + return { logging, serve, build, templates, markdown, images }; 165 234 } 166 235 167 236 function resolveConfigPath(toolRoot?: string): string | null { ··· 176 245 177 246 // 1) toolRoot override (if provided explicitly by caller) 178 247 if (toolRoot) { 179 - candidates.push(path.join(toolRoot, "webette.tool.json")); 248 + candidates.push(path.join(toolRoot, "webette.tool.ts")); 180 249 } 181 250 182 251 // 2) current working directory (typical use when running from repo root) 183 - candidates.push(path.join(process.cwd(), "webette.tool.json")); 252 + candidates.push(path.join(process.cwd(), "webette.tool.ts")); 184 253 185 254 // 3) directory of this file (fallback when bundled/installed elsewhere) 186 255 const here = path.dirname(fileURLToPath(import.meta.url)); 187 - candidates.push(path.join(here, "..", "..", "webette.tool.json")); 256 + candidates.push(path.join(here, "..", "..", "webette.tool.ts")); 188 257 189 258 for (const candidate of candidates) { 190 259 if (existsSync(candidate)) { ··· 205 274 if (configPath) { 206 275 try { 207 276 const raw = await readFile(configPath, "utf8"); 208 - const cleaned = raw.replace(/^\uFEFF/, ""); 209 - overrides = JSON.parse(cleaned); 277 + const transpiled = new Bun.Transpiler({ loader: "ts" }).transformSync(raw); 278 + const dataUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString( 279 + "base64" 280 + )}?tool-config=${Date.now()}`; 281 + const mod = await import(dataUrl); 282 + const parsed = mod?.default ?? mod; 283 + overrides = parsed && typeof parsed === "object" ? (parsed as WebetteEnvOverrides) : {}; 210 284 } catch { 211 285 // ignore and fall back to defaults 212 286 }
+185
src/core/block-types/image/config.ts
··· 1 + import * as path from "node:path"; 2 + import type { Site } from "../../model"; 3 + import type { ImageFit, ImageFormat, WebetteEnv } from "../../../config/env"; 4 + 5 + // Fixed set of generated variants to keep themes and sites aligned 6 + export type GeneratedVariantName = "small" | "medium" | "large"; 7 + export const generatedVariants: GeneratedVariantName[] = ["small", "medium", "large"]; 8 + 9 + export interface ImageVariantSize { 10 + width: number; 11 + height?: number; 12 + } 13 + 14 + export interface ImagePipelineConfig { 15 + requiresImageEngine: boolean; 16 + variants: Record<GeneratedVariantName, ImageVariantSize>; 17 + format: ImageFormat; 18 + quality: number; 19 + fit: ImageFit; 20 + reencodeDefault: boolean; 21 + outputDir: string; // absolute path: <root>/<outputDirName>/<images.outputDir> 22 + publicBase: string; // URL prefix for routes, derived from outputDir segment 23 + } 24 + 25 + const MAX_DIMENSION = 4000; 26 + const DEFAULT_FORMAT: ImageFormat = "webp"; 27 + const DEFAULT_FIT: ImageFit = "cover"; 28 + 29 + function clampDimension(value: number | undefined): number | undefined { 30 + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; 31 + return Math.min(Math.round(value), MAX_DIMENSION); 32 + } 33 + 34 + function normalizeVariantSize(raw: unknown): ImageVariantSize | undefined { 35 + if (typeof raw === "number") { 36 + const width = clampDimension(raw); 37 + return width ? { width } : undefined; 38 + } 39 + 40 + if (raw && typeof raw === "object") { 41 + const obj = raw as Record<string, unknown>; 42 + const width = clampDimension(obj.width as number | undefined); 43 + const height = clampDimension(obj.height as number | undefined); 44 + if (!width && !height) return undefined; 45 + return { ...(width ? { width } : {}), ...(height ? { height } : {}) } as ImageVariantSize; 46 + } 47 + 48 + return undefined; 49 + } 50 + 51 + function normalizeOutputDir( 52 + rootDir: string, 53 + outputDirName: string, 54 + raw: unknown, 55 + fallback: string 56 + ): string { 57 + const candidate = typeof raw === "string" && raw.trim() ? raw.trim() : fallback; 58 + if (path.isAbsolute(candidate)) { 59 + return candidate; 60 + } 61 + 62 + const clean = candidate.replace(/^[/\\]+/, ""); 63 + return path.join(rootDir, outputDirName, clean); 64 + } 65 + 66 + function normalizeFormat(raw: unknown, fallback: ImageFormat): ImageFormat { 67 + if (raw === "webp" || raw === "avif" || raw === "jpeg" || raw === "png") { 68 + return raw; 69 + } 70 + return fallback; 71 + } 72 + 73 + function normalizeFit(raw: unknown, fallback: ImageFit): ImageFit { 74 + if ( 75 + raw === "cover" || 76 + raw === "contain" || 77 + raw === "fill" || 78 + raw === "inside" || 79 + raw === "outside" 80 + ) { 81 + return raw; 82 + } 83 + return fallback; 84 + } 85 + 86 + export function resolveImageConfig( 87 + rootDir: string, 88 + env: WebetteEnv, 89 + site: Site 90 + ): ImagePipelineConfig { 91 + // Site overrides live under site.images; tool defaults come from env.images. 92 + const rawSiteImages = 93 + site?.site && typeof site.site === "object" 94 + ? (site.site as Record<string, unknown>).images 95 + : undefined; 96 + 97 + const envImages = env.images; 98 + 99 + const fallbackVariants = { 100 + small: { width: clampDimension(envImages.sizes.small) ?? 480 }, 101 + medium: { width: clampDimension(envImages.sizes.medium) ?? 960 }, 102 + large: { width: clampDimension(envImages.sizes.large) ?? 1440 }, 103 + } as Record<GeneratedVariantName, ImageVariantSize>; 104 + 105 + const siteSizes = 106 + rawSiteImages && typeof rawSiteImages === "object" 107 + ? (rawSiteImages as Record<string, unknown>).sizes 108 + : undefined; 109 + 110 + const variants = generatedVariants.reduce((acc, name) => { 111 + const rawSize = 112 + siteSizes && typeof siteSizes === "object" 113 + ? (siteSizes as Record<string, unknown>)[name] 114 + : undefined; 115 + 116 + acc[name] = normalizeVariantSize(rawSize) ?? fallbackVariants[name]; 117 + return acc; 118 + }, {} as Record<GeneratedVariantName, ImageVariantSize>); 119 + 120 + const requiresImageEngine = 121 + (rawSiteImages && typeof rawSiteImages === "object" && 122 + typeof (rawSiteImages as Record<string, unknown>).requiresImageEngine === 123 + "boolean" 124 + ? (rawSiteImages as Record<string, unknown>).requiresImageEngine 125 + : undefined) ?? 126 + envImages.requiresImageEngine; 127 + 128 + const format = normalizeFormat( 129 + rawSiteImages && typeof rawSiteImages === "object" 130 + ? (rawSiteImages as Record<string, unknown>).format 131 + : undefined, 132 + envImages.format ?? DEFAULT_FORMAT 133 + ); 134 + 135 + const qualityRaw = 136 + rawSiteImages && typeof rawSiteImages === "object" 137 + ? (rawSiteImages as Record<string, unknown>).quality 138 + : undefined; 139 + const quality = 140 + typeof qualityRaw === "number" && Number.isFinite(qualityRaw) 141 + ? Math.min(Math.max(Math.round(qualityRaw), 1), 100) 142 + : Math.min(Math.max(Math.round(envImages.quality ?? 80), 1), 100); 143 + 144 + const fit = normalizeFit( 145 + rawSiteImages && typeof rawSiteImages === "object" 146 + ? (rawSiteImages as Record<string, unknown>).fit 147 + : undefined, 148 + envImages.fit ?? DEFAULT_FIT 149 + ); 150 + 151 + const reencodeDefault = 152 + rawSiteImages && typeof rawSiteImages === "object" && 153 + typeof (rawSiteImages as Record<string, unknown>).reencodeDefault === "boolean" 154 + ? ((rawSiteImages as Record<string, unknown>).reencodeDefault as boolean) 155 + : envImages.reencodeDefault; 156 + 157 + const outputDirSegment = 158 + (rawSiteImages && typeof rawSiteImages === "object" 159 + ? (rawSiteImages as Record<string, unknown>).outputDir 160 + : undefined) ?? envImages.outputDir; 161 + 162 + const outputDir = normalizeOutputDir( 163 + rootDir, 164 + env.build.outputDirName, 165 + outputDirSegment, 166 + envImages.outputDir 167 + ); 168 + 169 + // Public base mirrors the outputDir segment so routes reflect the static path 170 + const publicBase = 171 + typeof outputDirSegment === "string" && outputDirSegment.trim() 172 + ? outputDirSegment.replace(/^\/+|\/+$/g, "") 173 + : envImages.outputDir.replace(/^\/+|\/+$/g, ""); 174 + 175 + return { 176 + requiresImageEngine: Boolean(requiresImageEngine), 177 + variants, 178 + format, 179 + quality, 180 + fit, 181 + reencodeDefault: Boolean(reencodeDefault), 182 + outputDir, 183 + publicBase, 184 + }; 185 + }
+238
src/core/block-types/image/engine.ts
··· 1 + import { copyFile, mkdir, stat } from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import type { Stats } from "node:fs"; 4 + import type { Block, ImageVariant } from "../../model"; 5 + import type { Logger } from "../../../logging/logger"; 6 + import { 7 + buildVariantFileName, 8 + formatToExtension, 9 + resolveOutputPaths, 10 + stripDot, 11 + } from "./paths"; 12 + import type { ImagePipelineConfig, GeneratedVariantName } from "./config"; 13 + import { generatedVariants } from "./config"; 14 + 15 + type SharpModule = typeof import("sharp"); 16 + let cachedSharp: SharpModule | null | undefined; 17 + 18 + async function ensureDir(dir: string) { 19 + await mkdir(dir, { recursive: true }); 20 + } 21 + 22 + async function loadSharp(logger?: Logger): Promise<SharpModule | null> { 23 + if (cachedSharp !== undefined) return cachedSharp; 24 + try { 25 + const mod = await import("sharp"); 26 + cachedSharp = mod.default ?? (mod as SharpModule); 27 + return cachedSharp; 28 + } catch (err) { 29 + logger?.warn("resolve.image.sharpMissing", { 30 + error: String(err), 31 + }); 32 + cachedSharp = null; 33 + return cachedSharp; 34 + } 35 + } 36 + 37 + export async function generateImageVariants(options: { 38 + block: Block; 39 + filePath: string; 40 + dimensions: { width?: number; height?: number }; 41 + stats: Stats; 42 + config: ImagePipelineConfig; 43 + logger?: Logger; 44 + routeHint?: string; 45 + routeSegments?: string[]; 46 + }): Promise<ImageVariant[]> { 47 + const { block, filePath, dimensions, stats, config, logger, routeHint, routeSegments } = 48 + options; 49 + 50 + // Lazy load sharp; if absent we still return aliases so templates remain stable. 51 + const sharpModule = await loadSharp(logger); 52 + const { baseDir, publicStem, slug } = resolveOutputPaths({ 53 + block, 54 + outputDir: config.outputDir, 55 + publicBase: config.publicBase, 56 + routeHint, 57 + routeSegments, 58 + }); 59 + await ensureDir(baseDir); 60 + 61 + const originalExt = stripDot(block.ext?.toLowerCase()) ?? formatToExtension(config.format); 62 + const defaultExt = 63 + config.reencodeDefault && sharpModule 64 + ? formatToExtension(config.format) 65 + : originalExt; 66 + const defaultFileName = buildVariantFileName(slug, "default", defaultExt); 67 + const defaultOutputPath = path.join(baseDir, defaultFileName); 68 + const defaultRoute = path.posix.join(publicStem, defaultFileName); 69 + 70 + const variants: ImageVariant[] = []; 71 + 72 + // Default: copy or reencode without resizing 73 + try { 74 + if (config.reencodeDefault && sharpModule) { 75 + const info = await sharpModule(filePath) 76 + .toFormat(config.format, { quality: config.quality }) 77 + .toFile(defaultOutputPath); 78 + 79 + variants.push({ 80 + name: "default", 81 + route: defaultRoute, 82 + width: info.width ?? dimensions.width, 83 + height: info.height ?? dimensions.height, 84 + format: info.format, 85 + size: info.size, 86 + }); 87 + } else { 88 + await copyFile(filePath, defaultOutputPath); 89 + const copied = await stat(defaultOutputPath); 90 + variants.push({ 91 + name: "default", 92 + route: defaultRoute, 93 + width: dimensions.width, 94 + height: dimensions.height, 95 + format: defaultExt, 96 + size: copied.size, 97 + }); 98 + if (config.reencodeDefault && !sharpModule) { 99 + logger?.warn("resolve.image.defaultReencodeSkipped", { 100 + blockId: block.id, 101 + path: block.path, 102 + reason: "sharp-missing", 103 + }); 104 + } 105 + } 106 + } catch (err) { 107 + logger?.warn("resolve.image.defaultFailed", { 108 + blockId: block.id, 109 + path: block.path, 110 + error: String(err), 111 + }); 112 + return variants; 113 + } 114 + 115 + const defaultVariant = variants[0]; 116 + if (!defaultVariant) { 117 + return variants; 118 + } 119 + 120 + // If sharp is missing, alias generated variants to default 121 + if (!sharpModule) { 122 + for (const name of generatedVariants) { 123 + variants.push({ 124 + name, 125 + route: defaultVariant.route, 126 + width: defaultVariant.width, 127 + height: defaultVariant.height, 128 + format: defaultVariant.format, 129 + size: defaultVariant.size, 130 + aliasOf: "default", 131 + reason: "sharp-missing", 132 + }); 133 + } 134 + return variants; 135 + } 136 + 137 + // Generate small/medium/large when possible, otherwise alias to default (no upscale) 138 + for (const name of generatedVariants) { 139 + const target = config.variants[name as GeneratedVariantName]; 140 + if (!target) continue; 141 + 142 + const targetExt = formatToExtension(config.format); 143 + const fileName = buildVariantFileName(slug, name, targetExt); 144 + const outputPath = path.join(baseDir, fileName); 145 + const route = path.posix.join(publicStem, fileName); 146 + 147 + const missingDimensions = 148 + !dimensions.width || (target.height != null && !dimensions.height); 149 + const sourceTooSmall = 150 + missingDimensions || 151 + (target.width && dimensions.width && target.width > dimensions.width) || 152 + (target.height && dimensions.height && target.height > dimensions.height); 153 + 154 + if (sourceTooSmall) { 155 + variants.push({ 156 + name, 157 + route: defaultVariant.route, 158 + width: defaultVariant.width, 159 + height: defaultVariant.height, 160 + format: defaultVariant.format, 161 + size: defaultVariant.size, 162 + aliasOf: "default", 163 + reason: missingDimensions ? "dimensions-unknown" : "source-too-small", 164 + }); 165 + continue; 166 + } 167 + 168 + try { 169 + const info = await sharpModule(filePath) 170 + .resize(target.width, target.height, { fit: config.fit }) 171 + .toFormat(config.format, { quality: config.quality }) 172 + .toFile(outputPath); 173 + 174 + variants.push({ 175 + name, 176 + route, 177 + width: info.width ?? target.width ?? defaultVariant.width, 178 + height: info.height ?? target.height ?? defaultVariant.height, 179 + format: info.format, 180 + size: info.size, 181 + }); 182 + } catch (err) { 183 + logger?.warn("resolve.image.variantFailed", { 184 + blockId: block.id, 185 + path: block.path, 186 + variant: name, 187 + error: String(err), 188 + }); 189 + variants.push({ 190 + name, 191 + route: defaultVariant.route, 192 + width: defaultVariant.width, 193 + height: defaultVariant.height, 194 + format: defaultVariant.format, 195 + size: defaultVariant.size, 196 + aliasOf: "default", 197 + reason: "generation-failed", 198 + }); 199 + } 200 + } 201 + 202 + return variants; 203 + } 204 + 205 + export async function copyOriginalAsset(options: { 206 + block: Block; 207 + filePath: string; 208 + config: ImagePipelineConfig; 209 + logger?: Logger; 210 + routeHint?: string; 211 + routeSegments?: string[]; 212 + }): Promise<{ route: string; size: number } | undefined> { 213 + const { block, filePath, config, logger, routeHint, routeSegments } = options; 214 + try { 215 + const { baseDir, publicStem, slug } = resolveOutputPaths({ 216 + block, 217 + outputDir: config.outputDir, 218 + publicBase: config.publicBase, 219 + routeHint, 220 + routeSegments, 221 + }); 222 + await ensureDir(baseDir); 223 + const ext = block.ext ?? ""; 224 + const fileName = `${slug}${ext}`; 225 + const outputPath = path.join(baseDir, fileName); 226 + await copyFile(filePath, outputPath); 227 + const copied = await stat(outputPath); 228 + const route = path.posix.join(publicStem, fileName); 229 + return { route, size: copied.size }; 230 + } catch (err) { 231 + logger?.warn("resolve.image.copyOriginalFailed", { 232 + blockId: block.id, 233 + path: block.path, 234 + error: String(err), 235 + }); 236 + return undefined; 237 + } 238 + }
+23
src/core/block-types/image/meta.ts
··· 1 + import type { ImageInfo } from "image-size"; 2 + 3 + export const mimeByExt: Record<string, string> = { 4 + ".jpg": "image/jpeg", 5 + ".jpeg": "image/jpeg", 6 + ".png": "image/png", 7 + ".gif": "image/gif", 8 + ".webp": "image/webp", 9 + ".avif": "image/avif", 10 + ".svg": "image/svg+xml", 11 + ".ico": "image/x-icon", 12 + ".apng": "image/apng", 13 + }; 14 + 15 + export function collectImageMeta(info: ImageInfo): { 16 + width?: number; 17 + height?: number; 18 + } { 19 + return { 20 + width: typeof info.width === "number" ? info.width : undefined, 21 + height: typeof info.height === "number" ? info.height : undefined, 22 + }; 23 + }
+77
src/core/block-types/image/paths.ts
··· 1 + import * as path from "node:path"; 2 + import type { Block } from "../../model"; 3 + 4 + const escapedExtCache = new Map<string, string>(); 5 + function escapeExtForRegex(ext: string): string { 6 + if (escapedExtCache.has(ext)) return escapedExtCache.get(ext)!; 7 + const escaped = ext.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 8 + escapedExtCache.set(ext, escaped); 9 + return escaped; 10 + } 11 + 12 + export function stripDot(ext?: string | null): string | undefined { 13 + if (!ext) return undefined; 14 + return ext.startsWith(".") ? ext.slice(1) : ext; 15 + } 16 + 17 + export function formatToExtension(format: string): string { 18 + if (format === "jpeg") return "jpg"; 19 + return format; 20 + } 21 + 22 + // Derive a stable stem from the block route/path without extension to group outputs 23 + export function normalizeRouteStem(block: Block): { stem: string; slug: string } { 24 + const raw = 25 + (block.route ?? block.path ?? block.rawName ?? "").toString().replace(/\\/g, "/"); 26 + const trimmed = raw.replace(/^\/+/, ""); 27 + const stem = 28 + block.ext && trimmed 29 + ? trimmed.replace(new RegExp(`${escapeExtForRegex(block.ext)}$`, "i"), "") 30 + : trimmed.replace(/\/+$/, ""); 31 + 32 + return { 33 + stem: stem || block.slug || block.rawName, 34 + slug: block.slug || block.rawName, 35 + }; 36 + } 37 + 38 + export function buildVariantFileName(slug: string, name: string, ext: string): string { 39 + return `${slug}@${name}.${ext}`; 40 + } 41 + 42 + export function resolveOutputPaths(options: { 43 + block: Block; 44 + outputDir: string; 45 + publicBase: string; 46 + routeHint?: string; 47 + routeSegments?: string[]; 48 + }) { 49 + const { block, outputDir, publicBase, routeHint, routeSegments } = options; 50 + 51 + // Prefer slug-based segments; drop the block segment for the parent stem. 52 + const segments = Array.isArray(routeSegments) ? routeSegments.slice() : []; 53 + const parentSegments = 54 + segments.length > 0 ? segments.slice(0, -1) : undefined; 55 + 56 + let stem = ""; 57 + if (routeHint) { 58 + const cleaned = routeHint.replace(/^\/+|\/+$/g, ""); 59 + stem = block.ext 60 + ? cleaned.replace(new RegExp(`${escapeExtForRegex(block.ext)}$`, "i"), "") 61 + : cleaned.replace(/\/+$/, ""); 62 + } else { 63 + const normalized = normalizeRouteStem(block); 64 + stem = normalized.stem; 65 + } 66 + 67 + const parentStem = 68 + parentSegments && parentSegments.length > 0 ? parentSegments.join("/") : ""; 69 + 70 + const slug = block.slug || block.rawName; 71 + const baseDir = parentStem ? path.join(outputDir, parentStem) : outputDir; 72 + const publicStem = parentStem 73 + ? path.posix.join(publicBase, parentStem) 74 + : publicBase; 75 + 76 + return { baseDir, publicStem, stem, slug }; 77 + }
+9
src/core/block-types/image/sharp.d.ts
··· 1 + declare module "sharp" { 2 + type SharpInstance = any; 3 + interface SharpModule { 4 + (input?: any): SharpInstance; 5 + default?: SharpModule; 6 + } 7 + const sharp: SharpModule; 8 + export = sharp; 9 + }
+8 -7
src/core/build.ts
··· 39 39 typeof options.readContent === "boolean" 40 40 ? options.readContent 41 41 : toolEnv.build.readContent; 42 - 43 - const resolvedSite = await resolveBlocks(scannedSite, { 44 - rootDir, 45 - readContent, 46 - logger: scoped.child("resolve"), 47 - remarkPlugins: toolEnv.markdown.remarkPlugins, 48 - }); 49 42 const outputDir = path.join(rootDir, toolEnv.build.outputDirName); 50 43 const overwrite = toolEnv.build.overwrite; 51 44 const envExport = toolEnv.build.exportModel; ··· 106 99 // Directory already exists: not fatal when recursive, continue 107 100 scoped.debug("build.outputDirExists", { outputDir }); 108 101 } 102 + 103 + const resolvedSite = await resolveBlocks(scannedSite, { 104 + rootDir, 105 + readContent, 106 + logger: scoped.child("resolve"), 107 + remarkPlugins: toolEnv.markdown.remarkPlugins, 108 + toolEnv, 109 + }); 109 110 110 111 const liveReloadScript = options.liveReload 111 112 ? `
+9
src/core/config.ts
··· 5 5 interface WebetteConfigFile { 6 6 site?: Record<string, unknown>; 7 7 collections?: Record<string, unknown>; 8 + images?: Record<string, unknown>; 8 9 } 9 10 10 11 // Build the in-memory Site object based on the project folder and config file ··· 32 33 raw.collections && typeof raw.collections === "object" 33 34 ? { ...raw.collections } 34 35 : undefined; 36 + const images = 37 + raw.images && typeof raw.images === "object" 38 + ? { ...raw.images } 39 + : undefined; 35 40 36 41 return { 37 42 site: site as Record<string, unknown> | undefined, 38 43 collections: collections as Record<string, unknown> | undefined, 44 + images: images as Record<string, unknown> | undefined, 39 45 }; 40 46 } catch { 41 47 // no file or invalid module -> empty config ··· 49 55 50 56 const siteSection: Record<string, unknown> = 51 57 config.site && typeof config.site === "object" ? { ...config.site } : {}; 58 + if (config.images && typeof config.images === "object") { 59 + siteSection.images = { ...(siteSection.images as Record<string, unknown> | undefined), ...config.images }; 60 + } 52 61 const collectionsConfig: Record<string, unknown> | undefined = 53 62 config.collections && typeof config.collections === "object" 54 63 ? { ...config.collections }
+14
src/core/model.ts
··· 41 41 route?: string; // root route, usually "/" 42 42 } 43 43 44 + export type ImageVariantName = "default" | "small" | "medium" | "large"; 45 + 46 + export interface ImageVariant { 47 + name: ImageVariantName; 48 + route: string; // public path to the generated variant 49 + width?: number; 50 + height?: number; 51 + format?: string; 52 + size?: number; 53 + aliasOf?: ImageVariantName; 54 + reason?: string; 55 + } 56 + 44 57 export interface ResolvedBlockContent { 45 58 raw?: string; // raw text content (md/txt) 46 59 data?: unknown; // parsed data (json, etc.) ··· 52 65 format?: string; // e.g., "markdown" 53 66 renderedWith?: string; // pipeline used to generate HTML 54 67 }; 68 + variants?: ImageVariant[]; // generated/resolved variants for media (images) 55 69 ast?: unknown; // structured representation (e.g. mdast) 56 70 html?: string; // rendered HTML for markdown/text blocks 57 71 }
+40 -9
src/core/resolve/index.ts
··· 1 1 import type { Collection, Entry, Site, Block } from "../model"; 2 - import { 3 - getResolver, 4 - type ResolveContext, 5 - } from "./registry"; 2 + import type { WebetteEnv } from "../../config/env"; 3 + import { resolveImageConfig } from "../block-types/image/config"; 4 + import { getResolver, type ResolveContext } from "./registry"; 5 + 6 + function splitRouteSegments(route?: string): string[] { 7 + if (!route) return []; 8 + return route.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean); 9 + } 10 + 11 + function buildBlockRouteHint(block: Block, parentSegments: string[]): { 12 + routeHint: string; 13 + routeSegments: string[]; 14 + } { 15 + const routeSegments = [...parentSegments, block.slug]; 16 + const routeBase = routeSegments.join("/"); 17 + const routeHint = block.ext ? `${routeBase}${block.ext}` : `${routeBase}/`; 18 + return { routeHint, routeSegments }; 19 + } 6 20 7 21 // Entry point: walk the scanned model and enrich blocks with resolved content. 8 22 export interface ResolveOptions { ··· 12 26 remarkPlugins?: ResolveContext["remarkPlugins"]; 13 27 beforeBlock?: ResolveContext["beforeBlock"]; 14 28 afterBlock?: ResolveContext["afterBlock"]; 29 + toolEnv?: WebetteEnv; 15 30 } 16 31 17 32 function createContext(site: Site, options: ResolveOptions): ResolveContext { 18 33 const rootDir = options.rootDir ?? site.rootDir; 34 + const imageConfig = options.toolEnv 35 + ? resolveImageConfig(rootDir, options.toolEnv, site) 36 + : undefined; 19 37 20 38 return { 21 39 rootDir, 40 + site, 41 + toolEnv: options.toolEnv, 42 + imageConfig, 22 43 readContent: options.readContent, 23 44 logger: options.logger, 24 45 remarkPlugins: options.remarkPlugins, ··· 29 50 30 51 async function resolveBlock( 31 52 block: Block, 32 - ctx: ResolveContext 53 + ctx: ResolveContext, 54 + parentRouteSegments: string[] 33 55 ): Promise<Block> { 34 56 ctx.beforeBlock?.(block); 35 57 58 + const { routeHint, routeSegments } = buildBlockRouteHint(block, parentRouteSegments); 59 + const scopedCtx: ResolveContext = { ...ctx, routeHint, routeSegments }; 60 + 36 61 // Resolve children first so folder/meta resolvers see already enriched subBlocks 37 62 const subBlocks = block.subBlocks 38 - ? await Promise.all(block.subBlocks.map((child) => resolveBlock(child, ctx))) 63 + ? await Promise.all(block.subBlocks.map((child) => resolveBlock(child, scopedCtx, routeSegments))) 39 64 : undefined; 40 65 41 66 const resolver = getResolver(block); 42 - const resolved = resolver ? await resolver(block, ctx) : undefined; 67 + const resolved = resolver ? await resolver(block, scopedCtx) : undefined; 43 68 44 69 ctx.afterBlock?.(block, resolved); 45 70 71 + const { blockRoute, ...content } = resolved ?? {}; 72 + 46 73 return { 47 74 ...block, 75 + ...(blockRoute ? { route: blockRoute } : {}), 48 76 ...(subBlocks ? { subBlocks } : {}), 49 - ...(resolved ? { content: resolved } : {}), 77 + ...(resolved ? { content } : {}), 50 78 }; 51 79 } 52 80 53 81 async function resolveEntry(entry: Entry, ctx: ResolveContext): Promise<Entry> { 54 - const blocks = await Promise.all(entry.blocks.map((blk) => resolveBlock(blk, ctx))); 82 + const parentSegments = splitRouteSegments(entry.route); 83 + const blocks = await Promise.all( 84 + entry.blocks.map((blk) => resolveBlock(blk, ctx, parentSegments)) 85 + ); 55 86 return { ...entry, blocks }; 56 87 } 57 88
+11 -2
src/core/resolve/registry.ts
··· 1 1 // Resolver registry: maps block types to their content resolvers. 2 - import type { Block } from "../model"; 2 + import type { Block, ImageVariant, Site } from "../model"; 3 3 import { resolveMarkdown } from "./resolvers/markdown"; 4 4 import { resolveText } from "./resolvers/text"; 5 5 import { resolveData } from "./resolvers/data"; ··· 10 10 import { resolveTable } from "./resolvers/table"; 11 11 import { resolveFile } from "./resolvers/file"; 12 12 import type { Logger } from "../../logging/logger"; 13 + import type { ImagePipelineConfig } from "../block-types/image/config"; 14 + import type { WebetteEnv } from "../../config/env"; 13 15 14 16 export interface ResolvedContent { 15 17 raw?: string; ··· 24 26 }; 25 27 ast?: unknown; // Structured representation (e.g. mdast) when available 26 28 html?: string; // Optional HTML rendering for text/markdown blocks 29 + variants?: ImageVariant[]; // Generated variants for media (images) 30 + blockRoute?: string; // Optional override for the block route (e.g., generated asset path) 27 31 } 28 32 29 33 // Context passed to all resolvers 30 34 export interface ResolveContext { 31 35 rootDir: string; 36 + site: Site; 37 + toolEnv?: WebetteEnv; 38 + imageConfig?: ImagePipelineConfig; 39 + routeHint?: string; // slug-based route hint for the current block (public-ish) 40 + routeSegments?: string[]; // slug-based segments for the current block 32 41 readContent?: boolean; 33 42 logger?: Logger; 34 - remarkPlugins?: unknown[]; 43 + remarkPlugins?: unknown[]; 35 44 beforeBlock?: (block: Block) => void; 36 45 afterBlock?: (block: Block, resolved?: ResolvedContent) => void; 37 46 }
+38 -24
src/core/resolve/resolvers/image.ts
··· 2 2 import * as path from "node:path"; 3 3 import type { Resolver } from "../registry"; 4 4 import imageSize from "image-size"; 5 - 6 - const mimeByExt: Record<string, string> = { 7 - ".jpg": "image/jpeg", 8 - ".jpeg": "image/jpeg", 9 - ".png": "image/png", 10 - ".gif": "image/gif", 11 - ".webp": "image/webp", 12 - ".avif": "image/avif", 13 - ".svg": "image/svg+xml", 14 - ".ico": "image/x-icon", 15 - ".apng": "image/apng", 16 - }; 5 + import { collectImageMeta, mimeByExt } from "../../block-types/image/meta"; 6 + import { copyOriginalAsset, generateImageVariants } from "../../block-types/image/engine"; 17 7 18 - // Resolve image blocks: keep it light, only record size and mime 8 + // Resolve image blocks: record metadata, optionally copy originals, and generate variants. 19 9 export const resolveImage: Resolver = async (block, ctx) => { 20 10 if (ctx.readContent === false) return undefined; 21 11 ··· 27 17 let dimensions: { width?: number; height?: number } = {}; 28 18 try { 29 19 const size = imageSize(filePath); 30 - dimensions = { 31 - width: typeof size.width === "number" ? size.width : undefined, 32 - height: typeof size.height === "number" ? size.height : undefined, 33 - }; 20 + dimensions = collectImageMeta(size); 34 21 } catch (err) { 35 22 ctx.logger?.warn("resolve.image.dimensionsFailed", { 36 23 blockId: block.id, ··· 39 26 }); 40 27 } 41 28 42 - return { 43 - meta: { 44 - size: stats.size, 45 - ...(mime ? { mime } : {}), 46 - ...(dimensions.width ? { width: dimensions.width } : {}), 47 - ...(dimensions.height ? { height: dimensions.height } : {}), 48 - }, 29 + const meta = { 30 + size: stats.size, 31 + ...(mime ? { mime } : {}), 32 + ...(dimensions.width ? { width: dimensions.width } : {}), 33 + ...(dimensions.height ? { height: dimensions.height } : {}), 49 34 }; 35 + 36 + // If the image engine is disabled, still copy the original to the public assets path. 37 + if (!ctx.imageConfig?.requiresImageEngine) { 38 + const copied = await copyOriginalAsset({ 39 + block, 40 + filePath, 41 + config: ctx.imageConfig, 42 + logger: ctx.logger, 43 + routeHint: ctx.routeHint, 44 + routeSegments: ctx.routeSegments, 45 + }); 46 + const blockRoute = copied?.route; 47 + return { meta, ...(blockRoute ? { blockRoute } : {}) }; 48 + } 49 + 50 + const variants = await generateImageVariants({ 51 + block, 52 + filePath, 53 + dimensions, 54 + stats, 55 + config: ctx.imageConfig, 56 + logger: ctx.logger, 57 + routeHint: ctx.routeHint, 58 + routeSegments: ctx.routeSegments, 59 + }); 60 + 61 + const blockRoute = variants[0]?.route; 62 + 63 + return { meta, variants, ...(blockRoute ? { blockRoute } : {}) }; 50 64 };
+2 -5
src/core/routes.ts
··· 27 27 } 28 28 29 29 function resolveBlockRoutes(block: Block, parentSegments: string[]): Block { 30 - // Blocks inherit the parent route; folders end with "/", files keep their extension. 30 + // Blocks inherit the parent route segments for structure, but the route itself 31 + // is set later by resolvers/processors when needed. 31 32 const segments = [...parentSegments, block.slug]; 32 - const routeBase = joinRoute(...segments); 33 - const route = block.ext ? `${routeBase}${block.ext}` : `${routeBase}/`; 34 - 35 33 const subBlocks = block.subBlocks?.map((sub) => resolveBlockRoutes(sub, segments)); 36 34 37 35 return { 38 36 ...block, 39 - route, 40 37 ...(subBlocks ? { subBlocks } : {}), 41 38 }; 42 39 }
-26
webette.tool.json
··· 1 - { 2 - "logging": { 3 - "level": "info", 4 - "locale": "fr", 5 - "dirName": ".webette" 6 - }, 7 - "serve": { 8 - "port": 4173, 9 - "liveReloadIntervalMs": 1000, 10 - "watcherDebounceMs": 150 11 - }, 12 - "build": { 13 - "outputDirName": "_public", 14 - "overwrite": "replace-files", 15 - "readContent": true, 16 - "exportModel": { 17 - "enabled": true, 18 - "dir": ".webette/_model", 19 - "only": false 20 - } 21 - }, 22 - "templates": { 23 - "root": "templates", 24 - "default": "wrapper.html" 25 - } 26 - }
+40
webette.tool.ts
··· 1 + // Tool config (TS) loaded by src/config/env.ts 2 + export default { 3 + logging: { 4 + level: "info", // "debug" | "info" | "warn" | "error" 5 + locale: "fr", // "fr" | "en" 6 + dirName: ".webette", // site-local logs folder 7 + }, 8 + serve: { 9 + port: 4173, // dev server port 10 + liveReloadIntervalMs: 1000, // live reload polling interval (ms) 11 + watcherDebounceMs: 150, // watcher debounce (ms) 12 + }, 13 + build: { 14 + outputDirName: "_public", // build output directory 15 + overwrite: "replace-all", // "none" | "replace-all" | "replace-files" 16 + readContent: true, // resolve block contents or skip 17 + exportModel: { 18 + enabled: true, // export the in-memory model 19 + dir: ".webette/_model", // target folder for export 20 + only: false, // if true, only write the model 21 + }, 22 + }, 23 + templates: { 24 + root: "templates", // default templates folder 25 + default: "wrapper.html", // default wrapper 26 + }, 27 + images: { 28 + requiresImageEngine: false, // enable variant generation via sharp 29 + sizes: { 30 + small: 480, // max width for small 31 + medium: 960, // max width for medium 32 + large: 1440, // max width for large 33 + }, 34 + format: "webp", // target format for variants 35 + quality: 80, // quality (1-100) 36 + fit: "cover", // resize strategy: cover/contain/fill/inside/outside 37 + outputDir: "assets/images", // relative path under output dir 38 + reencodeDefault: false, // re-encode the default variant without resize 39 + }, 40 + };
website-example/_public/assets/images/custom-slug/my-first-post/gallerie/herschel-2.webp

This is a binary file and will not be displayed.

website-example/_public/assets/images/custom-slug/my-first-post/gallerie/the-great-nebula-in-orion-2.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/custom-slug/my-first-post/herschel.webp

This is a binary file and will not be displayed.

website-example/_public/assets/images/custom-slug/my-first-post/the-great-nebula-in-orion.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/custom-slug/my-second-post/ideal-view-of-saturn-s-rings-and-satellites-from-the-planet.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/custom-slug/my-second-post/stars-whose-distances-are-best-known.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/custom-slug/my-second-post/the-planet-jupiter.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-first-post/gallerie/herschel-2.webp

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-first-post/gallerie/the-great-nebula-in-orion-2.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-first-post/herschel.webp

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-first-post/the-great-nebula-in-orion.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-second-post/ideal-view-of-saturn-s-rings-and-satellites-from-the-planet.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-second-post/stars-whose-distances-are-best-known.jpg

This is a binary file and will not be displayed.

website-example/_public/assets/images/posts/my-second-post/the-planet-jupiter.jpg

This is a binary file and will not be displayed.

+14
website-example/webette.config.ts
··· 21 21 // Optional plugin references 22 22 // root: "_plugins", 23 23 ], 24 + 25 + images: { 26 + requiresImageEngine: false, 27 + sizes: { 28 + small: 480, 29 + medium: 960, 30 + large: 1440, 31 + }, 32 + format: "webp", 33 + quality: 80, 34 + fit: "cover", 35 + outputDir: "assets/images", 36 + reencodeDefault: false, 37 + }, 24 38 };