diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3723623 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/package.json b/package.json index d503e80..d247bbe 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,12 @@ "preview": "astro preview", "lint": "eslint .", "lint:fix": "eslint --fix .", - "format": "prettier --write ." + "format": "prettier --write .", + "prepare": "husky" + }, + "lint-staged": { + "*.{js,ts,astro}": "eslint --fix", + "*": "prettier --write --ignore-unknown" }, "engines": { "node": ">=22.0.0" @@ -33,6 +38,8 @@ "@types/node": "^25.3.3", "eslint": "^10.0.2", "eslint-plugin-astro": "^1.6.0", + "husky": "^9.1.7", + "lint-staged": "^16.3.2", "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1", "typescript": "^5.9.3", diff --git a/src/components/ReadNext.astro b/src/components/ReadNext.astro index bc0c3b4..b9f66f3 100644 --- a/src/components/ReadNext.astro +++ b/src/components/ReadNext.astro @@ -1,32 +1,46 @@ --- import PostCard from './PostCard.astro'; -import ReadNextCard from './ReadNextCard.astro'; import type { CollectionEntry } from 'astro:content'; interface Props { currentSlug: string; - tags: string[]; relatedPosts: CollectionEntry<'posts'>[]; prevPost?: CollectionEntry<'posts'> | null; nextPost?: CollectionEntry<'posts'> | null; } -const { currentSlug, tags, relatedPosts, prevPost, nextPost } = Astro.props; -const showRelatedPosts = relatedPosts.length > 1; -const hasContent = showRelatedPosts || prevPost || nextPost; +const { currentSlug, relatedPosts, prevPost, nextPost } = Astro.props; + +const MAX_CARDS = 3; +const seen = new Set([currentSlug]); +const cards: CollectionEntry<'posts'>[] = []; + +for (const post of relatedPosts) { + if (cards.length >= MAX_CARDS) break; + if (!seen.has(post.id)) { + seen.add(post.id); + cards.push(post); + } +} + +for (const post of [prevPost, nextPost]) { + if (cards.length >= MAX_CARDS) break; + if (post && !seen.has(post.id)) { + seen.add(post.id); + cards.push(post); + } +} --- { - hasContent && ( + cards.length > 0 && ( diff --git a/src/components/ReadNextCard.astro b/src/components/ReadNextCard.astro deleted file mode 100644 index d89c624..0000000 --- a/src/components/ReadNextCard.astro +++ /dev/null @@ -1,124 +0,0 @@ ---- -import { format } from 'date-fns'; -import type { CollectionEntry } from 'astro:content'; - -interface Props { - currentSlug: string; - tags: string[]; - relatedPosts: CollectionEntry<'posts'>[]; -} - -const { currentSlug, tags, relatedPosts } = Astro.props; -const tag = tags[0]; -const tagSlug = tag?.toLowerCase().replace(/\s+/g, '-'); - -const filteredPosts = relatedPosts.filter(p => p.id !== currentSlug).slice(0, 3); ---- - - - - diff --git a/src/content/posts/013-shades-vnode-refactor.md b/src/content/posts/013-shades-vnode-refactor.md new file mode 100644 index 0000000..6846bfa --- /dev/null +++ b/src/content/posts/013-shades-vnode-refactor.md @@ -0,0 +1,90 @@ +--- +title: 'Shades 12: The VNode Refactor' +author: [gallayl] +tags: ['shades'] +date: '2026-03-05T18:00:00.000Z' +draft: false +image: img/013-vnode.jpg +excerpt: Shades v12 replaces the rendering engine with a VNode-based reconciler and drops lifecycle callbacks in favor of hooks — here's the full story. +--- + +## Why rewrite the renderer + +Shades had a beautifully dumb rendering model: your `render()` function spits out real DOM elements, the framework diffs them against what's already on screen, and patches the differences. Simple, honest, easy to reason about... and wasteful. Every single render cycle spun up a full shadow DOM tree _just to throw it away after comparison_. That's a lot of garbage collection for what often boils down to changing one text node. + +v12 rips that out and replaces it with a **VNode-based reconciler**. Now the JSX factory produces lightweight descriptor objects — plain JS, no DOM involved. The reconciler diffs the old VNode tree against the new one and pokes the real DOM only where something actually changed. No throwaway trees. No phantom elements. Just surgical updates. + +## Hooks in, lifecycle callbacks out + +The old API had three separate lifecycle hooks: `constructed`, `onAttach`, and `onDetach`. Three places to scatter your setup and teardown logic, three sets of timing semantics to keep in your head. In v12, they're all gone — consolidated into one composable primitive: **`useDisposable`**. + +```typescript +// Before — lifecycle spaghetti +Shade({ + shadowDomName: 'my-component', + constructed: ({ element }) => { + const listener = () => { /* ... */ } + window.addEventListener('click', listener) + return () => window.removeEventListener('click', listener) + }, + render: () =>
Hello
, +}) + +// After — setup and cleanup live together, right where you use them +Shade({ + shadowDomName: 'my-component', + render: ({ useDisposable }) => { + useDisposable('click-handler', () => { + const listener = () => { /* ... */ } + window.addEventListener('click', listener) + return { [Symbol.dispose]: () => window.removeEventListener('click', listener) } + }) + return
Hello
+ }, +}) +``` + +The `element` parameter is also gone. Reaching into the host element and mutating it imperatively was always a bit... rebellious for a declarative framework. Say hello to **`useHostProps`** instead — it lets you declare attributes, styles, CSS custom properties, ARIA attrs, and event handlers without ever touching the DOM yourself: + +```typescript +render: ({ useHostProps, props }) => { + useHostProps({ + 'data-variant': props.variant, + style: { '--color': colors.main }, + }) + return +} +``` + +And for those moments when you _do_ need a handle on a child element (focusing an input, measuring a bounding rect), there's **`useRef`** — no more `querySelector` treasure hunts through the shadow DOM: + +```typescript +render: ({ useRef }) => { + const inputRef = useRef('input') + return + // Later: inputRef.current?.focus() +} +``` + +## Batched updates (a.k.a. stop re-rendering so much) + +`updateComponent()` used to be synchronous. Fire three observable changes in a row? Enjoy your three render passes. In v12, updates go through `queueMicrotask` and get coalesced — hammer as many observables as you want within a synchronous block and the component renders _once_. The new `flushUpdates()` utility lets tests await pending renders properly, so you can finally delete those sketchy `sleepAsync(50)` calls. + +## SVG — for real this time + +Shades now handles SVG elements as first-class citizens. Elements are created with `createElementNS` under the correct namespace, attributes go through `setAttribute` instead of property assignment (because SVG is picky like that), and there's a full set of typed interfaces covering shapes, gradients, filters, and animations. Your editor's autocomplete will thank you. + +## Migration cheat sheet + +| Gone | Use this instead | +| ------------------------------- | ---------------------------------------- | +| `constructed` callback | `useDisposable` in `render` | +| `element` in render options | `useHostProps` hook | +| `onAttach` / `onDetach` | `useDisposable` | +| Synchronous `updateComponent()` | Async batched updates + `flushUpdates()` | + +## What's next + +The v12.x train keeps rolling — we've already shipped dependency tracking for `useDisposable`, a `css` property for component-level styling with pseudo-selectors, and a brand new routing system. Stay tuned for a dedicated post on the `NestedRouter`. + +Want to take it for a spin? `npm install @furystack/shades@latest` and check the [changelog](https://github.com/furystack/furystack/blob/develop/packages/shades/CHANGELOG.md#1220---2026-02-22) for all the gory details. diff --git a/src/content/posts/img/013-vnode.jpg b/src/content/posts/img/013-vnode.jpg new file mode 100644 index 0000000..e58bacf Binary files /dev/null and b/src/content/posts/img/013-vnode.jpg differ diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro index d33dd93..9a706e4 100644 --- a/src/layouts/PostLayout.astro +++ b/src/layouts/PostLayout.astro @@ -89,7 +89,6 @@ const primaryTag = tags?.[0]; { const prev = index > 0 ? sorted[index - 1] : null; const next = index < sorted.length - 1 ? sorted[index + 1] : null; - const primaryTag = post.data.tags?.[0]; - const relatedPosts = primaryTag - ? sorted.filter(p => p.data.tags?.includes(primaryTag) && p.id !== post.id) - : []; + const currentTags = post.data.tags ?? []; + const relatedPosts = + currentTags.length > 0 + ? sorted + .filter(p => p.id !== post.id && p.data.tags?.some(t => currentTags.includes(t))) + .map(p => ({ + post: p, + overlap: (p.data.tags ?? []).filter(t => currentTags.includes(t)).length, + })) + .sort( + (a, b) => + b.overlap - a.overlap || b.post.data.date.valueOf() - a.post.data.date.valueOf(), + ) + .map(r => r.post) + : []; const authorData = post.data.author .map(authorId => authors.find(a => a.data.id === authorId)?.data) .filter(Boolean); diff --git a/yarn.lock b/yarn.lock index 8b3087d..1a84b7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,6 +1730,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^7.0.0": + version: 7.3.0 + resolution: "ansi-escapes@npm:7.3.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/068961d99f0ef28b661a4a9f84a5d645df93ccf3b9b93816cc7d46bbe1913321d4cdf156bb842a4e1e4583b7375c631fa963efb43001c4eb7ff9ab8f78fc0679 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -1753,7 +1762,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.2.1": +"ansi-styles@npm:^6.2.1, ansi-styles@npm:^6.2.3": version: 6.2.3 resolution: "ansi-styles@npm:6.2.3" checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868 @@ -2075,6 +2084,25 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10c0/7ec62f69b79f6734ab209a3e4dbdc8af7422d44d360a7cb1efa8a0887bbe466a6e625650c466fe4359aee44dbe2dc0b6994b583d40a05d0808a5cb193641d220 + languageName: node + linkType: hard + +"cli-truncate@npm:^5.0.0": + version: 5.2.0 + resolution: "cli-truncate@npm:5.2.0" + dependencies: + slice-ansi: "npm:^8.0.0" + string-width: "npm:^8.2.0" + checksum: 10c0/0d4ec94702ca85b64522ac93633837fb5ea7db17b79b1322a60f6045e6ae2b8cd7bd4c1d19ac7d1f9e10e3bbda1112e172e439b68c02b785ee00da8d6a5c5471 + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -2109,6 +2137,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.20": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 + languageName: node + linkType: hard + "comma-separated-tokens@npm:^2.0.0": version: 2.0.2 resolution: "comma-separated-tokens@npm:2.0.2" @@ -2123,6 +2158,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^14.0.3": + version: 14.0.3 + resolution: "commander@npm:14.0.3" + checksum: 10c0/755652564bbf56ff2ff083313912b326450d3f8d8c85f4b71416539c9a05c3c67dbd206821ca72635bf6b160e2afdefcb458e86b317827d5cb333b69ce7f1a24 + languageName: node + linkType: hard + "common-ancestor-path@npm:^1.0.1": version: 1.0.1 resolution: "common-ancestor-path@npm:1.0.1" @@ -2426,6 +2468,13 @@ __metadata: languageName: node linkType: hard +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d + languageName: node + linkType: hard + "es-module-lexer@npm:^1.7.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" @@ -3051,6 +3100,8 @@ __metadata: date-fns: "npm:^4.1.0" eslint: "npm:^10.0.2" eslint-plugin-astro: "npm:^1.6.0" + husky: "npm:^9.1.7" + lint-staged: "npm:^16.3.2" prettier: "npm:^3.8.1" prettier-plugin-astro: "npm:^0.14.1" remark-smartypants: "npm:^3.0.2" @@ -3067,7 +3118,7 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0": +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": version: 1.5.0 resolution: "get-east-asian-width@npm:1.5.0" checksum: 10c0/bff8bbc8d81790b9477f7aa55b1806b9f082a8dc1359fff7bd8b96939622c86b729685afc2bfeb22def1fc6ef1e5228e4d87dd4e6da60bc43a5edfb03c4ee167 @@ -3319,6 +3370,15 @@ __metadata: languageName: node linkType: hard +"husky@npm:^9.1.7": + version: 9.1.7 + resolution: "husky@npm:9.1.7" + bin: + husky: bin.js + checksum: 10c0/35bb110a71086c48906aa7cd3ed4913fb913823715359d65e32e0b964cb1e255593b0ae8014a5005c66a68e6fa66c38dcfa8056dbbdfb8b0187c0ffe7ee3a58f + languageName: node + linkType: hard + "iconv-lite@npm:^0.7.2": version: 0.7.2 resolution: "iconv-lite@npm:0.7.2" @@ -3393,6 +3453,15 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^5.0.0, is-fullwidth-code-point@npm:^5.1.0": + version: 5.1.0 + resolution: "is-fullwidth-code-point@npm:5.1.0" + dependencies: + get-east-asian-width: "npm:^1.3.1" + checksum: 10c0/c1172c2e417fb73470c56c431851681591f6a17233603a9e6f94b7ba870b2e8a5266506490573b607fb1081318589372034aa436aec07b465c2029c0bc7f07a4 + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -3536,6 +3605,36 @@ __metadata: languageName: node linkType: hard +"lint-staged@npm:^16.3.2": + version: 16.3.2 + resolution: "lint-staged@npm:16.3.2" + dependencies: + commander: "npm:^14.0.3" + listr2: "npm:^9.0.5" + micromatch: "npm:^4.0.8" + string-argv: "npm:^0.3.2" + tinyexec: "npm:^1.0.2" + yaml: "npm:^2.8.2" + bin: + lint-staged: bin/lint-staged.js + checksum: 10c0/4cbaa85904a912215660ac3c69400b71beabe3fe7e82969cd32c726dbfb04b0f651d0660906215f955c1446ac6b520319e1c7a03c707353a5f038c3d0441a8c3 + languageName: node + linkType: hard + +"listr2@npm:^9.0.5": + version: 9.0.5 + resolution: "listr2@npm:9.0.5" + dependencies: + cli-truncate: "npm:^5.0.0" + colorette: "npm:^2.0.20" + eventemitter3: "npm:^5.0.1" + log-update: "npm:^6.1.0" + rfdc: "npm:^1.4.1" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/46448d1ba0addc9d71aeafd05bb8e86ded9641ccad930ac302c2bd2ad71580375604743e18586fcb8f11906edf98e8e17fca75ba0759947bf275d381f68e311d + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -3552,6 +3651,19 @@ __metadata: languageName: node linkType: hard +"log-update@npm:^6.1.0": + version: 6.1.0 + resolution: "log-update@npm:6.1.0" + dependencies: + ansi-escapes: "npm:^7.0.0" + cli-cursor: "npm:^5.0.0" + slice-ansi: "npm:^7.1.0" + strip-ansi: "npm:^7.1.0" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/4b350c0a83d7753fea34dcac6cd797d1dc9603291565de009baa4aa91c0447eab0d3815a05c8ec9ac04fdfffb43c82adcdb03ec1fceafd8518e1a8c1cff4ff89 + languageName: node + linkType: hard + "longest-streak@npm:^3.0.0": version: 3.1.0 resolution: "longest-streak@npm:3.1.0" @@ -4154,6 +4266,13 @@ __metadata: languageName: node linkType: hard +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + "minimatch@npm:^10.2.1, minimatch@npm:^10.2.2": version: 10.2.4 resolution: "minimatch@npm:10.2.4" @@ -4385,6 +4504,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: 10c0/5cb9179d74b63f52a196a2e7037ba2b9a893245a5532d3f44360012005c9cadb60851d56716ebff18a6f47129dab7168022445df47c2aff3b276d92585ed1221 + languageName: node + linkType: hard + "oniguruma-parser@npm:^0.12.1": version: 0.12.1 resolution: "oniguruma-parser@npm:0.12.1" @@ -4831,6 +4959,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 10c0/c2ba89131eea791d1b25205bdfdc86699767e2b88dee2a590b1a6caa51737deac8bad0260a5ded2f7c074b7db2f3a626bcf1fcf3cdf35974cbeea5e2e6764f60 + languageName: node + linkType: hard + "retext-latin@npm:^4.0.0": version: 4.0.0 resolution: "retext-latin@npm:4.0.0" @@ -4890,6 +5028,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.4.1": + version: 1.4.1 + resolution: "rfdc@npm:1.4.1" + checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7 + languageName: node + linkType: hard + "rollup@npm:^4.34.9": version: 4.59.0 resolution: "rollup@npm:4.59.0" @@ -5155,6 +5300,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^4.1.0": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -5176,6 +5328,26 @@ __metadata: languageName: node linkType: hard +"slice-ansi@npm:^7.1.0": + version: 7.1.2 + resolution: "slice-ansi@npm:7.1.2" + dependencies: + ansi-styles: "npm:^6.2.1" + is-fullwidth-code-point: "npm:^5.0.0" + checksum: 10c0/36742f2eb0c03e2e81a38ed14d13a64f7b732fe38c3faf96cce0599788a345011e840db35f1430ca606ea3f8db2abeb92a8d25c2753a819e3babaa10c2e289a2 + languageName: node + linkType: hard + +"slice-ansi@npm:^8.0.0": + version: 8.0.0 + resolution: "slice-ansi@npm:8.0.0" + dependencies: + ansi-styles: "npm:^6.2.3" + is-fullwidth-code-point: "npm:^5.1.0" + checksum: 10c0/0ce4aa91febb7cea4a00c2c27bb820fa53b6d2862ce0f80f7120134719f7914fc416b0ed966cf35250a3169e152916392f35917a2d7cad0fcc5d8b841010fa9a + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -5241,6 +5413,13 @@ __metadata: languageName: node linkType: hard +"string-argv@npm:^0.3.2": + version: 0.3.2 + resolution: "string-argv@npm:0.3.2" + checksum: 10c0/75c02a83759ad1722e040b86823909d9a2fc75d15dd71ec4b537c3560746e33b5f5a07f7332d1e3f88319909f82190843aa2f0a0d8c8d591ec08e93d5b8dec82 + languageName: node + linkType: hard + "string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -5263,6 +5442,16 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.2.0": + version: 8.2.0 + resolution: "string-width@npm:8.2.0" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/d8915428b43519b0f494da6590dbe4491857d8a12e40250e50fc01fbb616ffd8400a436bbe25712255ee129511fe0414c49d3b6b9627e2bc3a33dcec1d2eda02 + languageName: node + linkType: hard + "stringify-entities@npm:^4.0.0": version: 4.0.4 resolution: "stringify-entities@npm:4.0.4" @@ -5282,7 +5471,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.1.0": +"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": version: 7.2.0 resolution: "strip-ansi@npm:7.2.0" dependencies: @@ -6177,7 +6366,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.5.0": +"yaml@npm:^2.5.0, yaml@npm:^2.8.2": version: 2.8.2 resolution: "yaml@npm:2.8.2" bin: