diff --git a/.cache/pages.json b/.cache/pages.json index 753f32c6c..d8f1a2f4d 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -100,13 +100,30 @@ { "tags": [ "apis-and-gateways", + "argo-cd", + "aws", + "azure", + "backstage-idp", "build-and-deploy", "cloud-platforms", + "crossplane", + "docker", + "dotnet", + "go", + "grafana", + "helm", + "kubernetes", "observability-and-telemetry", + "openstack", "platform-engineering", + "prometheus", + "python", "reliability-and-testing", + "ruby", "system-modernization", - "systems-and-development" + "systems-and-development", + "terraform", + "typescript" ] } ] \ No newline at end of file diff --git a/COVERS.md b/COVERS.md index 9ddab9c24..16ab26b5f 100644 --- a/COVERS.md +++ b/COVERS.md @@ -1717,3 +1717,175 @@ Medical photograph of surgical instruments during a precise operation—the care 4. Technical diagram of a typical platform stack: TypeScript tooling layer at top, application services in middle, backing services (databases, queues, caches) at bottom, all running on Linux hosts. Blueprint aesthetic, dark navy background, layers rendered as translucent panels with glowing connection lines. 5. Stylized 3D render of interconnected technology logos arranged as nodes in a network: TypeScript, Linux (Tux), PostgreSQL, RabbitMQ, Kafka. Each node glowing with its brand color, connected by data flow ribbons. Dark background, vibrant but professional color palette, foundational technology aesthetic. + +## Tags for new Skills tags + +### src/content/tags/argo-cd/index.mdx + +1. An octopus with translucent tentacles wrapping around floating Kubernetes pods, each tentacle tip holding a glowing Git commit hash, dark navy background with cyan circuit-board traces, minimal flat illustration style + +2. A compass rose made from intertwined Git branches and Kubernetes wheel spokes, the cardinal points replaced by sync status icons (green checkmark, yellow spinner, red X, blue arrow), matte paper texture, vector art + +3. A mechanical clock face where the gears are Kubernetes logos and the hands are Git merge arrows, the clock reads "in sync", steampunk-meets-tech aesthetic, muted teal and copper tones + +4. Split composition: left half shows a Git repository tree with glowing branches, right half shows a Kubernetes cluster diagram with matching pods, a translucent sync wave connects them across the divide, dark background with electric blue and orange accents + +### src/content/tags/aws/index.mdx + +1. Isometric cloud city floating above a circuit-board landscape, miniature buildings shaped like AWS service icons connected by glowing orange data streams, cool blue sky background, technical illustration style + +2. Abstract network topology map with a bold orange arrow logo at the center radiating connection lines outward to geometric nodes representing compute, storage, and database services, dark navy background, flat vector art + +3. Mechanical pocket watch with cloud-shaped gears inside, each gear etched with tiny infrastructure symbols like servers and databases, warm amber and steel tones, detailed technical drawing + +4. Bird's-eye view of a shipping port with containers labeled as cloud services being loaded onto platforms by automated cranes, sunset orange and industrial grey palette, clean line art style + +### src/content/tags/azure/index.mdx + +1. Isometric crystal fortress built from translucent blue diamond shapes, each facet reflecting tiny infrastructure symbols like servers and databases, floating on a cloud layer, cool azure and white palette, technical illustration style + +2. Abstract stained glass window pattern with a central diamond motif radiating geometric panels representing identity, compute, networking, and storage services, rich blues and teals on dark background, flat vector art + +3. Mechanical blueprint of interlocking gears shaped like hexagonal tiles, each engraved with cloud service glyphs, steel blue and white technical drawing on navy background, precise engineering illustration + +4. Aerial view of a modern city grid where each building block is a different shade of blue, connected by luminous data highways forming a diamond pattern at the center, clean minimalist line art style + +### src/content/tags/backstage-idp/index.mdx + +1. Isometric control room with a central glowing dashboard screen surrounded by floating plugin cards showing catalog entries, each card connected by thin luminous wires, teal and purple palette, technical illustration style + +2. Abstract blueprint of an open book whose pages unfold into a branching tree of service tiles and documentation nodes, clean lines on dark indigo background, flat vector art with accent highlights + +3. Mechanical switchboard with labeled toggle switches and patch cables connecting service icons to a central portal display, warm copper and cool steel tones, vintage engineering diagram style + +4. Top-down view of a mosaic floor pattern where each tile represents a different developer tool icon, all converging toward a central compass rose, muted teals and greys, clean geometric line art + +### src/content/tags/crossplane/index.mdx + +1. Isometric Kubernetes wheel at center with extending bridges connecting to floating cloud provider islands (AWS, Azure, GCP), each island holding miniature infrastructure icons, cool blue and teal palette, technical illustration style + +2. Abstract circuit board pattern where traces flow from a central hub outward to modular resource blocks labeled with database, storage, and network symbols, dark background with glowing cyan pathways, flat vector art + +3. Mechanical loom weaving threads of different colors (orange, blue, green representing cloud providers) into a single unified fabric, detailed technical drawing with steel and copper tones + +4. Top-down view of a crossroads intersection where four roads converge, each road carrying stylized infrastructure symbols toward the center meeting point, clean geometric line art on slate background + +### src/content/tags/docker/index.mdx + +1. Isometric shipping dock scene with neatly stacked colorful containers on a platform, a small crane organizing them into rows, ocean-blue background fading to white, clean technical illustration style + +2. Abstract cross-section of a container showing layered horizontal strata in different blue tones, each layer labeled with tiny dependency symbols, dark navy background, flat vector art with clean edges + +3. Mechanical conveyor belt carrying sealed boxes through stages labeled build, scan, sign, and deploy, industrial steel and blue palette, detailed technical drawing style + +4. Top-down view of a warehouse floor plan with modular storage units arranged in a perfect grid, pathways connecting them to a central dispatch hub, cool blue and grey geometric line art + +### src/content/tags/dotnet/index.mdx + +1. Isometric purple crystal structure with faceted surfaces, each face etched with code symbols and curly braces, floating above a grid of microservice nodes connected by glowing lines, deep purple and white palette, technical illustration style + +2. Abstract molecular model with interconnected spheres forming the shape of a hexagonal lattice, each node containing a tiny gear or API symbol, violet and silver tones on dark background, flat vector art + +3. Mechanical typewriter with keys shaped like code blocks, a sheet of paper emerging with structured log output, purple ribbon and steel frame, detailed vintage technical drawing style + +4. Bird's-eye view of a railway switching yard where tracks branch and merge, each track carrying cargo labeled with service names, purple and grey industrial palette, clean geometric line art + +### src/content/tags/go/index.mdx + +1. Isometric scene of a small gopher character operating a control panel with levers and switches connected to floating infrastructure cubes representing services, light blue and white palette, playful technical illustration style + +2. Abstract speed lines radiating from a central compiled binary icon, surrounded by orbiting symbols for containers, APIs, and CLIs, teal and cyan on dark background, flat vector art with motion emphasis + +3. Mechanical assembly line where identical toolboxes roll off a conveyor belt, each stamped with a different infrastructure icon, clean steel and sky-blue tones, precise engineering diagram style + +4. Top-down view of a river delta splitting into clean parallel channels, each channel carrying data packets toward different infrastructure endpoints, natural blue-green palette rendered in geometric line art + +### src/content/tags/grafana/index.mdx + +1. Isometric control room with multiple floating holographic screens showing time-series graphs, heatmaps, and gauges, warm orange and amber glow against a dark background, technical illustration style + +2. Abstract sundial face with concentric rings representing metric thresholds, the shadow hand pointing to an alert zone, burnt orange and charcoal palette, flat vector art with geometric precision + +3. Mechanical gauge cluster like a vintage airplane instrument panel, each dial showing a different observability signal (latency, errors, throughput), copper and amber tones, detailed technical drawing + +4. Top-down view of a mosaic pattern made from interlocking dashboard tiles of varying sizes, each containing miniature chart patterns, warm gradient from orange to gold on dark slate, clean geometric line art + +### src/content/tags/helm/index.mdx + +1. Isometric ship's wheel mounted on a platform, each spoke ending in a floating Kubernetes resource icon (pod, service, configmap), nautical blue and white palette, technical illustration style + +2. Abstract blueprint of a package being unwrapped to reveal layered manifest sheets stacked neatly inside, each sheet showing YAML-like grid patterns, navy and silver tones on dark background, flat vector art + +3. Mechanical compass rose with an anchor at center, cardinal points labeled with deployment lifecycle stages (template, install, upgrade, rollback), brass and ocean blue tones, detailed nautical engineering drawing + +4. Top-down view of a harbor with docked vessels arranged in versioned rows, cargo cranes loading configuration crates onto each ship, cool blue-grey maritime palette, clean geometric line art + +### src/content/tags/kubernetes/index.mdx + +1. Isometric seven-spoke ship's wheel floating above a sea of hexagonal pod cells, each cell glowing with a tiny container icon, deep blue and white palette, technical illustration style + +2. Abstract orbital diagram with a central control-plane nucleus orbited by worker-node rings, each ring carrying workload particles along defined paths, navy and electric blue on dark background, flat vector art + +3. Mechanical clockwork mechanism with interlocking gears labeled schedule, reconcile, and scale, a central flywheel bearing the seven-spoke wheel emblem, steel blue and silver tones, detailed engineering drawing + +4. Bird's-eye view of a hexagonal honeycomb grid where each cell contains a different workload symbol, pathways between cells forming a network mesh, cool blue gradient on slate background, clean geometric line art + +### src/content/tags/openstack/index.mdx + +1. Isometric data center building cross-section showing stacked infrastructure layers (compute, network, storage) in red and white, tiny server racks visible inside each layer, clean technical illustration style + +2. Abstract layered cake diagram with each tier a different shade of red, connected by vertical data pipes, the top tier radiating API endpoint symbols, dark background with warm red accents, flat vector art + +3. Mechanical elevator shaft cross-section with platforms at different levels carrying infrastructure components (VMs, switches, disks), industrial red and steel grey tones, detailed engineering drawing style + +4. Top-down view of a modular floor plan with color-coded rooms for compute, networking, and storage, hallways connecting all rooms to a central identity hub, warm red and white palette, clean geometric line art + +### src/content/tags/prometheus/index.mdx + +1. Isometric torch or flame beacon on a stone pedestal, radiating concentric rings of time-series data waves outward, warm orange and ember tones against dark background, technical illustration style + +2. Abstract circular gauge with a flame icon at center, needle arcs showing metric thresholds from green through amber to red, surrounded by floating label tag chips, dark background with warm accents, flat vector art + +3. Mechanical seismograph with a pen drawing continuous waveforms on scrolling paper, each waveform in a different warm tone representing different metric types, copper and flame-orange palette, detailed technical drawing + +4. Top-down view of a radar screen with concentric range rings, blips representing scraped targets arranged in clusters, warm amber glow on dark navy background, clean geometric line art + +### src/content/tags/python/index.mdx + +1. Isometric workshop bench with two intertwined snake sculptures made of coiled cable, surrounded by miniature tools representing cloud SDKs and CLI utilities, blue and gold palette, technical illustration style + +2. Abstract double helix formed from two serpentine ribbons weaving through automation pipeline stages (script, test, deploy, monitor), blue and yellow on dark background, flat vector art + +3. Mechanical telegraph machine with Python-blue keys and golden connectors, ticker tape emerging with infrastructure command output, warm brass and blue tones, detailed vintage technical drawing + +4. Top-down view of a branching river system where two streams intertwine and split repeatedly, carrying automation task icons downstream through a landscape of infrastructure nodes, blue and gold watercolor rendered as geometric line art + +### src/content/tags/ruby/index.mdx + +1. Isometric cut gemstone with faceted surfaces, each facet reflecting miniature infrastructure code blocks, deep red and crimson palette with white highlights, technical illustration style + +2. Abstract crystalline structure with hexagonal faces arranged in a gem formation, internal light refracting into configuration management symbols, rich ruby red on dark background, flat vector art + +3. Mechanical jeweler's workbench with a magnifying loupe over a gemstone being polished, tiny automation script engravings visible on the gem surface, warm red and brass tones, detailed vintage technical drawing + +4. Top-down view of a gem mine cross-section showing layered strata, each layer containing embedded tool icons (chef's hat, puppet strings, vagrant box), deep red and earth tones, clean geometric line art + +### src/content/tags/terraform/index.mdx + +1. Isometric landscape of interlocking purple hexagonal platforms at different heights, each platform holding miniature cloud infrastructure icons, connected by bridges and pathways, violet and white palette, technical illustration style + +2. Abstract tessellation of hexagons in varying purple shades, each containing a provider logo silhouette (cloud, database, network), forming a cohesive mosaic on dark background, flat vector art + +3. Mechanical drafting table with a hexagonal grid ruler and compass, infrastructure blueprints being drawn in precise purple ink, showing plan and apply annotations, detailed technical drawing style + +4. Top-down view of a terraced hillside farm where each terrace is a hexagonal plot containing different infrastructure crops, connected by irrigation channels representing state flow, purple and lavender palette, clean geometric line art + +### src/content/tags/typescript/index.mdx + +1. Isometric shield badge with the TypeScript "TS" monogram embossed at center, surrounded by floating type definition cards showing interface and type annotations, blue and white palette, technical illustration style + +2. Abstract circuit board where traces form curly-brace and angle-bracket patterns, key junction points marked with type symbols (string, number, interface), deep blue on dark background, flat vector art with glowing traces + +3. Mechanical typesetting press with movable type blocks arranged to spell out interface definitions, each block precisely machined in steel blue metal, detailed vintage technical drawing style + +4. Top-down view of an architectural floor plan where rooms are shaped like nested type brackets, each room containing miniature infrastructure tool icons, clean blue and white palette, precise geometric line art diff --git a/_LIT_AND_CAROUSEL_REFACTOR.md b/_LIT_AND_CAROUSEL_REFACTOR.md new file mode 100644 index 000000000..df923a9eb --- /dev/null +++ b/_LIT_AND_CAROUSEL_REFACTOR.md @@ -0,0 +1,76 @@ +1. Issues with refactoring Carousel and Testimonials to LitElement +No real blockers. The migration is straightforward because: + +Light DOM is already solved. ThemePicker demonstrates the pattern: createRenderRoot() { return this }. Since all three components render their HTML via Astro templates (not Lit's render()), this is the correct approach and already in use. +LitElement is already bundled. ThemePicker pulls it in, so there's no added dependency cost. +Lifecycle maps cleanly. The manual connectedCallback → initialize() pattern in Carousel/Testimonials translates directly. The main changes are calling super.connectedCallback() / super.disconnectedCallback() and optionally using firstUpdated() instead of the manual DOMContentLoaded check. +Nanostore integration improves. Carousel and Testimonials currently call createAnimationController manually. With LitElement they could use StoreController for reactive bindings (like ThemePicker does with createThemeController), though it's not required since the animation lifecycle uses callbacks rather than direct store subscriptions. +The only thing to watch: the transition:persist directive must remain on the HTML custom element tag itself, not on an Astro component wrapper. That's already a project rule and isn't affected by LitElement vs HTMLElement. + +2. Revised recommendation + +With all three components sharing LitElement as a base, an abstract base class becomes the cleanest option. + +Proposed structure: + +```bash +src/components/scripts/embla/ + EmblaCarouselBase.ts – abstract LitElement base class + types.ts – shared types (TimerHandle, EmblaRootElement, etc.) + logging.ts – createE2ELogger(prefix) +``` + +## What goes in EmblaCarouselBase extends LitElement + +All the code that's currently duplicated (~400 lines between Carousel and Testimonials): + +Responsibility Implementation +Light DOM createRenderRoot() { return this } +Embla init/destroy initEmbla() / teardownEmbla() with options from abstract getter +Autoplay plugin Setup, pending-state queue, scheduleAutoplayReady(), flushPendingAutoplayState() +pause() / resume() public API Identical in both today +Viewport observer setupViewportObserver() / teardownViewportObserver() / syncAutoplayWithViewport() +Animation lifecycle registerAnimationLifecycle() wrapping createAnimationController +Focus-visible pause handleFocusIn() / scheduleFocusVisiblePauseSync() / syncFocusVisiblePauseState() +Prev/next buttons setupNavigationButtons() with enable/disable state updates +Dots navigation setupDotsNavigation() with an overridable method for dot active styling +E2E logging logForE2E() using a configurable prefix +data-carousel-ready / data-carousel-autoplay attributes Set/remove in base +connectedCallback / disconnectedCallback / teardown Full lifecycle orchestration + +### What subclasses configure + +Via abstract getters or overridable methods — keeping each subclass to ~50–100 lines of glue: + +| Configuration point | Carousel | Testimonials | Skills (new) | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| `abstract get emblaOptions()` | [{ loop: true, align: 'start' }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [{ loop: true, align: 'center' }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [{ loop: true, align: 'start', dragFree: true }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | +| `abstract get autoplayOptions()` | [{ delay: 4000, ... }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [{ delay: 6000, ... }](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `null` (no autoplay initially) | +| [abstract get animationId()](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'carousel'` | ['testimonials-carousel'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'skills-carousel'` | +| [abstract get scriptName()](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | ['CarouselElement'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | ['TestimonialsCarouselElement'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'SkillsCarouselElement'` | +| `abstract get logPrefix()` | `'carousel'` | ['testimonials'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | `'skills'` | +| `abstract get focusVisibleSelector()` | `'[data-carousel-slide] :focus-visible'` | `'[data-testimonials-slide] :focus-visible'` | component-specific | +| `abstract queryElements()` | Uses Carousel selectors | Uses Testimonials selectors | Uses Skills selectors | + +Then each subclass adds only its unique features: + +| Feature | Where it lives | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| Keyboard nav (ArrowLeft/Right) | Carousel subclass only | +| Status region ("Slide X of Y") | Carousel subclass only | +| Autoplay toggle button + icon swap | Testimonials subclass only | +| Viewport ID / `aria-controls` on buttons | Testimonials subclass only | +| Dot active styling ([is-active](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) vs `w-6`) | Override `applyDotActiveStyle()` / `applyDotInactiveStyle()` | + +Two reasonable paths: + +A: ThemePicker stays independent but extracts just the nav-button wiring into a small utility function in src/components/scripts/embla/navigation.ts (~30 lines). This avoids forcing ThemePicker into a base class that's 90% irrelevant to it. +B: ThemePicker extends EmblaCarouselBase with autoplay disabled and most features no-op'd. This feels forced and adds cognitive overhead for no real benefit. +I'd recommend A. The base class serves the autoplay-carousel family (Carousel, Testimonials, Skills, and any future autoplay carousel). ThemePicker shares one small utility. + +Estimated result +EmblaCarouselBase.ts: ~350–400 lines (extracted from current duplication) +Each subclass (Carousel, Testimonials, Skills): ~50–150 lines of component-specific code +navigation.ts utility for ThemePicker: ~30 lines +Net reduction: ~400+ lines eliminated across Carousel and Testimonials +Skills starts with a thin subclass instead of copy-pasting a fourth time \ No newline at end of file diff --git a/_TODO.md b/_TODO.md index 63cda78b3..d65e61196 100644 --- a/_TODO.md +++ b/_TODO.md @@ -21,6 +21,45 @@ Cons: Browser rendering can be inconsistent across different operating systems a Axe accessibility (2) - axe-core integration +## Missing E2E Component Tests + +- Code/CodeBlock +- Code/CodeTabs +- Consent/Checkbox + +## Testimonials on mobile + +We have E2E errors again testimonials slide on mobile chrome and safari. I think the problem is that we are pausing carousels when part of the carousel is outside of the viewport, and the testimonials are too large to display on mobile without being off viewport. + +`test/e2e/specs/04-components/testimonials.spec.ts`:244:3 › Testimonials Component › @ready testimonials auto-rotate changes slide index + +## Refactor of Common Code in Carousel related components + +See if there's any code in common between Carousel, Testimonials, and Themepicker. We have another one to add that uses the carousel code - for Skills. + +Issue identified with refactoring Testimonial and Carousel to Lit custom web components: + +The only thing to watch: the `transition:persist` directive must remain on the HTML custom element tag itself, not on an Astro component wrapper. That's already a project rule and isn't affected by `LitElement` vs `HTMLElement`. + +#### Selector files + +Carousel/client/selectors.ts and Testimonials/client/selectors.ts follow an identical pattern — only the CSS selectors and data-attribute names differ. The query/get function pairs are structurally identical. + +### What's genuinely different + +| Concern | Carousel | Testimonials | ThemePicker | +| -------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| Embla options | [align: 'start'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [align: 'center'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [align: 'center'](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), [containScroll](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html), no loop | +| Autoplay delay | 4000ms | 6000ms | None | +| Autoplay toggle button | ❌ | ✅ (play/pause icon swap) | ❌ | +| Keyboard nav (ArrowLeft/Right) | ✅ | ❌ | ❌ | +| Status region (`"Slide X of Y"`) | ✅ | ❌ | ❌ | +| Dot styling | [is-active](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) class | width-based (`w-3` → `w-6`) | None | +| Viewport ID / `aria-controls` | ❌ | ✅ | ❌ | +| View Transitions integration | ❌ | ❌ | ✅ (Lit + `astro:after-swap`) | +| Tooltip portal | ❌ | ❌ | ✅ | +| Base class | [HTMLElement](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [HTMLElement](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | [LitElement](vscode-file://vscode-app/usr/share/code/resources/app/out/vs/code/electron-browser/workbench/workbench.html) | + ## Youtube video for Backstage IDP hero on Home page Discuss agentic AI integrations to add to Backstage @@ -39,12 +78,6 @@ Vercel AI Gateway, maybe could use for a chatbot: https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai-gateway -## Testimonials on mobile - -We have E2E errors again testimonials slide on mobile chrome and safari. I think the problem is that we are pausing carousels when part of the carousel is outside of the viewport, and the testimonials are too large to display on mobile without being off viewport. - -`test/e2e/specs/04-components/testimonials.spec.ts`:244:3 › Testimonials Component › @ready testimonials auto-rotate changes slide index - ## Move containers to dev server from Playwright We should start the mock containers with the dev server instead of with Playwright so that they're useable in a dev environment. @@ -83,20 +116,6 @@ if (window.matchMedia) { } ``` -## Home Page Ordering - -- Hero -- About Preview ("Building the Future of Software Development") -- Featured Services -- (Todo) Turn-Key Backstage IDP Implementation -- Testimonials -- Latest Articles -- Primary CTA ("Ready to Transform Your Development Process?") with links to /contact and /services -- Newsletter Signup -- Skills/Technologies Preview (__move to after "About Preview"__) - -Maybe change the "ready..." in the hero to "get in touch..." - ## Search Box - Show article titles only in drop-down search result box, and dedupe results @@ -143,23 +162,43 @@ Need a tooltip component for consistency. List to add tooltips to: - Should header links be in the tab order? - Does the total page tab order make sense? -## Lead on Hero for Articles -There's an [example screen shot](src/components/Layout/Markdown/Lead/lead-on-hero-example.png) from CSS-Tricks showing a layout of the author avatar, author name, published date, and title overlaid on the cover image. It would save space on the page. Probably should have it above the image on mobile to avoid visibility issues. - -A scrim is an elliptical gradient from translucent black (center) to transparent black (edges), strategically placed behind white text. The scrim is probably the most subtle way of reliably overlaying text on images out there, and very few designs use this technique. - -To overlay an article title and published date on a cover image, use CSS positioning, specifically position: relative on the container and position: absolute on the text elements, combined with design techniques to ensure readability such as a semi-transparent overlay or text shadows. +## Non-featured tags + +Generate cover images for the skills and technologies tags pages: + +argo-cd +aws +azure +backstage +crossplane +docker +dotnet +go +grafana +helm +kubernetes +openstack +prometheus / promql +python +ruby +terraform +typescript ## Stylings - Add 'featured' to tags to filter, and move tags page to articles + - Need to make tables responsive on mobile -- Investigate why we're getting hyphenate breaks within words across lines, like or- ders and us- age + - Need to remove automatic abbr handling when the abbreviation is used in a header, also the abbr presentation needs improved - right now it gives a question mark pointer and long delay to appear + - When adding backticks in a callout, it gets the standard grey background for a code block in light theme. But it should get an offset of the callout color, like "info-offset". + - Inline code blocks are not wrapping. They're breaking to a new line. An example is in the "Scenario: CRD Sync Order Problem" section of argocd-sync-failures-gitops-debugging-troubleshooting#specific-failure-scenarios. + - The spacing on unordered task lists nested in an ordered list is wrong, see "Questions to Ask Before Setting Targets" in src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx + - There's too much space at the bottom of callouts, a full line of extra space in the colored background area. ## Project Stuff @@ -193,20 +232,11 @@ To overlay an article title and published date on a cover image, use CSS positio - add "cel" language to code blocks - hovering menu items should cause the dots to disappear immediately and then the animation slide to start - Update EXIF data on all AI generated JPGs -- See if there's any code in common between Carousel, Testimonials, and Themepicker. We have another one to add that uses the carousel code - for Skills. ## Header Need to improve the "squish" animation where the header reduces in size on scroll down, and returns to full size on scroll up. Maybe reduce and expand the text and search / themepicker / hamburger menu sizes in place, and then slide them horizontally. The relevant CSS classes: -src/components/Header/index.css -.header-fixed, .header-footprint, .site-header, .header-brand - -src/components/Navigation/menu.module.css -.mainNavLink - -[example](https://thenewstack.io/) - ## Reading position indicator [Add scroll bar under header](https://css-tricks.com/reading-position-indicator/) to show how far down you are on the page while reading @@ -227,9 +257,6 @@ src/components/Navigation/menu.module.css This article has different approaches to [print pagination](https://www.customjs.space/blog/html-print-pagination-footer/). One approach overlaps with PagedJS's approach. -## Downloads Collection - -Remove the `content/downloads` folder, and point the content collection to look for a `download.astro` file in the `contents/articles` folder. Exclude the `pdf.astro` file from the Articles collection. Generated PDFs go into the public directory maybe? See the note on gating below. ## Downloads / Gated Content @@ -240,3 +267,10 @@ Remove the `content/downloads` folder, and point the content collection to look - "### Geographic/Currency Mismatches" in src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx - cover.jpg for reliability-and-testing needs touch up in GIMP +- We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx +- Need an article on OpenStack + +## focus-visible + +- yellow outline on "Let's talk ->" in backstage component is rounded like the button +- yellow outlines on icon cards in skills + technologies are clipped at the top, and rounded on the play/pause button diff --git a/cover.jpg b/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/cover.jpg differ diff --git a/package-lock.json b/package-lock.json index 2b3b04980..87dac4280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@github/relative-time-element": "^5.0.0", "@glidejs/glide": "^3.7.1", "@googlemaps/extended-component-library": "^0.6.14", - "@happy-dom/global-registrator": "^20.5.0", + "@happy-dom/global-registrator": "^20.6.1", "@lighthousejs/types": "^0.0.3", "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.3.3", @@ -52,14 +52,14 @@ "@types/hast": "^3.0.4", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^27.0.0", - "@types/node": "^25.2.1", + "@types/node": "^25.2.3", "@types/nodemailer": "^7.0.9", "@types/pubsub-js": "^1.8.6", - "@types/react": "^19.2.13", + "@types/react": "^19.2.14", "@types/sanitize-html": "^2.16.0", "@types/to-ico": "1.1.3", - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", "@uppy/audio": "^3.1.0", "@uppy/box": "^4.1.0", "@uppy/core": "^5.2.0", @@ -74,7 +74,7 @@ "@vitest/coverage-v8": "^4.0.18", "@webcomponents/template-shadowroot": "^0.2.1", "alex": "^11.0.1", - "astro": "5.17.1", + "astro": "5.17.2", "astro-icon": "^1.1.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.10.1", @@ -99,23 +99,23 @@ "eslint-plugin-yml": "3.1.2", "focus-trap": "8.0.0", "gsap": "^3.14.2", - "happy-dom": "^20.5.0", + "happy-dom": "^20.6.1", "html-element-attributes": "^3.5.0", "husky": "^9.1.7", "install": "^0.13.0", "is-whitespace-character": "^2.0.1", - "isomorphic-git": "^1.36.3", + "isomorphic-git": "^1.37.0", "js-cookie": "^3.0.5", "jsdom": "^28.0.0", "libphonenumber-js": "1.12.36", - "lighthouse": "^13.0.1", + "lighthouse": "^13.0.3", "lit": "^3.3.2", "markdownlint-cli2": "^0.20.0", "md-attr-parser": "^1.3.0", "mermaid": "^11.12.2", "nanostores": "^1.1.0", "nodemailer": "^8.0.1", - "npm": "^11.9.0", + "npm": "^11.10.0", "playwright-lighthouse": "^4.0.0", "postcss": "8.5.6", "postcss-html": "1.8.1", @@ -147,7 +147,7 @@ "remark-stringify": "^11.0.0", "remark-supersub": "^1.0.0", "remark-video": "^0.10.0", - "resend": "^6.9.1", + "resend": "^6.9.2", "retext": "^9.0.0", "retext-smartypants": "^6.2.0", "rimraf": "6.1.2", @@ -156,7 +156,7 @@ "sharp": "^0.34.5", "shiki": "^3.22.0", "space-separated-tokens": "^2.0.2", - "stylelint": "^17.1.1", + "stylelint": "^17.2.0", "stylelint-config-standard": "^40.0.0", "stylelint-declaration-block-no-ignored-properties": "3.0.0", "stylelint-order": "7.0.1", @@ -167,14 +167,14 @@ "to-ico": "1.1.5", "tslib": "2.8.1", "typescript": "5.9.3", - "typescript-eslint": "8.54.0", + "typescript-eslint": "8.55.0", "unified": "^11.0.5", "unist": "^0.0.1", "unist-util-inspect": "^8.1.0", "unist-util-is": "^6.0.1", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", - "vercel": "^50.13.2", + "vercel": "^50.15.1", "vite": "^7.3.1", "vitest": "4.0.18", "vitest-axe": "0.1.0", @@ -3323,13 +3323,13 @@ } }, "node_modules/@happy-dom/global-registrator": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.5.0.tgz", - "integrity": "sha512-N40+OOBXdI7TcKfxA0/EsD1eI+Zew4gwPPC9PJljAniIbNra0OjzTC4GsO/BjIMHq+cq1ogzL4/KZQhEVLEQ7w==", + "version": "20.6.1", + "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.6.1.tgz", + "integrity": "sha512-4Aji+soqukwUxq2DgHmkjxdGnG7hEiJuprqDlW4Wu6AQ0t8U9ItlICcM5to89pulIsEGrF1CkCoNrufQTcqb8A==", "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", - "happy-dom": "^20.5.0" + "happy-dom": "^20.6.1" }, "engines": { "node": ">=20.0.0" @@ -6583,19 +6583,6 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "license": "MIT" }, - "node_modules/@selderee/plugin-htmlparser2": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", - "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "selderee": "^0.11.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/@semantic-ui/astro-lit": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@semantic-ui/astro-lit/-/astro-lit-5.1.1.tgz", @@ -8250,9 +8237,9 @@ } }, "node_modules/@types/node": { - "version": "25.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", - "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -8300,9 +8287,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", - "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -8441,16 +8428,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -8463,20 +8450,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", + "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -8493,15 +8480,15 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8525,15 +8512,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "engines": { @@ -8549,13 +8536,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "engines": { @@ -8570,13 +8557,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8587,9 +8574,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8603,9 +8590,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8616,15 +8603,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -8655,12 +8642,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -9236,12 +9223,12 @@ } }, "node_modules/@vercel/backends": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@vercel/backends/-/backends-0.0.30.tgz", - "integrity": "sha512-COWZ2EaEAu1mG9Pc9T8QKqHJzmoTs7+3ateIobZvx+e8x0bfOYF2UUiyJpyVJNwAd0spcvs6jt3eiNF6/lTuBw==", + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@vercel/backends/-/backends-0.0.32.tgz", + "integrity": "sha512-lBVGqNLILLTHQo00dUN4aBe5fGOU25dbEtRSCuZ3F6J/TkSmtwsu8VXfsGXGSGsAHAf/j5GHRKB9P3/BRnCGBA==", "license": "Apache-2.0", "dependencies": { - "@vercel/build-utils": "13.3.3", + "@vercel/build-utils": "13.3.5", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", @@ -9326,9 +9313,9 @@ } }, "node_modules/@vercel/backends/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", + "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.2", @@ -9343,9 +9330,9 @@ } }, "node_modules/@vercel/backends/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9451,21 +9438,21 @@ } }, "node_modules/@vercel/build-utils": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.3.3.tgz", - "integrity": "sha512-OwnEj1OOVXq2UMo1Mrr8/Klt5BuWTo1GCWDYkrTbcm1b8cgRx3Hp0CnHGqMx8p3WpY4Jw6NkkreQETPg9gTEHQ==", + "version": "13.3.5", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.3.5.tgz", + "integrity": "sha512-y23xjwTNgRXy8eqX8pMcU8xZjepinG4zibddCFumkia1U1WlWshC02+dUfIL0/pPegSb/Bf6aZ//8ZOkluaZNw==", "license": "Apache-2.0", "dependencies": { - "@vercel/python-analysis": "0.3.2" + "@vercel/python-analysis": "0.4.0" } }, "node_modules/@vercel/cervel": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@vercel/cervel/-/cervel-0.0.17.tgz", - "integrity": "sha512-+Y0TA6+DUA47hOpupxvpqGCB09NZvjrykxDQW1KZyqh0XQww1oYVnr9QiXeehfFhKt1QKFnUd3JzRvJIuq4LuA==", + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/@vercel/cervel/-/cervel-0.0.19.tgz", + "integrity": "sha512-votUhV6wr1SQ96NnO1AIQXwoLpQUND8x1et04Vz4+Ckj3vs1oxD2+WJmOvysk1LMLkYaabWfwtfVsfXNndi79w==", "license": "Apache-2.0", "dependencies": { - "@vercel/backends": "0.0.30" + "@vercel/backends": "0.0.32" }, "bin": { "cervel": "bin/cervel.mjs" @@ -9484,12 +9471,12 @@ } }, "node_modules/@vercel/elysia": { - "version": "0.1.32", - "resolved": "https://registry.npmjs.org/@vercel/elysia/-/elysia-0.1.32.tgz", - "integrity": "sha512-VtVSQtWFpSrZBGbx0pmuUGgKCSmeGceou4hDPH5+kXG9D6/lNSFBqvv76ikjnFFIb1kIrgI3qwU+98GTld2Ceg==", + "version": "0.1.35", + "resolved": "https://registry.npmjs.org/@vercel/elysia/-/elysia-0.1.35.tgz", + "integrity": "sha512-yZFwcRE66cNUSmGFNunAUisj4uxPdtSCuq9v9Ms5JRsX5elT2naqfFWoWFiF7IApXcNhD7aVmcuAr3/s5ZK7nA==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2" } }, @@ -9500,14 +9487,14 @@ "license": "Apache-2.0" }, "node_modules/@vercel/express": { - "version": "0.1.41", - "resolved": "https://registry.npmjs.org/@vercel/express/-/express-0.1.41.tgz", - "integrity": "sha512-Ak4QSVxgucuUbp1pCjn+GEcLPHBoIvTvbUEdv1BmkqB0Ccq1oEJLVWp+WaPy8W7QY8Ik0Nc+EkFCYz2johbTnQ==", + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/@vercel/express/-/express-0.1.44.tgz", + "integrity": "sha512-CKIGDvQg2Vqz2hWol+gEg5/ozxBdsNSnLzfD90gBawcwcFddUxoauvJ1JDvY/tFwYBvtgck1Lh+F6oFMNjZTCg==", "license": "Apache-2.0", "dependencies": { - "@vercel/cervel": "0.0.17", + "@vercel/cervel": "0.0.19", "@vercel/nft": "1.1.1", - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", @@ -9584,9 +9571,9 @@ } }, "node_modules/@vercel/express/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", + "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.2", @@ -9601,9 +9588,9 @@ } }, "node_modules/@vercel/express/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9681,12 +9668,12 @@ } }, "node_modules/@vercel/fastify": { - "version": "0.1.35", - "resolved": "https://registry.npmjs.org/@vercel/fastify/-/fastify-0.1.35.tgz", - "integrity": "sha512-TWoJ4Shw5pVryIdI/f5bqm+61pl+WASNtb4xabZdUynR7katxqQ2KbbDlkwfkMm36sfxcStHUERed0CSs4xdmg==", + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@vercel/fastify/-/fastify-0.1.38.tgz", + "integrity": "sha512-w3Kc+UBSc1/f3z9wlSR5yIP3oimUxuIc7DLZQFt9wsgv+sMoi+3CjlTeRgYGAmHE6S+bxaVs1THnvKqxGR1pHQ==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2" } }, @@ -9846,13 +9833,13 @@ } }, "node_modules/@vercel/gatsby-plugin-vercel-builder": { - "version": "2.0.132", - "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-builder/-/gatsby-plugin-vercel-builder-2.0.132.tgz", - "integrity": "sha512-9aebXsQTcUCDPIsKfjBjYZJlkTXN8ySoHOIJSbYsylckhS+hXg+O9/RhKvaQZSaCE/5NO6SSt40Or2HQukIUHA==", + "version": "2.0.134", + "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-builder/-/gatsby-plugin-vercel-builder-2.0.134.tgz", + "integrity": "sha512-+/k7bQfLK2VXG/bX/ebA8b7CSc46ODRY1cH9P422zUWjgA4lo3SHAmNdFd7xobX07zmD/XGJr6sko0nJM04Z3w==", "license": "Apache-2.0", "dependencies": { "@sinclair/typebox": "0.25.24", - "@vercel/build-utils": "13.3.3", + "@vercel/build-utils": "13.3.5", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" @@ -10336,23 +10323,23 @@ "license": "Apache-2.0" }, "node_modules/@vercel/h3": { - "version": "0.1.41", - "resolved": "https://registry.npmjs.org/@vercel/h3/-/h3-0.1.41.tgz", - "integrity": "sha512-4g+ddVZExyjIZnH4RyBv+OeWNXZOP18jcwBVRTtnhBk49hZ/tzHNOePwqf9QGRcXp5CLdlGD77w4BL4UzZIEDA==", + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/@vercel/h3/-/h3-0.1.44.tgz", + "integrity": "sha512-wgYcmKcFTCG8fFvirFEGslDVDpb3ulzmjpkf4ElP2BqTZx3b8JQJGUBCgWB94kS6F69KgUaRGfm/J2hTlkRBCA==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2" } }, "node_modules/@vercel/hono": { - "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@vercel/hono/-/hono-0.2.35.tgz", - "integrity": "sha512-DVXvRIWekfp1G+fkXSrM6KhDVwrV59b+szHYRvuKssPPcsuXg0UGVHgJESAU38sdl2pbaFDkQO4vIssz6JMsDQ==", + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@vercel/hono/-/hono-0.2.38.tgz", + "integrity": "sha512-whRiVu/lgfbpktVDYEayqU0ed4Tk58JygR+nBBtosuL1wfKvuDDRcE07qz7PoNTQ4gJ0nVO34yQ8wkjgQlIAnw==", "license": "Apache-2.0", "dependencies": { "@vercel/nft": "1.1.1", - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", @@ -10429,9 +10416,9 @@ } }, "node_modules/@vercel/hono/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", + "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.2", @@ -10446,9 +10433,9 @@ } }, "node_modules/@vercel/hono/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -10536,29 +10523,29 @@ } }, "node_modules/@vercel/koa": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/@vercel/koa/-/koa-0.1.15.tgz", - "integrity": "sha512-DtcUD7i2qL/DfgYTXQCZolZT/bZToH0sSCsOHj3hC8h+h6s900Jb76yv6fs1LuEFtdPfERGpT9TAkpKmKSRL5g==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@vercel/koa/-/koa-0.1.18.tgz", + "integrity": "sha512-X4LWt4XI9VHojGxuwZgNFJ9rx/gB+i4keEWvHLZTSkdK0aLyAhj/FktfAx0MerDbm0/KcqODrDJB6smhNXmTUQ==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2" } }, "node_modules/@vercel/nestjs": { - "version": "0.2.36", - "resolved": "https://registry.npmjs.org/@vercel/nestjs/-/nestjs-0.2.36.tgz", - "integrity": "sha512-t3yhyVB7tuGgMHD5lnJ+hPnrQ5GJZA/T6r+vnmrIh/xDHhcXWQ8t41xkSqnAPMP/t08qeNDcZzqjq58VIGD/lg==", + "version": "0.2.39", + "resolved": "https://registry.npmjs.org/@vercel/nestjs/-/nestjs-0.2.39.tgz", + "integrity": "sha512-r4JxlQ8+sWk8s+/lhlalV1db1Yu05XPshXk8qUTd768XydNrtqY/PnjPPAYENRaFprsv1u3FZDY0iE/loYpoNw==", "license": "Apache-2.0", "dependencies": { - "@vercel/node": "5.5.33", + "@vercel/node": "5.6.2", "@vercel/static-config": "3.1.2" } }, "node_modules/@vercel/next": { - "version": "4.15.27", - "resolved": "https://registry.npmjs.org/@vercel/next/-/next-4.15.27.tgz", - "integrity": "sha512-F1Fk183H7qIBA2uKCV2lJtM7lnTJwo5KEZF50n15Htzmw+w617l63kGu6hSi1G9WrL/kbJyurd6A63Ri3S0Odw==", + "version": "4.15.28", + "resolved": "https://registry.npmjs.org/@vercel/next/-/next-4.15.28.tgz", + "integrity": "sha512-pnnkGS9pXCgMsRX1OOiS/M0ePqcwPFIQH/XLZ2ecKf/peRs3IpHzmsFBPg3XAgGrvpkP0tQe23I12FT5lir9iQ==", "license": "Apache-2.0", "dependencies": { "@vercel/nft": "1.1.1" @@ -10619,9 +10606,9 @@ "license": "MIT" }, "node_modules/@vercel/next/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", + "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.2", @@ -10636,9 +10623,9 @@ } }, "node_modules/@vercel/next/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -10763,16 +10750,16 @@ } }, "node_modules/@vercel/node": { - "version": "5.5.33", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.5.33.tgz", - "integrity": "sha512-kr2V7yJQj5WwFFlp1KnlJ3VMfmIKVv5paYT7ILjtqj6A3Y2DEs0GYmstI9pdC+xJyiy4jLRxGFWEwuguNHhiQg==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.6.2.tgz", + "integrity": "sha512-FJRnokQAWNo2vpLKnY08wb7oJEFc06Dl6XCg4KltXIBf+8idFrGTA9gkJML2HrFVOvATfRSXRTK2R1XfS2+F6A==", "license": "Apache-2.0", "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", - "@vercel/build-utils": "13.3.3", + "@vercel/build-utils": "13.3.5", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", @@ -11334,9 +11321,9 @@ "license": "MIT" }, "node_modules/@vercel/node/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", + "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.1.2", @@ -11351,9 +11338,9 @@ } }, "node_modules/@vercel/node/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11463,15 +11450,18 @@ } }, "node_modules/@vercel/python": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.8.0.tgz", - "integrity": "sha512-/Hg7MlgmCleKxAnLfphxxIi1VJMELOY4xUTQmVIOUBkJep7JCKZn3dUlt1KXu/bQKwRxrplqQXj7wsCZ8ze13A==", - "license": "Apache-2.0" + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.11.1.tgz", + "integrity": "sha512-QUVZf6z6f5jyH80FETRqO7xfMdkHe/2FzC5fOmS0MxyEtuzaOO978Vh+Bvy2AImE2tBrrRKMYp+cfopTyOa8Cw==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/python-analysis": "0.4.0" + } }, "node_modules/@vercel/python-analysis": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.3.2.tgz", - "integrity": "sha512-z7W4NidF8t/LB6fz2/Ui1wNx43mH1xP1sc01ZlwQvQXuCegokmwRvDERwxcPvR8RZQ5w+6U9qtTRQKNIE9M7wQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.4.0.tgz", + "integrity": "sha512-u1t5ZpbtSVYKw4fPEB+doFLY3z898TjNZaoNOjSPUqYNTxmuSeipNgpRPxJGwOkjB1A2LZYDLSfsB2Y4gwDvxQ==", "license": "Apache-2.0", "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", @@ -11838,9 +11828,9 @@ } }, "node_modules/@vercel/ruby": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@vercel/ruby/-/ruby-2.2.5.tgz", - "integrity": "sha512-DeSHZDEU1BvxzomHeBDsFyZ9VL2U/rUC15Ce4Asaw6LadYPyQ9HEvREJC7Vz4f2iHHD0iamJG9znaC3/UgvQDg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vercel/ruby/-/ruby-2.3.0.tgz", + "integrity": "sha512-8qrx53zFq2cvTOZZ2MQ8lkxXR8AXFWduNVDWxFKQ4zZjaSZdjoUZgpTWrtsUj8FCWJBp2XZjoCj0cKJbfp7Hcg==", "license": "Apache-2.0" }, "node_modules/@vercel/rust": { @@ -11904,13 +11894,13 @@ "license": "ISC" }, "node_modules/@vercel/static-build": { - "version": "2.8.34", - "resolved": "https://registry.npmjs.org/@vercel/static-build/-/static-build-2.8.34.tgz", - "integrity": "sha512-B9Rhj/K/mKRZX+7anlPN6/4UO7vkzug1sF8gsVHl60hrWctms4F+V7YkHXx3i1I1DqTqD6SmoiP7wjztk4paiQ==", + "version": "2.8.36", + "resolved": "https://registry.npmjs.org/@vercel/static-build/-/static-build-2.8.36.tgz", + "integrity": "sha512-NKp4opoMm/QzDj9hBmcek0B75ExwDII0Kdi60a7Zs9Zeal7ktzf1/mexGA619e6ARLGA9gbSo2ZeW81uVWADzA==", "license": "Apache-2.0", "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", - "@vercel/gatsby-plugin-vercel-builder": "2.0.132", + "@vercel/gatsby-plugin-vercel-builder": "2.0.134", "@vercel/static-config": "3.1.2", "ts-morph": "12.0.0" } @@ -12295,17 +12285,6 @@ "node": ">=14.6" } }, - "node_modules/@zone-eu/mailsplit": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", - "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", - "license": "(MIT OR EUPL-1.1+)", - "dependencies": { - "libbase64": "1.3.0", - "libmime": "5.3.7", - "libqp": "2.1.1" - } - }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -14147,9 +14126,9 @@ } }, "node_modules/astro": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/astro/-/astro-5.17.1.tgz", - "integrity": "sha512-oD3tlxTaVWGq/Wfbqk6gxzVRz98xa/rYlpe+gU2jXJMSD01k6sEDL01ZlT8mVSYB/rMgnvIOfiQQ3BbLdN237A==", + "version": "5.17.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.17.2.tgz", + "integrity": "sha512-7jnMqGo53hOQNwo1N/wqeOvUp8wwW/p+DeerSjSkHNx8L/1mhy6P7rVo7EhdmF8DpKqw0tl/B5Fx1WcIzg1ysA==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^2.13.0", @@ -14175,7 +14154,7 @@ "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", @@ -14360,6 +14339,422 @@ "url": "https://github.com/sponsors/martrapp" } }, + "node_modules/astro/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/astro/node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -14442,6 +14837,47 @@ "node": ">=0.3.1" } }, + "node_modules/astro/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/astro/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -14611,6 +15047,463 @@ } } }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/astro/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -17833,15 +18726,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/encoding-japanese": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", - "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", - "license": "MIT", - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -20148,15 +21032,15 @@ "license": "MIT" }, "node_modules/happy-dom": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.5.0.tgz", - "integrity": "sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==", + "version": "20.6.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.1.tgz", + "integrity": "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==", "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", - "entities": "^4.5.0", + "entities": "^6.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" }, @@ -20164,6 +21048,18 @@ "node": ">=20.0.0" } }, + "node_modules/happy-dom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/happy-dom/node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -21032,41 +21928,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-to-text": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", - "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", - "license": "MIT", - "dependencies": { - "@selderee/plugin-htmlparser2": "^0.11.0", - "deepmerge": "^4.3.1", - "dom-serializer": "^2.0.0", - "htmlparser2": "^8.0.2", - "selderee": "^0.11.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/html-to-text/node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -22218,9 +23079,9 @@ "license": "ISC" }, "node_modules/isomorphic-git": { - "version": "1.36.3", - "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.36.3.tgz", - "integrity": "sha512-bHF1nQTjL0IfSo13BHDO8oQ6SvYNQduTAdPJdSmrJ5JwZY2fsyjLujEXav5hqPCegSCAnc75ZsBUHqT/NqR7QA==", + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.37.0.tgz", + "integrity": "sha512-XXdcywuJq8ff2tUQRCfFp+mz8dQTOZonXQ7I9WPgM2dUrhm6+oh5JM+EOrobgq6n+qN3TqgyRBkfgqFf9g7RTg==", "license": "MIT", "dependencies": { "async-lock": "^1.4.1", @@ -22858,15 +23719,6 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, - "node_modules/leac": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", - "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", - "license": "MIT", - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/legacy-javascript": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz", @@ -22895,36 +23747,12 @@ "node": ">= 0.8.0" } }, - "node_modules/libbase64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", - "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", - "license": "MIT" - }, - "node_modules/libmime": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", - "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", - "license": "MIT", - "dependencies": { - "encoding-japanese": "2.2.0", - "iconv-lite": "0.6.3", - "libbase64": "1.3.0", - "libqp": "2.1.1" - } - }, "node_modules/libphonenumber-js": { "version": "1.12.36", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", "license": "MIT" }, - "node_modules/libqp": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", - "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", - "license": "MIT" - }, "node_modules/libsql": { "version": "0.5.22", "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz", @@ -22967,9 +23795,9 @@ } }, "node_modules/lighthouse": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-13.0.1.tgz", - "integrity": "sha512-SsxFXPE0DoUv6rH3hva0luh0pbpyIx9McBQ1WUpqCYFMtArODT6l9Zpu1K3XSdkeMQ2/zFcMN5o3pPVhfVwnCA==", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-13.0.3.tgz", + "integrity": "sha512-mEHAQV3nn4fB+3FDapye+KGeeE4V8FERgbCFegKT7HxqDVGWVOM/BoH9Qof71fzVYVMLIiGnDsnWRrH0sQ9o4Q==", "license": "Apache-2.0", "dependencies": { "@paulirish/trace_engine": "0.0.61", @@ -24226,49 +25054,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/mailparser": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz", - "integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==", - "license": "MIT", - "dependencies": { - "@zone-eu/mailsplit": "5.4.8", - "encoding-japanese": "2.2.0", - "he": "1.2.0", - "html-to-text": "9.0.5", - "iconv-lite": "0.7.0", - "libmime": "5.3.7", - "linkify-it": "5.0.0", - "nodemailer": "7.0.11", - "punycode.js": "2.3.1", - "tlds": "1.261.0" - } - }, - "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mailparser/node_modules/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -27499,9 +28284,9 @@ } }, "node_modules/npm": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.9.0.tgz", - "integrity": "sha512-BBZoU926FCypj4b7V7ElinxsWcy4Kss88UG3ejFYmKyq7Uc5XnT34Me2nEhgCOaL5qY4HvGu5aI92C4OYd7NaA==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.10.0.tgz", + "integrity": "sha512-i8hE43iSIAMFuYVi8TxsEISdELM4fIza600aLjJ0ankGPLqd0oTPKMJqAcO/QWm307MbSlWGzJcNZ0lGMQgHPA==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -27580,8 +28365,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.2.0", - "@npmcli/config": "^10.6.0", + "@npmcli/arborist": "^9.3.0", + "@npmcli/config": "^10.7.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -27598,19 +28383,19 @@ "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^13.0.0", + "glob": "^13.0.2", "graceful-fs": "^4.2.11", "hosted-git-info": "^9.0.2", "ini": "^6.0.0", "init-package-json": "^8.2.4", - "is-cidr": "^6.0.1", + "is-cidr": "^6.0.3", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.0", - "libnpmexec": "^10.2.0", - "libnpmfund": "^7.0.14", + "libnpmdiff": "^8.1.1", + "libnpmexec": "^10.2.1", + "libnpmfund": "^7.0.15", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.0", + "libnpmpack": "^9.1.1", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -27630,21 +28415,21 @@ "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.1.0", + "pacote": "^21.3.1", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.7.3", + "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", - "ssri": "^13.0.0", + "ssri": "^13.0.1", "supports-color": "^10.2.2", "tar": "^7.5.7", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", "validate-npm-package-name": "^7.0.2", - "which": "^6.0.0" + "which": "^6.0.1" }, "bin": { "npm": "bin/npm-cli.js", @@ -27726,7 +28511,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.2.0", + "version": "9.3.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -27772,7 +28557,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.6.0", + "version": "10.7.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -28144,7 +28929,7 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.1", + "version": "5.0.2", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -28269,7 +29054,7 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "13.0.1", + "version": "13.0.2", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -28405,7 +29190,7 @@ } }, "node_modules/npm/node_modules/is-cidr": { - "version": "6.0.2", + "version": "6.0.3", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -28424,11 +29209,11 @@ } }, "node_modules/npm/node_modules/isexe": { - "version": "3.1.1", + "version": "4.0.0", "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/npm/node_modules/json-parse-even-better-errors": { @@ -28478,11 +29263,11 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.0", + "version": "8.1.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.2.0", + "@npmcli/arborist": "^9.3.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -28496,11 +29281,11 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.0", + "version": "10.2.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.2.0", + "@npmcli/arborist": "^9.3.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -28518,11 +29303,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.14", + "version": "7.0.15", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.2.0" + "@npmcli/arborist": "^9.3.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -28541,11 +29326,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.0", + "version": "9.1.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.2.0", + "@npmcli/arborist": "^9.3.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -28611,7 +29396,7 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.2.5", + "version": "11.2.6", "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -28940,7 +29725,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.1.0", + "version": "21.3.1", "inBundle": true, "license": "ISC", "dependencies": { @@ -29105,7 +29890,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.7.3", + "version": "7.7.4", "inBundle": true, "license": "ISC", "bin": { @@ -29215,7 +30000,7 @@ "license": "CC0-1.0" }, "node_modules/npm/node_modules/ssri": { - "version": "13.0.0", + "version": "13.0.1", "inBundle": true, "license": "ISC", "dependencies": { @@ -29418,11 +30203,11 @@ } }, "node_modules/npm/node_modules/which": { - "version": "6.0.0", + "version": "6.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" @@ -30326,19 +31111,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/parseley": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", - "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", - "license": "MIT", - "dependencies": { - "leac": "^0.6.0", - "peberminta": "^0.9.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -30437,15 +31209,6 @@ "through": "~2.3" } }, - "node_modules/peberminta": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", - "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", - "license": "MIT", - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -30720,6 +31483,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -33497,12 +34266,12 @@ } }, "node_modules/resend": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.1.tgz", - "integrity": "sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA==", + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", "license": "MIT", "dependencies": { - "mailparser": "3.9.1", + "postal-mime": "2.7.3", "svix": "1.84.1" }, "engines": { @@ -34474,18 +35243,6 @@ "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", "license": "Apache-2.0" }, - "node_modules/selderee": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", - "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", - "license": "MIT", - "dependencies": { - "parseley": "^0.12.0" - }, - "funding": { - "url": "https://ko-fi.com/killymxi" - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -35809,9 +36566,9 @@ } }, "node_modules/stylelint": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.1.1.tgz", - "integrity": "sha512-SBHVcLEcRF1M9OkD3oT0hT2PayDNLw2hd+aovmzfNQ2ys4Xd3oS9ZNizILWqhQvW802AqKN/vUTMwJqQYMBlWw==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.2.0.tgz", + "integrity": "sha512-602jhMkRt6P1dSh9kEzbFIaOKY//h4D0E7u/w2WHKxmi5VAjjMqe6P8rQPJuCWdbB3apOkjOFN5kcg6qWPIZWQ==", "funding": [ { "type": "opencollective", @@ -35824,8 +36581,9 @@ ], "license": "MIT", "dependencies": { + "@csstools/css-calc": "^3.0.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.0.25", + "@csstools/css-syntax-patches-for-csstree": "^1.0.26", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -36694,15 +37452,6 @@ "integrity": "sha512-I/nkcBo73mO42Idfv08jhInV61IMb61OdIFxk+B4Gu1oBjWBPOLmhZdsli+oJCVaD+86pYQA93cJfFt224ZFAA==", "license": "MIT" }, - "node_modules/tlds": { - "version": "1.261.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", - "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", - "license": "MIT", - "bin": { - "tlds": "bin.js" - } - }, "node_modules/tldts": { "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", @@ -37684,15 +38433,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -37707,15 +38456,15 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -38903,33 +39652,33 @@ } }, "node_modules/vercel": { - "version": "50.13.2", - "resolved": "https://registry.npmjs.org/vercel/-/vercel-50.13.2.tgz", - "integrity": "sha512-GHcXMNByoVo+dCXof81uSn8Zk2LAPqGl9gBUt5mNII4nBEyWhKu3077yrUVHwYC1xKO3VBqVW3HMka07vnTZrQ==", + "version": "50.15.1", + "resolved": "https://registry.npmjs.org/vercel/-/vercel-50.15.1.tgz", + "integrity": "sha512-selhG+F7MEpt2XAKEKUVbSws0c9Cw6YAij6J92XXr1g6d8qLi4xjhE5z/5IdUMiVsX04BQUhzuHx0gSl5mXTIA==", "license": "Apache-2.0", "dependencies": { - "@vercel/backends": "0.0.30", + "@vercel/backends": "0.0.32", "@vercel/blob": "1.0.2", - "@vercel/build-utils": "13.3.3", + "@vercel/build-utils": "13.3.5", "@vercel/detect-agent": "1.1.0", - "@vercel/elysia": "0.1.32", - "@vercel/express": "0.1.41", - "@vercel/fastify": "0.1.35", + "@vercel/elysia": "0.1.35", + "@vercel/express": "0.1.44", + "@vercel/fastify": "0.1.38", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.0", - "@vercel/h3": "0.1.41", - "@vercel/hono": "0.2.35", + "@vercel/h3": "0.1.44", + "@vercel/hono": "0.2.38", "@vercel/hydrogen": "1.3.5", - "@vercel/koa": "0.1.15", - "@vercel/nestjs": "0.2.36", - "@vercel/next": "4.15.27", - "@vercel/node": "5.5.33", - "@vercel/python": "6.8.0", + "@vercel/koa": "0.1.18", + "@vercel/nestjs": "0.2.39", + "@vercel/next": "4.15.28", + "@vercel/node": "5.6.2", + "@vercel/python": "6.11.1", "@vercel/redwood": "2.4.9", "@vercel/remix-builder": "5.5.10", - "@vercel/ruby": "2.2.5", + "@vercel/ruby": "2.3.0", "@vercel/rust": "1.0.5", - "@vercel/static-build": "2.8.34", + "@vercel/static-build": "2.8.36", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", diff --git a/package.json b/package.json index 64dfbb624..79d60d238 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@github/relative-time-element": "^5.0.0", "@glidejs/glide": "^3.7.1", "@googlemaps/extended-component-library": "^0.6.14", - "@happy-dom/global-registrator": "^20.5.0", + "@happy-dom/global-registrator": "^20.6.1", "@lighthousejs/types": "^0.0.3", "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.3.3", @@ -106,14 +106,14 @@ "@types/hast": "^3.0.4", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^27.0.0", - "@types/node": "^25.2.1", + "@types/node": "^25.2.3", "@types/nodemailer": "^7.0.9", "@types/pubsub-js": "^1.8.6", - "@types/react": "^19.2.13", + "@types/react": "^19.2.14", "@types/sanitize-html": "^2.16.0", "@types/to-ico": "1.1.3", - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", "@uppy/audio": "^3.1.0", "@uppy/box": "^4.1.0", "@uppy/core": "^5.2.0", @@ -128,7 +128,7 @@ "@vitest/coverage-v8": "^4.0.18", "@webcomponents/template-shadowroot": "^0.2.1", "alex": "^11.0.1", - "astro": "5.17.1", + "astro": "5.17.2", "astro-icon": "^1.1.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.10.1", @@ -153,23 +153,23 @@ "eslint-plugin-yml": "3.1.2", "focus-trap": "8.0.0", "gsap": "^3.14.2", - "happy-dom": "^20.5.0", + "happy-dom": "^20.6.1", "html-element-attributes": "^3.5.0", "husky": "^9.1.7", "install": "^0.13.0", "is-whitespace-character": "^2.0.1", - "isomorphic-git": "^1.36.3", + "isomorphic-git": "^1.37.0", "js-cookie": "^3.0.5", "jsdom": "^28.0.0", "libphonenumber-js": "1.12.36", - "lighthouse": "^13.0.1", + "lighthouse": "^13.0.3", "lit": "^3.3.2", "markdownlint-cli2": "^0.20.0", "md-attr-parser": "^1.3.0", "mermaid": "^11.12.2", "nanostores": "^1.1.0", "nodemailer": "^8.0.1", - "npm": "^11.9.0", + "npm": "^11.10.0", "playwright-lighthouse": "^4.0.0", "postcss": "8.5.6", "postcss-html": "1.8.1", @@ -201,7 +201,7 @@ "remark-stringify": "^11.0.0", "remark-supersub": "^1.0.0", "remark-video": "^0.10.0", - "resend": "^6.9.1", + "resend": "^6.9.2", "retext": "^9.0.0", "retext-smartypants": "^6.2.0", "rimraf": "6.1.2", @@ -210,7 +210,7 @@ "sharp": "^0.34.5", "shiki": "^3.22.0", "space-separated-tokens": "^2.0.2", - "stylelint": "^17.1.1", + "stylelint": "^17.2.0", "stylelint-config-standard": "^40.0.0", "stylelint-declaration-block-no-ignored-properties": "3.0.0", "stylelint-order": "7.0.1", @@ -221,14 +221,14 @@ "to-ico": "1.1.5", "tslib": "2.8.1", "typescript": "5.9.3", - "typescript-eslint": "8.54.0", + "typescript-eslint": "8.55.0", "unified": "^11.0.5", "unist": "^0.0.1", "unist-util-inspect": "^8.1.0", "unist-util-is": "^6.0.1", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", - "vercel": "^50.13.2", + "vercel": "^50.15.1", "vite": "^7.3.1", "vitest": "4.0.18", "vitest-axe": "0.1.0", diff --git a/src/components/Animations/Computers/client/__tests__/index.spec.ts b/src/components/Animations/Computers/client/__tests__/index.spec.ts deleted file mode 100644 index 7a2d10973..000000000 --- a/src/components/Animations/Computers/client/__tests__/index.spec.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { experimental_AstroContainer as AstroContainer } from 'astro/container' -import { TestError } from '@test/errors' -import ComputersAnimationAstro from '@components/Animations/Computers/index.astro' -import type { ComputersAnimationElement } from '@components/Animations/Computers/client' -import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' -import type { - AnimationControllerConfig, - AnimationControllerHandle, -} from '@components/scripts/store' -import { executeRender, withJsdomEnvironment } from '@test/unit/helpers/litRuntime' -import { gsap } from 'gsap' - -type ComputersAnimationModule = WebComponentModule -type ComputersClientModule = typeof import('@components/Animations/Computers/client') - -let computersAnimationElementCtor: ComputersClientModule['ComputersAnimationElement'] | undefined -let registerComputersAnimationWebComponentFn: - | ComputersClientModule['registerComputersAnimationWebComponent'] - | undefined -let computersWebComponentModule: ComputersClientModule['webComponentModule'] | undefined - -const ensureComputersModuleLoaded = async (): Promise => { - if ( - computersAnimationElementCtor && - registerComputersAnimationWebComponentFn && - computersWebComponentModule - ) { - return - } - - await withJsdomEnvironment(async () => { - const module = await import('@components/Animations/Computers/client') - computersAnimationElementCtor = module.ComputersAnimationElement - registerComputersAnimationWebComponentFn = module.registerComputersAnimationWebComponent - computersWebComponentModule = module.webComponentModule - }) -} - -const getComputersModule = () => { - if ( - !computersAnimationElementCtor || - !registerComputersAnimationWebComponentFn || - !computersWebComponentModule - ) { - throw new TestError('Computers animation module was not initialized correctly') - } - - return { - ComputersAnimationElement: computersAnimationElementCtor, - registerComputersAnimationWebComponent: registerComputersAnimationWebComponentFn, - webComponentModule: computersWebComponentModule, - } -} - -const addScriptBreadcrumbMock = vi.hoisted(() => vi.fn()) -const handleScriptErrorMock = vi.hoisted(() => vi.fn()) -const createAnimationControllerMock = vi.hoisted(() => - vi.fn( - (_config: AnimationControllerConfig): AnimationControllerHandle => ({ - requestPlay: vi.fn(), - requestPause: vi.fn(), - setInstancePauseState: vi.fn(), - clearUserPreference: vi.fn(), - destroy: vi.fn(), - }) - ) -) - -vi.mock('@components/scripts/errors', () => ({ - addScriptBreadcrumb: addScriptBreadcrumbMock, -})) - -vi.mock('@components/scripts/errors/handler', () => ({ - handleScriptError: handleScriptErrorMock, -})) - -vi.mock('@components/scripts/store', () => ({ - createAnimationController: createAnimationControllerMock, -})) - -vi.mock('gsap', () => ({ - gsap: { - set: vi.fn(), - timeline: vi.fn(), - }, -})) - -type TimelineMock = ReturnType - -const gsapMock = vi.mocked(gsap, true) -let container: AstroContainer -let timelineMock: TimelineMock -let intersectionObserverCallback: IntersectionObserverCallback | undefined - -class IntersectionObserverMock { - observe = vi.fn() - unobserve = vi.fn() - disconnect = vi.fn() - - constructor(callback: IntersectionObserverCallback) { - intersectionObserverCallback = callback - } -} - -beforeAll(async () => { - await ensureComputersModuleLoaded() -}) - -beforeEach(async () => { - container = await AstroContainer.create() - vi.clearAllMocks() - timelineMock = createTimelineMock() - gsapMock.timeline.mockImplementation( - () => timelineMock as unknown as ReturnType - ) - intersectionObserverCallback = undefined - ;( - globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } - ).IntersectionObserver = IntersectionObserverMock as unknown as typeof IntersectionObserver -}) - -describe('ComputersAnimation web component module', () => { - it('exposes metadata required by the loader', () => { - const { - webComponentModule: module, - ComputersAnimationElement: ctor, - registerComputersAnimationWebComponent, - } = getComputersModule() - - expect(module.registeredName).toBe('computers-animation') - expect(module.componentCtor).toBe(ctor) - expect(module.registerWebComponent).toBe(registerComputersAnimationWebComponent) - }) - - it('registers the custom element when window is available', async () => { - const { registerComputersAnimationWebComponent, ComputersAnimationElement: ctor } = - getComputersModule() - - await withJsdomEnvironment(async ({ window }) => { - const tagName = 'computers-animation' - registerComputersAnimationWebComponent(tagName) - - expect(window.customElements.get(tagName)).toBe(ctor) - }) - }) - - it('skips registration when window is unavailable', async () => { - const { registerComputersAnimationWebComponent } = getComputersModule() - - await withJsdomEnvironment(async ({ window }) => { - const globalRef = globalThis as { window?: Window } - const originalWindow = globalRef.window - const expectedTagName = generateUniqueTagName() - delete globalRef.window - - try { - registerComputersAnimationWebComponent(expectedTagName) - } finally { - if (originalWindow) { - globalRef.window = originalWindow - } else { - delete globalRef.window - } - } - - expect(window.customElements.get(expectedTagName)).toBeUndefined() - }) - }) -}) - -describe('ComputersAnimationElement', () => { - it('initializes the GSAP timeline when hero markup exists', async () => { - await renderComputersAnimation(async ({ element }) => { - element.initialize() - - const svg = element.querySelector('svg#heroAnimation') - expect(svg).toBeTruthy() - expect(svg?.getAttribute('aria-hidden')).toBe('true') - expect(svg?.getAttribute('focusable')).toBe('false') - - expect(gsapMock.timeline).toHaveBeenCalledTimes(1) - expect(timelineMock.timeScale).toHaveBeenCalledWith(3) - expect(gsapMock.set).toHaveBeenCalledWith( - '.monitorBottom', - expect.objectContaining({ transformOrigin: '50% 100%' }) - ) - expect(createAnimationControllerMock).toHaveBeenCalledWith( - expect.objectContaining({ - animationId: 'computers-animation', - debugLabel: 'ComputersAnimationElement', - onPause: expect.any(Function), - onPlay: expect.any(Function), - }) - ) - expect(getBreadcrumbOperations()).toEqual( - expect.arrayContaining(['initialize', 'startAnimation']) - ) - }) - }) - - it('pauses and resumes when lifecycle controller callbacks run', async () => { - await renderComputersAnimation(async ({ element }) => { - element.initialize() - - const controllerArgs = createAnimationControllerMock.mock.calls[0]?.[0] - const pauseHandler = controllerArgs?.onPause - const resumeHandler = controllerArgs?.onPlay - const toggleButton = element.querySelector('[data-animation-toggle]') - - pauseHandler?.() - expect(element.getAttribute('data-animation-state')).toBe('paused') - expect(toggleButton?.getAttribute('aria-pressed')).toBe('true') - expect(toggleButton?.getAttribute('aria-label')).toBe('Play animation') - resumeHandler?.() - - expect(timelineMock.pause).toHaveBeenCalled() - expect(timelineMock.play).toHaveBeenCalled() - expect(element.getAttribute('data-animation-state')).toBe('playing') - expect(toggleButton?.getAttribute('aria-pressed')).toBe('false') - expect(toggleButton?.getAttribute('aria-label')).toBe('Pause animation') - expect(getBreadcrumbOperations()).toEqual(expect.arrayContaining(['pause', 'resume'])) - }) - }) - - it('tears down resources on disconnect', async () => { - await renderComputersAnimation(async ({ element }) => { - element.initialize() - element.disconnectedCallback() - - const controllerHandle = getLastControllerHandle() - expect(controllerHandle?.destroy).toHaveBeenCalled() - expect(timelineMock.kill).toHaveBeenCalled() - expect(getBreadcrumbOperations()).toContain('teardown') - }) - }) - - it('is idempotent when initialized multiple times', async () => { - await renderComputersAnimation(async ({ element }) => { - element.initialize() - element.initialize() - - expect(gsapMock.timeline).toHaveBeenCalledTimes(1) - }) - }) - - it('does not start animation when the hero markup is missing', async () => { - await renderComputersAnimation(async ({ element, window }) => { - window.document.getElementById('heroAnimation')?.remove() - - gsapMock.timeline.mockClear() - gsapMock.set.mockClear() - - element.initialize() - - expect(gsapMock.timeline).not.toHaveBeenCalled() - expect(gsapMock.set).not.toHaveBeenCalled() - }) - }) - - it('does not start the animation when it is hidden on the current viewport', async () => { - await renderComputersAnimation(async ({ element, window }) => { - const originalGetComputedStyle = window.getComputedStyle - - // The element auto-initializes via connectedCallback during rendering. - // Tear it down so we can re-initialize with a mocked display:none state. - element.disconnectedCallback() - - gsapMock.timeline.mockClear() - gsapMock.set.mockClear() - createAnimationControllerMock.mockClear() - - const getComputedStyleSpy = vi - .spyOn(window, 'getComputedStyle') - .mockReturnValue({ display: 'none' } as unknown as CSSStyleDeclaration) - - try { - element.initialize() - - expect(gsapMock.timeline).not.toHaveBeenCalled() - expect(createAnimationControllerMock).not.toHaveBeenCalled() - expect(element.getAttribute('data-animation-state')).toBe('paused') - } finally { - getComputedStyleSpy.mockRestore() - window.getComputedStyle = originalGetComputedStyle - } - }) - }) - - it('delegates pause and resume helpers to the GSAP timeline', async () => { - await renderComputersAnimation(async ({ element }) => { - element.initialize() - - timelineMock.pause.mockClear() - timelineMock.play.mockClear() - - element.pause() - expect(element.getAttribute('data-animation-state')).toBe('paused') - element.resume() - - expect(timelineMock.pause).toHaveBeenCalledTimes(1) - expect(timelineMock.play).toHaveBeenCalledTimes(1) - expect(element.getAttribute('data-animation-state')).toBe('playing') - }) - }) - - it('pauses when the element is not visible and resumes when it returns', async () => { - await renderComputersAnimation(async ({ element, window }) => { - void window - element.initialize() - - expect(intersectionObserverCallback).toBeTypeOf('function') - timelineMock.pause.mockClear() - timelineMock.play.mockClear() - - intersectionObserverCallback?.( - [ - { - isIntersecting: false, - intersectionRatio: 0, - } as unknown as IntersectionObserverEntry, - ], - {} as IntersectionObserver - ) - - expect(timelineMock.pause).toHaveBeenCalledTimes(1) - expect(element.getAttribute('data-animation-state')).toBe('playing') - - intersectionObserverCallback?.( - [ - { - isIntersecting: true, - intersectionRatio: 1, - } as unknown as IntersectionObserverEntry, - ], - {} as IntersectionObserver - ) - - expect(timelineMock.play).toHaveBeenCalledTimes(1) - }) - }) - - it('pauses when the page is hidden and resumes when visible again', async () => { - await renderComputersAnimation(async ({ element, window }) => { - element.initialize() - - timelineMock.pause.mockClear() - timelineMock.play.mockClear() - - Object.defineProperty(window.document, 'visibilityState', { - configurable: true, - get: () => 'hidden', - }) - - window.document.dispatchEvent(new window.Event('visibilitychange')) - expect(timelineMock.pause).toHaveBeenCalledTimes(1) - - Object.defineProperty(window.document, 'visibilityState', { - configurable: true, - get: () => 'visible', - }) - - window.document.dispatchEvent(new window.Event('visibilitychange')) - expect(timelineMock.play).toHaveBeenCalledTimes(1) - }) - }) - - it('requests pause and play through the animation controller when the toggle is clicked', async () => { - await renderComputersAnimation(async ({ element }) => { - element.initialize() - - const toggleButton = element.querySelector('[data-animation-toggle]') - const controllerHandle = getLastControllerHandle() - - expect(toggleButton).toBeTruthy() - - toggleButton?.click() - expect(controllerHandle?.requestPause).toHaveBeenCalledTimes(1) - - element.pause() - - toggleButton?.click() - expect(controllerHandle?.requestPlay).toHaveBeenCalledTimes(1) - }) - }) - - it('reports GSAP failures through the script error handler', async () => { - const error = new TestError('gsap failed') - gsapMock.set.mockImplementationOnce(() => { - throw error - }) - - await renderComputersAnimation(async ({ element }) => { - element.initialize() - - expect(handleScriptErrorMock).toHaveBeenCalledWith( - error, - expect.objectContaining({ - operation: 'startAnimation', - scriptName: 'ComputersAnimationElement', - }) - ) - }) - }) -}) - -interface RenderComputersAnimationContext { - element: ComputersAnimationElement - window: Window & typeof globalThis - module: ComputersAnimationModule -} - -type RenderComputersAnimationAssertion = ( - _context: RenderComputersAnimationContext -) => Promise | void - -const renderComputersAnimation = async ( - assertion: RenderComputersAnimationAssertion -): Promise => { - await executeRender({ - container, - component: ComputersAnimationAstro, - moduleSpecifier: '@components/Animations/Computers/client/index', - selector: 'computers-animation', - assert: async ({ element, module, window, renderResult }) => { - if (!window) { - throw new TestError('Computers animation tests require a DOM-capable window environment') - } - expect(renderResult).toContain(`<${module.registeredName}`) - await assertion({ element, window: window as Window & typeof globalThis, module }) - }, - }) -} - -const getBreadcrumbOperations = (): string[] => { - return addScriptBreadcrumbMock.mock.calls - .map(([context]) => context?.operation) - .filter((operation): operation is string => Boolean(operation)) -} - -function createTimelineMock() { - const timeline = { - timeScale: vi.fn(), - from: vi.fn(), - to: vi.fn(), - set: vi.fn(), - kill: vi.fn(), - pause: vi.fn(), - play: vi.fn(), - } - - timeline.timeScale.mockReturnValue(timeline) - timeline.from.mockReturnValue(timeline) - timeline.to.mockReturnValue(timeline) - timeline.set.mockReturnValue(timeline) - - return timeline -} - -const generateUniqueTagName = (): string => - `computers-animation-${Math.random().toString(36).slice(2)}` - -const getLastControllerHandle = (): AnimationControllerHandle | undefined => - createAnimationControllerMock.mock.results.at(-1)?.value diff --git a/src/components/Animations/Computers/index.astro b/src/components/Animations/Computers/index.astro deleted file mode 100644 index 36bb4a6b2..000000000 --- a/src/components/Animations/Computers/index.astro +++ /dev/null @@ -1,254 +0,0 @@ ---- -/** - * Animation of laptop, table, and desktop for home page Hero - */ -import Icon from '@components/Icon/index.astro' ---- - - - - diff --git a/src/components/Animations/Confetti/index.astro b/src/components/Animations/Confetti/index.astro index c61ce0f95..d351cc48b 100644 --- a/src/components/Animations/Confetti/index.astro +++ b/src/components/Animations/Confetti/index.astro @@ -6,8 +6,12 @@ */ --- - - + + diff --git a/src/components/Animations/Hero/index.astro b/src/components/Animations/Hero/index.astro deleted file mode 100644 index 51eb00e7a..000000000 --- a/src/components/Animations/Hero/index.astro +++ /dev/null @@ -1,81 +0,0 @@ ---- -import ComputersAnimation from '@components/Animations/Computers/index.astro' -import Button from '@components/Button/index.astro' - -export interface Props { - pretitle: string - benefits: string[] -} - -const { pretitle, benefits } = Astro.props as Props ---- - - -
-
- - -
-
$ whoami
- -

- Senior
- Platform
- Authority. -

- -

{pretitle}

- -
-
// What I deliver
-
    - {benefits.map(benefit => ( -
  • - - {benefit} -
  • - ))} - -
  • - $ - - - - -
  • -
-
- - -
-
-
-
- - diff --git a/src/components/Animations/Computers/client/index.ts b/src/components/Animations/Terraform/client/index.ts similarity index 54% rename from src/components/Animations/Computers/client/index.ts rename to src/components/Animations/Terraform/client/index.ts index 2d368d961..4e6538c23 100644 --- a/src/components/Animations/Computers/client/index.ts +++ b/src/components/Animations/Terraform/client/index.ts @@ -14,37 +14,18 @@ import { queryAnimationToggleButton, queryAnimationTogglePauseIcon, queryAnimationTogglePlayIcon, + queryEdgePath, } from './selectors' -const SCRIPT_NAME = 'ComputersAnimationElement' -const COMPONENT_TAG_NAME = 'computers-animation' +const SCRIPT_NAME = 'TerraformAnimationElement' +const COMPONENT_TAG_NAME = 'terraform-animation' -type Timeline = ReturnType +/** Adjust this single value to speed up (>1) or slow down (<1) the entire animation. */ +const TIMESCALE = 0.7 -const cnst = () => { - const tenPercentBounce = 1.70158 - return tenPercentBounce * 1.525 -} +type Timeline = ReturnType -const Anticipate = { - out(p: number) { - return (p *= 2) < 1 - ? 0.5 * p * p * ((cnst() + 1) * p - cnst()) - : 0.5 * (2 - Math.pow(2, -10 * (p - 1))) - }, - in(p: number) { - return (p *= 2) < 1 - ? 0.5 * (Math.pow(2, 10 * (p - 1)) - 0.001) - : 0.5 * ((p -= 2) * p * ((cnst() + 1) * p + cnst()) + 2) - }, - inOut(p: number) { - return (p *= 2) < 1 - ? 0.5 * p * p * ((cnst() + 1) * p - cnst()) - : 0.5 * ((p -= 2) * p * ((cnst() + 1) * p + cnst()) + 2) - }, -} as const - -export class ComputersAnimationElement extends LitElement { +export class TerraformAnimationElement extends LitElement { private timeline: Timeline | null = null private initialized = false private animationReady = false @@ -133,9 +114,7 @@ export class ComputersAnimationElement extends LitElement { const tick = () => { this.deferredInitRafId = null - if (this.animationReady) { - return - } + if (this.animationReady) return if (!this.isHiddenOnThisViewport()) { this.setupAnimationAndController() @@ -143,9 +122,7 @@ export class ComputersAnimationElement extends LitElement { } this.deferredInitFrames += 1 - if (this.deferredInitFrames >= 60) { - return - } + if (this.deferredInitFrames >= 60) return this.deferredInitRafId = window.requestAnimationFrame(tick) } @@ -161,14 +138,13 @@ export class ComputersAnimationElement extends LitElement { if (!this.timeline) return // Start from a known baseline and let the global lifecycle store decide what happens next. - // The store may immediately pause (reduced motion / persisted preference / overlays). this.timeline.play(0) this.setAnimationState('playing') this.setupVisibilityHandlers() this.animationController = createAnimationController({ - animationId: 'computers-animation', + animationId: 'terraform-animation', debugLabel: SCRIPT_NAME, defaultState: 'playing', initialState: 'playing', @@ -187,14 +163,8 @@ export class ComputersAnimationElement extends LitElement { private isHiddenOnThisViewport(): boolean { try { - if (typeof window === 'undefined') { - return false - } - - if (typeof window.getComputedStyle !== 'function') { - return false - } - + if (typeof window === 'undefined') return false + if (typeof window.getComputedStyle !== 'function') return false return window.getComputedStyle(this).display === 'none' } catch { return false @@ -236,15 +206,12 @@ export class ComputersAnimationElement extends LitElement { globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver - if (typeof IntersectionObserverCtor !== 'function') { - return - } + if (typeof IntersectionObserverCtor !== 'function') return this.intersectionObserver = new IntersectionObserverCtor( entries => { const entry = entries[0] // Auto-play as long as *any* part of the animation is visible. - // This avoids the hero animation being paused on load due to minor clipping. this.isInViewport = Boolean(entry?.isIntersecting) this.syncPlaybackWithVisibility() }, @@ -334,11 +301,11 @@ export class ComputersAnimationElement extends LitElement { ) this.toggleButton.dataset['animationState'] = state - const pauseIcon = queryAnimationTogglePauseIcon(this.toggleButton) - const playIcon = queryAnimationTogglePlayIcon(this.toggleButton) + const pauseIcon = queryAnimationTogglePauseIcon(this.toggleButton) as HTMLElement | null + const playIcon = queryAnimationTogglePlayIcon(this.toggleButton) as HTMLElement | null - pauseIcon?.classList.toggle('hidden', state === 'paused') - playIcon?.classList.toggle('hidden', state === 'playing') + if (pauseIcon) pauseIcon.style.display = state === 'paused' ? 'none' : 'flex' + if (playIcon) playIcon.style.display = state === 'playing' ? 'none' : 'flex' } private resetToggleButton(): void { @@ -348,11 +315,11 @@ export class ComputersAnimationElement extends LitElement { this.toggleButton.setAttribute('aria-label', 'Pause animation') this.toggleButton.setAttribute('aria-pressed', 'false') - const pauseIcon = queryAnimationTogglePauseIcon(this.toggleButton) - const playIcon = queryAnimationTogglePlayIcon(this.toggleButton) + const pauseIcon = queryAnimationTogglePauseIcon(this.toggleButton) as HTMLElement | null + const playIcon = queryAnimationTogglePlayIcon(this.toggleButton) as HTMLElement | null - pauseIcon?.classList.remove('hidden') - playIcon?.classList.add('hidden') + if (pauseIcon) pauseIcon.style.display = 'flex' + if (playIcon) playIcon.style.display = 'none' this.toggleButton = null } @@ -388,347 +355,170 @@ export class ComputersAnimationElement extends LitElement { return state ?? 'playing' } - private startAnimation() { + /** + * Measures each primary edge `` and applies `stroke-dash` properties so + * it can be revealed with a draw-in effect (animating `strokeDashoffset` → 0). + * Cross-dependency edges use the SVG `stroke-dasharray="6 3"` dashed style, so + * they are revealed with opacity instead of dash-offset. + */ + private prepareEdgePaths(): void { + const primaryEdgeIds = ['tf-edge-vpc', 'tf-edge-iam', 'tf-edge-s3'] + + for (const id of primaryEdgeIds) { + const path = queryEdgePath(this, id) + if (path) { + const len = path.getTotalLength() + gsap.set(path, { strokeDasharray: len, strokeDashoffset: len }) + } + } + } + + private startAnimation(): void { if (typeof document === 'undefined') return - if (document.getElementById('heroAnimation') == undefined) return + if (document.getElementById('terraformAnimation') === null) return if (this.timeline) return const context = { scriptName: SCRIPT_NAME, operation: 'startAnimation' } addScriptBreadcrumb(context) try { - gsap.set('.monitorBottom', { - transformOrigin: '50% 100%', - }) - - gsap.set(['.monitorStand', '.laptopBase', '.tabletScreen'], { - transformOrigin: '50% 0%', - }) - + // --- Selector groups --- + const primaryArrows = '#tf-arrow-vpc, #tf-arrow-iam, #tf-arrow-s3' + const crossEdges = '#tf-edge-vpc-eks, #tf-edge-iam-eks, #tf-edge-vpc-rds, #tf-edge-iam-rds' + const crossArrows = '#tf-arrow-vpc-eks, #tf-arrow-iam-eks, #tf-arrow-vpc-rds, #tf-arrow-iam-rds' + const allChecks = + '#tf-check-root, #tf-check-vpc, #tf-check-iam, #tf-check-s3, #tf-check-eks, #tf-check-rds' + const termLines = + '#tf-term-line-1, #tf-term-line-2, #tf-term-line-3, #tf-term-line-4, ' + + '#tf-term-line-5, #tf-term-line-6, #tf-term-line-7, #tf-term-line-8' + + // --- Initial states --- gsap.set( [ - '.monitorLogo', - '.monitorScreen', - '.laptopScreen', - '.laptopTrackpad', - '.tabletGroup', - '.laptopGroup', - '.tabletButton', - '.tabletCamera', - '.tabletContentGroup', - '.phoneButton', - '.phoneCamera', - '.phoneSpeaker', - '.laptopContentGroup', - '.phoneGroup', + '#tf-node-root', '#tf-node-vpc', '#tf-node-iam', '#tf-node-s3', + '#tf-node-eks', '#tf-node-rds', ], - { - transformOrigin: '50% 50%', - } + { transformOrigin: '50% 50%' } ) - gsap.set(['.laptopEdgeLeft', '.laptopEdgeRight'], { - transformOrigin: '0% 100%', - }) + // Hide arrowheads and cross-dependency edge paths + gsap.set(`${primaryArrows}, ${crossArrows}`, { opacity: 0 }) + gsap.set(crossEdges, { opacity: 0 }) - gsap.set('.tabletGroup', { - rotation: -90, - }) + // Set up primary edge paths for stroke-dash draw-in + this.prepareEdgePaths() - gsap.set('svg', { - visibility: 'visible', - }) + // Make SVG visible (starts as visibility:hidden to prevent FOUC) + gsap.set('#terraformAnimation', { visibility: 'visible' }) + // --- Build timeline --- this.timeline = gsap .timeline({ - defaults: { duration: 1, immediateRender: false }, - delay: 1, + defaults: { duration: 0.8, immediateRender: false }, + delay: 0.8, paused: true, repeat: -1, + repeatDelay: 0.6, yoyo: false, }) - .timeScale(3) - .from('.monitorBottom', { - scaleY: 0, - ease: 'power1', - }) - .from( - '.monitorStand', - { - y: -70, - ease: 'power1', - }, - '-=1' - ) - .from( - '.monitorStandShadow', - { - duration: 0.5, - alpha: 0, - ease: 'power1.in', - }, - '-=0.5' - ) - .from( - '.monitorEdge', - { - y: 330, - }, - '-=0.25' - ) - .from( - '.monitorScreen', - { - duration: 2, - y: 330, - ease: 'power1', - }, - '-=1' - ) - .from('.monitorContentGroup path', { - duration: 1, - scaleX: 0, - stagger: 0.1, - }) - .from('.monitorLogo', { - scale: 0, - ease: 'back(2)', - }) - .to('.monitorContentGroup path', { - alpha: 0, - delay: 2, - stagger: { each: 0.1 }, - }) - .to( - '.monitorScreen', - { - y: 330, - ease: 'power1.in', - }, - '-=1' - ) - .to( - '.monitorEdge', - { - y: 330, - ease: 'power1.in', - }, - '-=0.75' - ) - .to('.monitorBottom', { - scaleX: 0.69, - y: -23, - }) - .to( - '.monitorBottom', - { - scaleY: 0.45, - alpha: 1, - }, - '-=1' - ) - .set('.monitorBottom', { - alpha: 0, - }) - .to( - '.monitorLogo', - { - duration: 0.5, - scale: 0, - ease: 'back.in', - }, - '-=1' - ) - .to( - '.monitorStand', - { - y: -120, - }, - '-=1.5' - ) - .to( - '.monitorStandShadow', - { - duration: 0.5, - alpha: 0, - }, - '-=1.5' - ) - .from( - '.laptopBase', - { - alpha: 0, - }, - '-=1' - ) - .from( - '.laptopTrackpad', - { - scaleX: 0, - }, - '-=1' - ) - .from('.laptopScreen', { - scale: 0, - ease: 'back(0.5)', - }) - .from( - '.laptopEdgeLeft', - { - duration: 2, - skewX: -40, - scaleY: 0, - ease: 'power3', - }, - '-=2' - ) - .from( - '.laptopEdgeRight', - { - duration: 2, - skewX: 40, - scaleY: 0, - ease: 'power3', - }, - '-=2' - ) - .from('.laptopContentGroup path', { - duration: 1, - scaleX: 0, - stagger: 0.1, - }) - .to('.laptopTrackpad', { - duration: 0.3, - alpha: 0, - delay: 2, - }) - .to('.laptopScreen', { - scaleX: 0.67, - }) - .to( - '.laptopScreen', - { - scaleY: 0.8, - }, - '-=1' - ) - .to( - '.laptopContentGroup', - { - alpha: 0, - scale: 0.5, - }, - '-=1' - ) - .to( - '.laptopBase', - { - y: -20, - scaleX: 0, - }, - '-=1' - ) - .to( - '.laptopEdgeLeft', - { - x: 40, - transformOrigin: '50% 50%', - scaleY: 0.85, - }, - '-=1' - ) - .to( - '.laptopEdgeRight', - { - x: -40, - transformOrigin: '50% 50%', - scaleY: 0.85, - }, - '-=1' - ) - .set('.laptopGroup', { - alpha: 0, - }) - .from( - '.tabletGroup', - { - scale: 1.1, - alpha: 0, - }, - '-=1' - ) - .to('.tabletGroup', { - duration: 2, - rotation: 0, - delay: 2, - ease: Anticipate.out, - }) - .from( - ['.tabletButton', '.tabletCamera'], - { - duration: 0.5, - scale: 0, - ease: 'back', - stagger: 0, - }, - '-=1' - ) - .from('.tabletContentGroup', { - duration: 2, - rotation: 90, - scaleX: 1.33, - scaleY: 0.8, - ease: 'power3.inOut', - }) - .to(['.tabletButton', '.tabletCamera'], { - duration: 0.5, - scale: 0, - delay: 2, - }) - .to('.tabletGroup', { - scaleX: 0.45, - }) - .to( - '.tabletGroup', - { - scaleY: 0.7, - }, - '-=1' - ) - .to( - '.tabletContentGroup', - { - y: -5, - }, - '-=1' - ) - .to( - '.tabletScreen', - { - duration: 0.5, - scaleY: 0.92, - y: 4, - }, - '-=0.5' - ) - .set('.phoneGroup', { - alpha: 1, - }) - .set(['.tabletGroup', '.tabletContentGroup'], { - alpha: 0, - }) - .from(['.phoneButton', '.phoneCamera', '.phoneSpeaker'], { - duration: 1, - scale: 0, - ease: 'back', - stagger: 0.1, - }) - .to('.phoneGroup', { - duration: 2, - y: 80, - delay: 2, - ease: 'back.in(2)', + .timeScale(TIMESCALE) + + // ===== Phase 1: Root node appears ===== + .from('#tf-node-root', { + scale: 0.5, + opacity: 0, + duration: 0.6, + ease: 'back(1.7)', }) + + // ===== Phase 2: Primary edges draw in ===== + .to('#tf-edge-vpc', { strokeDashoffset: 0, duration: 0.5, ease: 'power2.out' }) + .to('#tf-edge-iam', { strokeDashoffset: 0, duration: 0.5, ease: 'power2.out' }, '-=0.35') + .to('#tf-edge-s3', { strokeDashoffset: 0, duration: 0.5, ease: 'power2.out' }, '-=0.35') + .to(primaryArrows, { opacity: 1, duration: 0.15 }, '-=0.1') + + // ===== Phase 3: Row 1 nodes pop in ===== + .from('#tf-node-vpc', { + scale: 0, opacity: 0, duration: 0.5, ease: 'back(2)', + }, '-=0.05') + .from('#tf-node-iam', { + scale: 0, opacity: 0, duration: 0.5, ease: 'back(2)', + }, '-=0.3') + .from('#tf-node-s3', { + scale: 0, opacity: 0, duration: 0.5, ease: 'back(2)', + }, '-=0.3') + + // ===== Phase 4: Cross-dependency edges fade in ===== + .to('#tf-edge-vpc-eks', { opacity: 1, duration: 0.35 }, '+=0.15') + .to('#tf-edge-iam-eks', { opacity: 1, duration: 0.35 }, '-=0.25') + .to('#tf-edge-vpc-rds', { opacity: 1, duration: 0.35 }, '-=0.25') + .to('#tf-edge-iam-rds', { opacity: 1, duration: 0.35 }, '-=0.25') + .to(crossArrows, { opacity: 1, duration: 0.15 }, '-=0.1') + + // ===== Phase 5: Row 2 nodes pop in ===== + .from('#tf-node-eks', { + scale: 0, opacity: 0, duration: 0.5, ease: 'back(2)', + }, '-=0.05') + .from('#tf-node-rds', { + scale: 0, opacity: 0, duration: 0.5, ease: 'back(2)', + }, '-=0.25') + + // ===== Phase 6: Checkmarks cascade with green flash borders ===== + .to('#tf-check-root', { opacity: 1, duration: 0.25 }, '+=0.3') + + .to('#tf-check-vpc', { opacity: 1, duration: 0.25 }, '+=0.06') + .to('#tf-flash-vpc', { opacity: 1, duration: 0.06 }, '-=0.15') + .to('#tf-flash-vpc', { opacity: 0, duration: 0.2 }) + + .to('#tf-check-iam', { opacity: 1, duration: 0.25 }, '-=0.1') + .to('#tf-flash-iam', { opacity: 1, duration: 0.06 }, '-=0.15') + .to('#tf-flash-iam', { opacity: 0, duration: 0.2 }) + + .to('#tf-check-s3', { opacity: 1, duration: 0.25 }, '-=0.1') + .to('#tf-flash-s3', { opacity: 1, duration: 0.06 }, '-=0.15') + .to('#tf-flash-s3', { opacity: 0, duration: 0.2 }) + + .to('#tf-check-eks', { opacity: 1, duration: 0.25 }, '-=0.1') + .to('#tf-flash-eks', { opacity: 1, duration: 0.06 }, '-=0.15') + .to('#tf-flash-eks', { opacity: 0, duration: 0.2 }) + + .to('#tf-check-rds', { opacity: 1, duration: 0.25 }, '-=0.1') + .to('#tf-flash-rds', { opacity: 1, duration: 0.06 }, '-=0.15') + .to('#tf-flash-rds', { opacity: 0, duration: 0.2 }) + + // ===== Phase 7: Terminal output lines appear ===== + .to('#tf-term-line-1', { opacity: 1, duration: 0.12 }, '+=0.3') + .to('#tf-term-line-2', { opacity: 1, duration: 0.12 }, '+=0.06') + .to('#tf-term-line-3', { opacity: 1, duration: 0.12 }, '+=0.15') + .to('#tf-term-line-4', { opacity: 1, duration: 0.12 }, '+=0.06') + .to('#tf-term-line-5', { opacity: 1, duration: 0.12 }, '+=0.15') + .to('#tf-term-line-6', { opacity: 1, duration: 0.12 }, '+=0.06') + .to('#tf-term-line-7', { opacity: 1, duration: 0.12 }, '+=0.04') + .to('#tf-term-line-8', { opacity: 1, duration: 0.12 }, '+=0.04') + + // ===== Phase 8: "Apply complete!" ===== + .to('#tf-term-complete', { opacity: 1, duration: 0.4, ease: 'power2.out' }, '+=0.3') + + // ===== Phase 9: Cursor blinks ===== + .to('#tf-cursor', { opacity: 1, duration: 0.08 }, '+=0.2') + .to('#tf-cursor', { opacity: 0, duration: 0.08 }, '+=0.35') + .to('#tf-cursor', { opacity: 1, duration: 0.08 }, '+=0.35') + .to('#tf-cursor', { opacity: 0, duration: 0.08 }, '+=0.35') + + // ===== Phase 10: Hold on completed state ===== + .to({}, { duration: 2.5 }) + + // ===== Phase 11: Dissolve for clean loop ===== + .to('#tf-cursor', { opacity: 0, duration: 0.1 }) + .to(`${termLines}, #tf-term-complete`, { opacity: 0, duration: 0.4 }, '-=0.05') + .to(allChecks, { opacity: 0, duration: 0.3 }, '-=0.3') + .to('#tf-node-eks, #tf-node-rds', { opacity: 0, scale: 0.8, duration: 0.4 }, '-=0.2') + .to('#tf-edges-cross', { opacity: 0, duration: 0.3 }, '-=0.3') + .to('#tf-node-vpc, #tf-node-iam, #tf-node-s3', { + opacity: 0, scale: 0.8, duration: 0.4, + }, '-=0.2') + .to('#tf-edges-primary', { opacity: 0, duration: 0.3 }, '-=0.3') + .to('#tf-node-root', { opacity: 0, scale: 0.8, duration: 0.3 }, '-=0.2') } catch (error) { handleScriptError(error, context) } @@ -737,17 +527,17 @@ export class ComputersAnimationElement extends LitElement { declare global { interface HTMLElementTagNameMap { - 'computers-animation': ComputersAnimationElement + 'terraform-animation': TerraformAnimationElement } } -export const registerComputersAnimationWebComponent = (tagName: string = COMPONENT_TAG_NAME) => { +export const registerTerraformAnimationWebComponent = (tagName: string = COMPONENT_TAG_NAME) => { if (typeof window === 'undefined') return - defineCustomElement(tagName, ComputersAnimationElement) + defineCustomElement(tagName, TerraformAnimationElement) } -export const webComponentModule: WebComponentModule = { +export const webComponentModule: WebComponentModule = { registeredName: COMPONENT_TAG_NAME, - componentCtor: ComputersAnimationElement, - registerWebComponent: registerComputersAnimationWebComponent, + componentCtor: TerraformAnimationElement, + registerWebComponent: registerTerraformAnimationWebComponent, } diff --git a/src/components/Animations/Computers/client/selectors.ts b/src/components/Animations/Terraform/client/selectors.ts similarity index 70% rename from src/components/Animations/Computers/client/selectors.ts rename to src/components/Animations/Terraform/client/selectors.ts index db59552d1..9c90e8d2e 100644 --- a/src/components/Animations/Computers/client/selectors.ts +++ b/src/components/Animations/Terraform/client/selectors.ts @@ -19,3 +19,11 @@ export function queryAnimationTogglePauseIcon(scope: ParentNode): Element | null export function queryAnimationTogglePlayIcon(scope: ParentNode): Element | null { return scope.querySelector(SELECTORS.playIcon) } + +/** + * Query a primary edge `` by its ID within the given scope. + * Returns the element cast to `SVGPathElement` so callers can use `getTotalLength()`. + */ +export function queryEdgePath(scope: ParentNode, id: string): SVGPathElement | null { + return scope.querySelector(`#${id}`) +} diff --git a/src/components/Animations/Terraform/index.astro b/src/components/Animations/Terraform/index.astro new file mode 100644 index 000000000..67b2c2101 --- /dev/null +++ b/src/components/Animations/Terraform/index.astro @@ -0,0 +1,221 @@ +--- +/** + * Terraform dependency graph animation for home page Hero. + * Depicts a `terraform graph` style two-tier dependency tree inside a + * terminal frame, animated with GSAP to show init → plan → apply workflow. + */ +import Icon from '@components/Icon/index.astro' +--- + + + + diff --git a/src/components/Animations/Terraform/terraform.svg b/src/components/Animations/Terraform/terraform.svg new file mode 100644 index 000000000..570b07310 --- /dev/null +++ b/src/components/Animations/Terraform/terraform.svg @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + terraform — plan & apply + + + + + + + + + + + + main.tf + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VPC + + + + + + + + + + + + + + + + IAM + + + + + + + + + + + + + + + S3 + + + + + + + + + + + + + + + + + + + + EKS + + + + + + + + + + + + + + + + + RDS + + + + + + + + + + + + + + + $ + terraform init + + + Initializing provider plugins... + + + $ + terraform plan + + + Plan: 5 to add, 0 to change, 0 to destroy. + + + $ + terraform apply -auto-approve + + + aws_vpc.main: Creating... + + + aws_eks_cluster.platform: Creating... + + + aws_db_instance.primary: Creating... + + + + + Apply complete! Resources: 5 added, 0 changed, 0 destroyed. + + + + + + diff --git a/src/components/Breadcrumbs/index.astro b/src/components/Breadcrumbs/index.astro index 55d216c5a..ee0aa66e3 100644 --- a/src/components/Breadcrumbs/index.astro +++ b/src/components/Breadcrumbs/index.astro @@ -38,7 +38,7 @@ const breadcrumbSchema = breadcrumbs.length > 1 && (
-
-

- {title} -

-

- {description} -

+
+
+

+ {title} +

+

+ {description} +

+
-
+
{primaryLink.text} {secondaryLink.text} diff --git a/src/components/CallToAction/Newsletter/article.astro b/src/components/CallToAction/Newsletter/article.astro new file mode 100644 index 000000000..92ec7f48f --- /dev/null +++ b/src/components/CallToAction/Newsletter/article.astro @@ -0,0 +1,124 @@ +--- +import GDPRConsent from '@components/Consent/Checkbox/index.astro' +import Confetti from '@components/Animations/Confetti/index.astro' +import type { NewsletterProps } from './client/@types' + +export type Props = Omit + +const { + title = 'Stay Updated', + description = 'Subscribe to our newsletter for the latest insights on web development and digital solutions.', + placeholder = 'Enter your email address', + buttonText = 'Subscribe', +} = Astro.props +--- + +
+
+
+
+
+
+ newsletter.subscribe +
+ +
+

+ $ + {` ${title}`} +

+

+ > + {` ${description}`} +

+ +
+
+
+ $ + + +
+ + + +
+ +
+ +
+ +

+ You'll receive a confirmation email. Click the link to complete your subscription. +

+
+
+
+
diff --git a/src/components/CallToAction/Newsletter/props.ts b/src/components/CallToAction/Newsletter/client/@types/index.d.ts similarity index 79% rename from src/components/CallToAction/Newsletter/props.ts rename to src/components/CallToAction/Newsletter/client/@types/index.d.ts index d2cd782ec..63eb8faee 100644 --- a/src/components/CallToAction/Newsletter/props.ts +++ b/src/components/CallToAction/Newsletter/client/@types/index.d.ts @@ -7,4 +7,6 @@ export interface NewsletterProps { placeholder?: string /** Submit button text */ buttonText?: string + /** Choose which variant to show */ + variant: 'article' | 'home' } diff --git a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts index e1e58284a..4c557a62c 100644 --- a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts +++ b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { experimental_AstroContainer as AstroContainer } from 'astro/container' import { TestError } from '@test/errors' import Newsletter from '@components/CallToAction/Newsletter/index.astro' -import type { NewsletterProps } from '@components/CallToAction/Newsletter/props' +import type { NewsletterProps } from '@components/CallToAction/Newsletter/client/@types' import type { NewsletterFormElement } from '@components/CallToAction/Newsletter/client' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { executeRender } from '@test/unit/helpers/litRuntime' @@ -29,8 +29,11 @@ const defaultNewsletterProps: NewsletterProps = { description: 'Test description for newsletter signup', placeholder: 'test@example.com', buttonText: 'Subscribe', + variant: 'article', } +const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home'] + const getElements = (root: NewsletterFormElement) => { const selectElement = (selector: string): T => { const element = root.querySelector(selector) @@ -53,7 +56,7 @@ const getElements = (root: NewsletterFormElement) => { } } -describe('NewsletterFormElement web component', () => { +describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', variant => { let container: AstroContainer beforeEach(async () => { @@ -79,6 +82,7 @@ describe('NewsletterFormElement web component', () => { args: { props: { ...defaultNewsletterProps, + variant, ...props, }, }, diff --git a/src/components/CallToAction/Newsletter/client/__tests__/selectors.spec.ts b/src/components/CallToAction/Newsletter/client/__tests__/selectors.spec.ts index 59dbe4baa..9ceec0fea 100644 --- a/src/components/CallToAction/Newsletter/client/__tests__/selectors.spec.ts +++ b/src/components/CallToAction/Newsletter/client/__tests__/selectors.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test } from 'vitest' import { experimental_AstroContainer as AstroContainer } from 'astro/container' import Newsletter from '@components/CallToAction/Newsletter/index.astro' -import type { NewsletterProps } from '@components/CallToAction/Newsletter/props' +import type { NewsletterProps } from '@components/CallToAction/Newsletter/client/@types' import type { NewsletterFormElement } from '@components/CallToAction/Newsletter/client' import { SELECTORS, @@ -22,6 +22,7 @@ const defaultNewsletterProps: NewsletterProps = { description: 'Ensures selector utilities receive accurate markup', placeholder: 'user@example.com', buttonText: 'Subscribe', + variant: 'article', } describe('Newsletter selector utilities', () => { diff --git a/src/components/CallToAction/Newsletter/home.astro b/src/components/CallToAction/Newsletter/home.astro new file mode 100644 index 000000000..da7ebb4e2 --- /dev/null +++ b/src/components/CallToAction/Newsletter/home.astro @@ -0,0 +1,274 @@ +--- +import GDPRConsent from '@components/Consent/Checkbox/index.astro' +import Confetti from '@components/Animations/Confetti/index.astro' +import type { NewsletterProps } from './client/@types' + +export type Props = Omit + +const { + title = 'Stay Updated', + description = 'Subscribe to our newsletter for the latest insights on web development and digital solutions.', + placeholder = 'Enter your email address', + buttonText = 'Subscribe', +} = Astro.props +--- + +
+
+
+ + + + + + + + +
+ +
+ +
+ +
+
+ + +
+
+
+ + Monthly Dispatch + +
+

+ {title} +

+

+ {description} +

+
+ +
    +
  • + + Incident analysis +
  • +
  • + + Scaling strategies +
  • +
  • + + Tool evaluations +
  • +
  • + + Architecture reviews +
  • +
+
+
+ +
+
+
+ + + + + + +
+ +
+
+ + +
+ + + + + + +

+ You'll receive a confirmation email. Click the link to complete your subscription. +

+ +
+
+
+
+
diff --git a/src/components/CallToAction/Newsletter/index.astro b/src/components/CallToAction/Newsletter/index.astro index 7d0448a67..d4d08c96c 100644 --- a/src/components/CallToAction/Newsletter/index.astro +++ b/src/components/CallToAction/Newsletter/index.astro @@ -15,9 +15,9 @@ * ``` */ -import GDPRConsent from '@components/Consent/Checkbox/index.astro' -import Confetti from '@components/Animations/Confetti/index.astro' -import type { NewsletterProps } from './props' +import type { NewsletterProps } from './client/@types' +import ArticleNewsletter from './article.astro' +import HomeNewsletter from './home.astro' export type Props = NewsletterProps @@ -26,138 +26,27 @@ const { description = 'Subscribe to our newsletter for the latest insights on web development and digital solutions.', placeholder = 'Enter your email address', buttonText = 'Subscribe', + variant = 'article', } = Astro.props --- -
-
-
-
-
-
- -
-

- {title} -

-

- {description} -

-
- -
-
- -
-
- - -
- - - -
- - -
- -
- - -

- You'll receive a confirmation email. Click the link to complete your subscription. -

-
-
-
-
-
-
+ {variant === 'article' && ( + + )} + {variant === 'home' && ( + + )}
diff --git a/src/components/Home/Intro/index.astro b/src/components/Home/Intro/index.astro index ff826fab9..4f377af31 100644 --- a/src/components/Home/Intro/index.astro +++ b/src/components/Home/Intro/index.astro @@ -3,94 +3,72 @@ * The Intro component serves as the welcoming section of the homepage, providing * a concise overview of Webstack Builders' mission and value proposition */ +type IconKey = keyof typeof svgPath +export interface Props { + pretitle: string + title: string + description: string + benefits: { + title: string + description: string + icon: IconKey + }[] +} + +const { description, benefits, pretitle, title } = Astro.props as Props + +const svgPath = { + 'check-circled': 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', + 'lightning-bolt': 'M13 10V3L4 14h7v7l9-11h-7z', + 'html': 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4', +} as const + +const visibleBenefits = benefits.slice(0, 3) --- -
-
-
-

- Building the Future of Software Development +
+ + + +
+
+

{pretitle}

+

+ {title}

-

- I'm a solo software development agency specializing in platform engineering, cloud - architecture, and developer experience optimization. With deep expertise in modern - technologies and a focus on scalable solutions, I help businesses transform their - technical infrastructure and accelerate development workflows. SOC-2 ready +

+ {description}

-
    -
  • -
    - -
    -

    Platform Engineering

    -

    - Scalable infrastructure and developer-first platforms -

    -
  • - -
  • -
    - -
    -

    Cloud Architecture

    -

    - Azure-focused solutions that scale with your business -

    -
  • - -
  • -
    - -
    -

    Developer Experience

    -

    Tools and workflows that empower development teams

    -
  • +
      + {visibleBenefits.map((benefit) => ( +
    • +
      +
      + +
      +

      {benefit.title}

      +
      +

      {benefit.description}

      +
    • + ))}
diff --git a/src/components/Home/Skills/client/__fixtures__/skillsCarousel.fixture.astro b/src/components/Home/Skills/client/__fixtures__/skillsCarousel.fixture.astro new file mode 100644 index 000000000..707c59d5b --- /dev/null +++ b/src/components/Home/Skills/client/__fixtures__/skillsCarousel.fixture.astro @@ -0,0 +1,5 @@ +--- +import Skills from '@components/Home/Skills/index.astro' +--- + + \ No newline at end of file diff --git a/src/components/Home/Skills/client/__tests__/index.spec.ts b/src/components/Home/Skills/client/__tests__/index.spec.ts new file mode 100644 index 000000000..57ef339ef --- /dev/null +++ b/src/components/Home/Skills/client/__tests__/index.spec.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { executeRender } from '@test/unit/helpers/litRuntime' +import SkillsFixture from '@components/Home/Skills/client/__fixtures__/skillsCarousel.fixture.astro' +import type { SkillsCarouselElement } from '@components/Home/Skills/client' + +type SkillsModule = WebComponentModule + +const addButtonEventListenersMock = vi.hoisted(() => vi.fn()) +const createAnimationControllerMock = vi.hoisted(() => vi.fn()) + +type AutoplayPluginInstance = { + play: ReturnType + stop: ReturnType +} + +const autoplayPluginInstances: AutoplayPluginInstance[] = [] + +const createAutoplayPluginMock = vi.fn(() => { + const instance: AutoplayPluginInstance = { + play: vi.fn(), + stop: vi.fn(), + } + autoplayPluginInstances.push(instance) + return instance +}) + +vi.mock('astro:content', () => { + const skills = [ + { + data: { + slug: 'aws', + displayName: 'AWS', + logo: undefined, + isSkill: true, + }, + }, + { + data: { + slug: 'kubernetes', + displayName: 'Kubernetes', + logo: undefined, + isSkill: true, + }, + }, + { + data: { + slug: 'terraform', + displayName: 'Terraform', + logo: undefined, + isSkill: true, + }, + }, + ] + + return { + getCollection: vi.fn(async (_collection: string, filter?: (_entry: unknown) => boolean) => { + if (!filter) return skills + return skills.filter(entry => filter(entry)) + }), + } +}) + +vi.mock('@components/scripts/store', () => ({ + createAnimationController: createAnimationControllerMock, +})) + +vi.mock('@components/scripts/elementListeners', () => ({ + addButtonEventListeners: addButtonEventListenersMock, +})) + +vi.mock('embla-carousel', () => { + const createEmbla = vi.fn(() => { + const handlers = new Map void>>() + let selectedIndex = 0 + + const api = { + canScrollPrev: vi.fn(() => false), + canScrollNext: vi.fn(() => false), + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + on: vi.fn((event: string, handler: () => void) => { + const existing = handlers.get(event) ?? [] + handlers.set(event, [...existing, handler]) + return api + }), + off: vi.fn((event: string, handler: () => void) => { + const existing = handlers.get(event) ?? [] + handlers.set( + event, + existing.filter(entry => entry !== handler) + ) + return api + }), + destroy: vi.fn(), + scrollSnapList: vi.fn(() => [0, 1, 2]), + selectedScrollSnap: vi.fn(() => selectedIndex), + scrollTo: vi.fn((index: number) => { + selectedIndex = index + }), + } + + return api + }) + + return { + default: createEmbla, + } +}) + +vi.mock('embla-carousel-autoplay', () => ({ + __esModule: true, + default: createAutoplayPluginMock, +})) + +describe('Skills carousel component', () => { + let container: AstroContainer + + beforeEach(async () => { + container = await AstroContainer.create() + vi.clearAllMocks() + autoplayPluginInstances.length = 0 + createAutoplayPluginMock.mockClear() + createAnimationControllerMock.mockReturnValue({ + requestPlay: vi.fn(), + requestPause: vi.fn(), + setInstancePauseState: vi.fn(), + clearUserPreference: vi.fn(), + destroy: vi.fn(), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + const renderSkills = async ( + assertion: (_context: { element: SkillsCarouselElement }) => Promise | void + ): Promise => { + await executeRender({ + container, + component: SkillsFixture, + moduleSpecifier: '@components/Home/Skills/client/index', + selector: 'skills-carousel', + waitForReady: async (element: SkillsCarouselElement) => { + await element.updateComplete + }, + assert: async ({ element, module, renderResult }) => { + expect(renderResult).toContain(`<${module.registeredName}`) + await assertion({ element }) + }, + }) + } + + it('renders centered labels with nav controls and no dots', async () => { + await renderSkills(async ({ element }) => { + const firstLink = element.querySelector('a') + expect(firstLink?.classList.contains('flex-col')).toBe(true) + expect(firstLink?.classList.contains('items-center')).toBe(true) + expect(firstLink?.classList.contains('text-center')).toBe(true) + + expect(element.querySelector('[data-skills-prev]')).toBeTruthy() + expect(element.querySelector('[data-skills-next]')).toBeTruthy() + expect(element.querySelector('[data-skills-autoplay-toggle]')).toBeTruthy() + expect(element.querySelector('[data-skills-dots]')).toBeFalsy() + }) + }) + + it('toggles autoplay state via the play/pause control', async () => { + vi.useFakeTimers() + const buttonHandlers = new Map void>() + addButtonEventListenersMock.mockImplementation((button, handler) => { + buttonHandlers.set(button as Element, handler as () => void) + }) + + await renderSkills(async ({ element }) => { + const toggle = element.querySelector('[data-skills-autoplay-toggle]') as HTMLButtonElement + expect(toggle).toBeTruthy() + + const handler = buttonHandlers.get(toggle) + expect(handler).toBeTruthy() + + const autoplayInstance = autoplayPluginInstances[0] + expect(autoplayInstance).toBeTruthy() + + vi.runAllTimers() + expect(autoplayInstance?.play).toHaveBeenCalled() + expect(toggle.getAttribute('aria-label')).toBe('Pause skills') + + handler?.() + expect(autoplayInstance?.stop).toHaveBeenCalled() + expect(toggle.getAttribute('aria-label')).toBe('Play skills') + + handler?.() + vi.runAllTimers() + expect(autoplayInstance?.play).toHaveBeenCalled() + expect(toggle.getAttribute('aria-label')).toBe('Pause skills') + }) + }) +}) \ No newline at end of file diff --git a/src/components/Home/Skills/client/index.ts b/src/components/Home/Skills/client/index.ts new file mode 100644 index 000000000..2d4b2ad5e --- /dev/null +++ b/src/components/Home/Skills/client/index.ts @@ -0,0 +1,133 @@ +import { addButtonEventListeners } from '@components/scripts/elementListeners' +import { isType1Element } from '@components/scripts/assertions/elements' +import { defineCustomElement } from '@components/scripts/utils' +import { createE2ELogger, EmblaCarouselBase } from '@components/scripts/embla' +import type { EmblaCarouselConfig, EmblaElementHandles } from '@components/scripts/embla' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { + getSkillsEmblaRoot, + getSkillsViewport, + querySkillsAutoplayPauseIcon, + querySkillsAutoplayPlayIcon, + querySkillsAutoplayToggle, + querySkillsNextBtn, + querySkillsPrevBtn, + querySkillsSlides, +} from './selectors' + +const SCRIPT_NAME = 'SkillsCarouselElement' +const logForE2E = createE2ELogger('skills') + +export class SkillsCarouselElement extends EmblaCarouselBase { + private autoplayToggleBtn: HTMLButtonElement | null = null + + protected getConfig(): EmblaCarouselConfig { + return { + emblaOptions: { + loop: true, + align: 'start', + skipSnaps: false, + dragFree: false, + }, + autoplayOptions: { + delay: 5000, + stopOnInteraction: true, + stopOnMouseEnter: true, + playOnInit: false, + }, + animationId: 'skills-carousel', + scriptName: SCRIPT_NAME, + logPrefix: 'skills', + } + } + + protected queryElements(): EmblaElementHandles { + return { + emblaRoot: getSkillsEmblaRoot(this), + viewport: getSkillsViewport(this), + slideCount: querySkillsSlides(this).length, + prevBtn: querySkillsPrevBtn(this), + nextBtn: querySkillsNextBtn(this), + } + } + + protected override onInitialized(): void { + this.autoplayToggleBtn = querySkillsAutoplayToggle(this) + this.setupAutoplayToggle() + if (this.hasAutoplaySupport) { + this.resume() + } + } + + protected override onTeardown(): void { + this.autoplayToggleBtn = null + } + + protected override onAutoplayStateChange(state: 'playing' | 'paused'): void { + this.syncAutoplayToggleButton(state) + } + + private setupAutoplayToggle(): void { + if (!this.autoplayToggleBtn) return + + const viewportId = this.viewport?.getAttribute('id') + if (viewportId) { + this.autoplayToggleBtn.setAttribute('aria-controls', viewportId) + } + + addButtonEventListeners( + this.autoplayToggleBtn, + () => { + const state = this.getAttribute('data-carousel-autoplay') + if (state === 'playing') { + this.pause() + return + } + this.resume() + }, + this + ) + + const initialState = + (this.getAttribute('data-carousel-autoplay') as 'playing' | 'paused' | null) ?? 'paused' + this.syncAutoplayToggleButton(initialState) + } + + private syncAutoplayToggleButton(state: 'playing' | 'paused'): void { + if (!this.autoplayToggleBtn) return + + const pauseIcon = querySkillsAutoplayPauseIcon(this.autoplayToggleBtn) + const playIcon = querySkillsAutoplayPlayIcon(this.autoplayToggleBtn) + + if (state === 'playing') { + this.autoplayToggleBtn.setAttribute('aria-label', 'Pause skills') + this.autoplayToggleBtn.setAttribute('aria-pressed', 'false') + if (isType1Element(pauseIcon)) pauseIcon.classList.remove('hidden') + if (isType1Element(playIcon)) playIcon.classList.add('hidden') + return + } + + this.autoplayToggleBtn.setAttribute('aria-label', 'Play skills') + this.autoplayToggleBtn.setAttribute('aria-pressed', 'true') + if (isType1Element(pauseIcon)) pauseIcon.classList.add('hidden') + if (isType1Element(playIcon)) playIcon.classList.remove('hidden') + } +} + +declare global { + interface HTMLElementTagNameMap { + 'skills-carousel': SkillsCarouselElement + } +} + +export const registerSkillsCarouselWebComponent = (tagName = 'skills-carousel') => { + if (typeof window === 'undefined') return + logForE2E('info', 'register:invoke', { tagName }) + defineCustomElement(tagName, SkillsCarouselElement) +} + +export const webComponentModule: WebComponentModule = { + registeredName: 'skills-carousel', + componentCtor: SkillsCarouselElement, + registerWebComponent: registerSkillsCarouselWebComponent, +} \ No newline at end of file diff --git a/src/components/Home/Skills/client/selectors.ts b/src/components/Home/Skills/client/selectors.ts new file mode 100644 index 000000000..ded4c871c --- /dev/null +++ b/src/components/Home/Skills/client/selectors.ts @@ -0,0 +1,100 @@ +import type { EmblaCarouselType } from 'embla-carousel' +import { + isButtonElement, + isType1Element, +} from '@components/scripts/assertions/elements' +import { ClientScriptError } from '@components/scripts/errors' + +type SkillsEmblaRootElement = HTMLElement & { __emblaApi__?: EmblaCarouselType } + +const SELECTORS = { + emblaRoot: '[data-skills-embla-root]', + viewport: '[data-skills-embla-viewport]', + slides: '[data-skills-slide]', + prevBtn: '[data-skills-prev]', + nextBtn: '[data-skills-next]', + autoplayToggle: '[data-skills-autoplay-toggle]', + autoplayPauseIcon: '[data-skills-icon="pause"]', + autoplayPlayIcon: '[data-skills-icon="play"]', +} as const + +const isHtmlElement = (element: unknown): element is HTMLElement => + isType1Element(element) && element instanceof HTMLElement + +export const querySkillsEmblaRoot = (scope: ParentNode): SkillsEmblaRootElement | null => { + const root = scope.querySelector(SELECTORS.emblaRoot) + if (!isHtmlElement(root)) { + return null + } + + return root as SkillsEmblaRootElement +} + +export const getSkillsEmblaRoot = (scope: ParentNode): SkillsEmblaRootElement => { + const root = querySkillsEmblaRoot(scope) + if (!root) { + throw new ClientScriptError(`SkillsCarouselElement: Missing required element: ${SELECTORS.emblaRoot}`) + } + + return root +} + +export const querySkillsViewport = (scope: ParentNode): HTMLElement | null => { + const viewport = scope.querySelector(SELECTORS.viewport) + if (!isHtmlElement(viewport)) { + return null + } + + return viewport +} + +export const getSkillsViewport = (scope: ParentNode): HTMLElement => { + const viewport = querySkillsViewport(scope) + if (!viewport) { + throw new ClientScriptError(`SkillsCarouselElement: Missing required element: ${SELECTORS.viewport}`) + } + + return viewport +} + +export const querySkillsSlides = (scope: ParentNode): Element[] => + Array.from(scope.querySelectorAll(SELECTORS.slides)) + +export const querySkillsPrevBtn = (scope: ParentNode): HTMLButtonElement | null => { + const button = scope.querySelector(SELECTORS.prevBtn) + if (!isButtonElement(button)) { + return null + } + + return button +} + +export const querySkillsNextBtn = (scope: ParentNode): HTMLButtonElement | null => { + const button = scope.querySelector(SELECTORS.nextBtn) + if (!isButtonElement(button)) { + return null + } + + return button +} + +export const querySkillsAutoplayToggle = (scope: ParentNode): HTMLButtonElement | null => { + const button = scope.querySelector(SELECTORS.autoplayToggle) + if (!isButtonElement(button)) { + return null + } + + return button +} + +export const querySkillsAutoplayPauseIcon = (scope: ParentNode): Element | null => { + const icon = scope.querySelector(SELECTORS.autoplayPauseIcon) + return isType1Element(icon) ? icon : null +} + +export const querySkillsAutoplayPlayIcon = (scope: ParentNode): Element | null => { + const icon = scope.querySelector(SELECTORS.autoplayPlayIcon) + return isType1Element(icon) ? icon : null +} + +export type { SkillsEmblaRootElement } \ No newline at end of file diff --git a/src/components/Home/Skills/index.astro b/src/components/Home/Skills/index.astro index 4254d830c..d46e3b271 100644 --- a/src/components/Home/Skills/index.astro +++ b/src/components/Home/Skills/index.astro @@ -1,63 +1,147 @@ --- /** - * "Technologies and Skills" section for the homepage + * "Technologies and Skills" section for the homepage. + * Renders a dark strip with icon + label links for each technology. */ +import { getCollection } from 'astro:content' +import { Image } from 'astro:assets' +import Icon from '@components/Icon/index.astro' + +export interface Props { + title: string +} + +const { title } = Astro.props as Props + +const skills = await getCollection('tags', ({ data }) => data.isSkill === true) --- -
-
-

- Technologies & Expertise -

-
-
-
- Go -
- Go -
-
-
- TS -
- TypeScript -
-
-
- TF -
- Terraform -
-
-
- Az + +

+ {title} +

+
+
+
+
+ {skills.map((skill, index) => ( + + ))}
- Azure -
-
-
- K8s -
- Kubernetes -
-
-
- Py -
- Python
+ + {skills.length > 1 && ( + <> + + + + + )}
-
+ + {skills.length > 1 && ( +
+ +
+ )} + + + diff --git a/src/components/Search/SearchBar/index.astro b/src/components/Search/SearchBar/index.astro index b358983e3..509209af2 100644 --- a/src/components/Search/SearchBar/index.astro +++ b/src/components/Search/SearchBar/index.astro @@ -1,5 +1,4 @@ --- -import Icon from '@components/Icon/index.astro' import closeSvg from '../../../icons/close.svg?raw' import microphoneSvg from '../../../icons/microphone.svg?raw' import styles from './index.module.css' @@ -86,11 +85,20 @@ const clearButtonLabel = hasInitialQuery ? 'Clear search' : 'Close search' hidden={isExpandedOnLoad} class="relative inline-flex items-center justify-center w-(--header-icon-size) h-(--header-icon-size) shrink-0 rounded-full border-0 bg-primary hover:bg-primary-offset focus:bg-primary-offset focus-visible:bg-primary-offset focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1.5 focus-visible:after:border-spotlight transition-colors duration-150 ease-linear data-[state=open]:bg-transparent data-[state=open]:hover:bg-transparent data-[state=open]:focus:bg-transparent data-[state=open]:focus-visible:bg-transparent" > - + diff --git a/src/components/Testimonials/props.ts b/src/components/Testimonials/@types/index.d.ts similarity index 84% rename from src/components/Testimonials/props.ts rename to src/components/Testimonials/@types/index.d.ts index 4562fe173..1371876f8 100644 --- a/src/components/Testimonials/props.ts +++ b/src/components/Testimonials/@types/index.d.ts @@ -4,4 +4,5 @@ export interface TestimonialsProps { limit?: number count?: number organization?: string + showControls: boolean } diff --git a/src/components/Testimonials/client/__tests__/index.spec.ts b/src/components/Testimonials/client/__tests__/index.spec.ts index 4b5b28f89..7ccd91553 100644 --- a/src/components/Testimonials/client/__tests__/index.spec.ts +++ b/src/components/Testimonials/client/__tests__/index.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { experimental_AstroContainer as AstroContainer } from 'astro/container' import TestimonialsComponent from '@components/Testimonials/index.astro' -import type { TestimonialsProps } from '@components/Testimonials/props' +import type { TestimonialsProps } from '@components/Testimonials/@types' import type { TestimonialsCarouselElement } from '@components/Testimonials/client' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { executeRender } from '@test/unit/helpers/litRuntime' @@ -92,6 +92,7 @@ vi.mock('embla-carousel-autoplay', () => ({ const defaultProps: TestimonialsProps = { title: 'Client Praise', + showControls: true, } const renderTestimonials = async ( diff --git a/src/components/Testimonials/client/__tests__/selectors.spec.ts b/src/components/Testimonials/client/__tests__/selectors.spec.ts index 074ee57be..eaa869353 100644 --- a/src/components/Testimonials/client/__tests__/selectors.spec.ts +++ b/src/components/Testimonials/client/__tests__/selectors.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { experimental_AstroContainer as AstroContainer } from 'astro/container' import Testimonials from '@components/Testimonials/index.astro' -import type { TestimonialsProps } from '@components/Testimonials/props' +import type { TestimonialsProps } from '@components/Testimonials/@types' import type { TestimonialsCarouselElement as TestimonialsCarouselElementInstance } from '../index' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { executeRender } from '@test/unit/helpers/litRuntime' diff --git a/src/components/Testimonials/client/index.ts b/src/components/Testimonials/client/index.ts index c76e93b97..4de43eacd 100644 --- a/src/components/Testimonials/client/index.ts +++ b/src/components/Testimonials/client/index.ts @@ -1,15 +1,8 @@ -import EmblaCarousel, { type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel' -import Autoplay from 'embla-carousel-autoplay' import { addButtonEventListeners } from '@components/scripts/elementListeners' -import { addScriptBreadcrumb } from '@components/scripts/errors' -import { handleScriptError } from '@components/scripts/errors/handler' import { isType1Element } from '@components/scripts/assertions/elements' import { defineCustomElement } from '@components/scripts/utils' -import { - createAnimationController, - type AnimationControllerHandle, - type AnimationPlayState, -} from '@components/scripts/store' +import { createE2ELogger, EmblaCarouselBase } from '@components/scripts/embla' +import type { EmblaCarouselConfig, EmblaElementHandles } from '@components/scripts/embla' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { getTestimonialsEmblaSetupElements, @@ -24,470 +17,112 @@ import { } from './selectors' const SCRIPT_NAME = 'TestimonialsCarouselElement' +const logForE2E = createE2ELogger('testimonials') -const logForE2E = ( - level: 'info' | 'error', - message: string, - details?: Record -): void => { - if (typeof window === 'undefined' || window.isPlaywrightControlled !== true) return - const payload = details ? [`[testimonials] ${message}`, details] : [`[testimonials] ${message}`] - console[level](...payload) -} - -const EMBLA_OPTIONS: EmblaOptionsType = { - loop: true, - align: 'center', - skipSnaps: false, - dragFree: false, -} - -const AUTOPLAY_OPTIONS = { - delay: 6000, - stopOnInteraction: true, - stopOnMouseEnter: true, - playOnInit: false, -} - -type DebugEmblaElement = HTMLElement & { __emblaApi__?: EmblaCarouselType } -type TimerHandle = ReturnType | number - -export class TestimonialsCarouselElement extends HTMLElement { - private emblaApi: EmblaCarouselType | null = null - private autoplayPlugin: ReturnType | null = null - private emblaRoot: DebugEmblaElement | null = null - private viewport: HTMLElement | null = null - private dotsContainer: HTMLElement | null = null - private prevBtn: HTMLButtonElement | null = null - private nextBtn: HTMLButtonElement | null = null +export class TestimonialsCarouselElement extends EmblaCarouselBase { private autoplayToggleBtn: HTMLButtonElement | null = null private viewportId: string | null = null - private initialized = false - private animationController: AnimationControllerHandle | undefined - private pendingAutoplayState: AnimationPlayState | null = null - private hasAutoplaySupport = false - private autoplayReady = false - private autoplayReadyScheduled = false - private autoplayReadyTimer: TimerHandle | null = null - private intersectionObserver: IntersectionObserver | undefined - private isFullyInViewport = true - private requestedAutoplayState: AnimationPlayState = 'paused' - private readonly animationInstanceId: string - private focusVisibleCheckRafId: number | null = null - private readonly domReadyHandler = () => { - document.removeEventListener('DOMContentLoaded', this.domReadyHandler) - this.initialize() - } - private readonly autoplayPlayHandler = () => this.setAutoplayState('playing') - private readonly autoplayStopHandler = () => this.setAutoplayState('paused') - private readonly focusInHandler = (event: FocusEvent) => this.handleFocusIn(event) - private readonly focusOutHandler = () => this.scheduleFocusVisiblePauseSync() - private static instanceCounter = 0 + // ────────────────────── Base class abstract implementations ────────────────────── - constructor() { - super() - TestimonialsCarouselElement.instanceCounter += 1 - this.animationInstanceId = `testimonials-${TestimonialsCarouselElement.instanceCounter}` - } - - connectedCallback(): void { - if (typeof document === 'undefined') return - - const context = { scriptName: SCRIPT_NAME, operation: 'connectedCallback' } - addScriptBreadcrumb(context) - - try { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', this.domReadyHandler, { once: true }) - return - } - this.initialize() - } catch (error) { - handleScriptError(error, context) + protected override getConfig(): EmblaCarouselConfig { + return { + emblaOptions: { + loop: true, + align: 'center', + skipSnaps: false, + dragFree: false, + }, + autoplayOptions: { + delay: 6000, + stopOnInteraction: true, + stopOnMouseEnter: true, + playOnInit: false, + }, + animationId: 'testimonials-carousel', + scriptName: SCRIPT_NAME, + logPrefix: 'testimonials', } } - disconnectedCallback(): void { - if (typeof document === 'undefined') return - document.removeEventListener('DOMContentLoaded', this.domReadyHandler) - this.teardown() - } - - private initialize(): void { - if (this.initialized || typeof document === 'undefined') return - - const context = { scriptName: SCRIPT_NAME, operation: 'initialize' } - addScriptBreadcrumb(context) - logForE2E('info', 'initialize:start', { - id: this.animationInstanceId, - existingReadyState: this.getAttribute('data-carousel-ready') ?? 'missing', - }) - - try { - const { emblaRoot, viewport } = getTestimonialsEmblaSetupElements(this) - - this.emblaRoot = emblaRoot as unknown as DebugEmblaElement - this.viewport = viewport - this.dotsContainer = queryTestimonialsDotsContainer(this) - this.prevBtn = queryTestimonialsPrevBtn(this) - this.nextBtn = queryTestimonialsNextBtn(this) - this.autoplayToggleBtn = queryTestimonialsAutoplayToggleBtn(this) - - const slideCount = queryTestimonialsSlides(this).length - const requestedAutoplay = slideCount > 1 ? Autoplay({ ...AUTOPLAY_OPTIONS }) : null - this.autoplayReady = false - this.autoplayReadyScheduled = false - const plugins = requestedAutoplay ? [requestedAutoplay] : [] - this.emblaApi = EmblaCarousel(this.viewport, EMBLA_OPTIONS, plugins) - this.emblaRoot.__emblaApi__ = this.emblaApi - const emblaSnapCount = this.emblaApi.scrollSnapList().length - const supportsAutoplay = slideCount > 1 && emblaSnapCount > 1 - this.hasAutoplaySupport = supportsAutoplay - this.autoplayPlugin = supportsAutoplay ? requestedAutoplay : null - this.setAutoplayState('paused') - this.requestedAutoplayState = 'paused' - - if (this.autoplayPlugin) { - const emblaWithEvents = this.emblaApi as EmblaCarouselType & { - on: (_event: string, _handler: () => void) => EmblaCarouselType - off: (_event: string, _handler: () => void) => EmblaCarouselType - } - - emblaWithEvents.on('autoplay:play', this.autoplayPlayHandler) - emblaWithEvents.on('autoplay:stop', this.autoplayStopHandler) - } - - this.setupNavigationButtons() - this.setupDotsNavigation() - this.setupAutoplayToggle() - this.addEventListener('focusin', this.focusInHandler) - this.addEventListener('focusout', this.focusOutHandler) - - this.initialized = true - this.setAttribute('data-carousel-ready', 'true') - logForE2E('info', 'initialize:complete', { - id: this.animationInstanceId, - slideCount, - supportsAutoplay: this.hasAutoplaySupport, - }) - - if (this.hasAutoplaySupport) { - this.registerAnimationLifecycle() - this.setupViewportObserver() - this.scheduleAutoplayReady() - this.scheduleFocusVisiblePauseSync() - } - } catch (error) { - this.teardown() - logForE2E('error', 'initialize:failed', { - id: this.animationInstanceId, - message: error instanceof Error ? error.message : String(error), - }) - handleScriptError(error, context) + protected override queryElements(): EmblaElementHandles { + const { emblaRoot, viewport } = getTestimonialsEmblaSetupElements(this) + return { + emblaRoot: emblaRoot as EmblaElementHandles['emblaRoot'], + viewport, + slideCount: queryTestimonialsSlides(this).length, + dotsContainer: queryTestimonialsDotsContainer(this), + prevBtn: queryTestimonialsPrevBtn(this), + nextBtn: queryTestimonialsNextBtn(this), } } - private teardown(): void { - this.teardownViewportObserver() - this.removeEventListener('focusin', this.focusInHandler) - this.removeEventListener('focusout', this.focusOutHandler) - if (typeof window !== 'undefined' && this.focusVisibleCheckRafId !== null) { - window.cancelAnimationFrame(this.focusVisibleCheckRafId) - this.focusVisibleCheckRafId = null - } - if (this.emblaApi) { - const emblaWithEvents = this.emblaApi as EmblaCarouselType & { - off: (_event: string, _handler: () => void) => EmblaCarouselType - } - - if (this.autoplayPlugin) { - emblaWithEvents.off('autoplay:play', this.autoplayPlayHandler) - emblaWithEvents.off('autoplay:stop', this.autoplayStopHandler) - } - this.emblaApi.destroy() - } - - if (this.emblaRoot && '__emblaApi__' in this.emblaRoot) { - delete this.emblaRoot.__emblaApi__ - } - - this.emblaApi = null - this.autoplayPlugin = null - this.initialized = false - this.removeAttribute('data-carousel-ready') - this.removeAttribute('data-carousel-autoplay') - this.animationController?.setInstancePauseState('focus-visible', false) - this.animationController?.destroy() - this.animationController = undefined - this.pendingAutoplayState = null - this.hasAutoplaySupport = false - if (this.autoplayReadyTimer) { - clearTimeout(this.autoplayReadyTimer) - this.autoplayReadyTimer = null - } - this.autoplayReadyScheduled = false - this.autoplayReady = false - - if (this.dotsContainer) { - this.dotsContainer.innerHTML = '' - } + protected override hasFocusVisibleSlide(): boolean { + return hasTestimonialsFocusVisibleSlide(this) } - private handleFocusIn(event: FocusEvent): void { - if (!this.hasAutoplaySupport) return - if (!this.animationController) return - - const target = event.target - if (!(target instanceof HTMLElement)) return - - const slideContainer = target.closest('[data-testimonials-slide]') - if (!slideContainer) return - - const isFocusVisible = this.isElementFocusVisible(target) - if (!isFocusVisible) return - - this.animationController.setInstancePauseState('focus-visible', true) + protected override isFocusInsideSlide(target: HTMLElement): boolean { + return target.closest('[data-testimonials-slide]') !== null } - private scheduleFocusVisiblePauseSync(): void { - if (!this.hasAutoplaySupport) return - if (!this.animationController) return - - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - this.syncFocusVisiblePauseState() - return - } - - if (this.focusVisibleCheckRafId !== null) { - window.cancelAnimationFrame(this.focusVisibleCheckRafId) - } + // ────────────────────── Testimonials-specific behaviour ────────────────────── - this.focusVisibleCheckRafId = window.requestAnimationFrame(() => { - this.focusVisibleCheckRafId = null - this.syncFocusVisiblePauseState() - }) + protected override onInitialized(): void { + this.autoplayToggleBtn = queryTestimonialsAutoplayToggleBtn(this) + this.ensureTestimonialsViewportId() + this.setupAriaControls() + this.setupAutoplayToggle() } - private syncFocusVisiblePauseState(): void { - if (!this.animationController) return - this.animationController.setInstancePauseState( - 'focus-visible', - hasTestimonialsFocusVisibleSlide(this) - ) + protected override onTeardown(): void { + this.autoplayToggleBtn = null + this.viewportId = null } - private isElementFocusVisible(element: HTMLElement): boolean { - try { - return element.matches(':focus-visible') - } catch { - return false - } - } - - private setupNavigationButtons(): void { - if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return - - this.ensureViewportId() - - const updateButtonStates = () => { - if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return - - if (this.emblaApi.canScrollPrev()) { - this.prevBtn.removeAttribute('disabled') - this.prevBtn.classList.remove('opacity-30', 'cursor-not-allowed') - } else { - this.prevBtn.setAttribute('disabled', 'true') - this.prevBtn.classList.add('opacity-30', 'cursor-not-allowed') - } - - if (this.emblaApi.canScrollNext()) { - this.nextBtn.removeAttribute('disabled') - this.nextBtn.classList.remove('opacity-30', 'cursor-not-allowed') - } else { - this.nextBtn.setAttribute('disabled', 'true') - this.nextBtn.classList.add('opacity-30', 'cursor-not-allowed') - } - } - - addButtonEventListeners( - this.prevBtn, - () => { - try { - this.emblaApi?.scrollPrev() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollPrev' }) - } - }, - this - ) - - addButtonEventListeners( - this.nextBtn, - () => { - try { - this.emblaApi?.scrollNext() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollNext' }) - } - }, - this - ) - - if (this.viewportId) { - this.prevBtn.setAttribute('aria-controls', this.viewportId) - this.nextBtn.setAttribute('aria-controls', this.viewportId) - } - - this.emblaApi.on('select', updateButtonStates) - this.emblaApi.on('reInit', updateButtonStates) - updateButtonStates() + protected override onAutoplayStateChange(state: 'playing' | 'paused'): void { + this.syncAutoplayToggleButton(state) } - private setupDotsNavigation(): void { - if (!this.emblaApi || !this.dotsContainer) return - - this.ensureViewportId() - - const dotsContainer = this.dotsContainer - const ownerDocument = this.ownerDocument ?? document - let dots: HTMLButtonElement[] = [] - - const rebuildDots = () => { - if (!this.emblaApi) return - dotsContainer.innerHTML = '' - dots = [] - - this.emblaApi.scrollSnapList().forEach((_, index) => { - const dot = ownerDocument.createElement('button') - dot.type = 'button' - dot.dataset['index'] = String(index) - dot.tabIndex = -1 - dot.setAttribute('tabindex', '-1') - dot.className = - 'embla__dot w-3 h-3 rounded-full bg-content-active transition-all duration-300 hover:bg-primary' - dot.setAttribute('aria-label', `Go to testimonial ${index + 1}`) - - if (this.viewportId) { - dot.setAttribute('aria-controls', this.viewportId) - } - - addButtonEventListeners( - dot, - () => { - try { - this.emblaApi?.scrollTo(index) - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'scrollToDot' }) - } - }, - this - ) - - dotsContainer.appendChild(dot) - dots.push(dot) - }) - } - - const updateDots = () => { - if (!this.emblaApi) return - const selectedIndex = this.emblaApi.selectedScrollSnap() - - dots.forEach((dot, index) => { - if (index === selectedIndex) { - dot.classList.add('bg-primary', 'w-6') - dot.classList.remove('bg-content-active', 'w-3') - dot.setAttribute('aria-current', 'true') - } else { - dot.classList.remove('bg-primary', 'w-6') - dot.classList.add('bg-content-active', 'w-3') - dot.removeAttribute('aria-current') - } - }) - } - - rebuildDots() - updateDots() + // ────────────────────── Dot styling overrides ────────────────────── - this.emblaApi.on('select', updateDots) - this.emblaApi.on('reInit', () => { - rebuildDots() - updateDots() - }) + protected override applyDotActiveStyle(dot: HTMLButtonElement): void { + dot.classList.add('bg-primary', 'w-6') + dot.classList.remove('bg-content-active', 'w-3') + dot.setAttribute('aria-current', 'true') } - pause(): void { - try { - this.requestedAutoplayState = 'paused' - this.syncAutoplayWithViewport() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'pause' }) - } + protected override applyDotInactiveStyle(dot: HTMLButtonElement): void { + dot.classList.remove('bg-primary', 'w-6') + dot.classList.add('bg-content-active', 'w-3') + dot.removeAttribute('aria-current') } - resume(): void { - try { - this.requestedAutoplayState = 'playing' - this.syncAutoplayWithViewport() - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'resume' }) - } + protected override getDotAriaLabel(index: number): string { + return `Go to testimonial ${index + 1}` } - private setupViewportObserver(): void { - if (typeof document === 'undefined') return - if (this.intersectionObserver) return - - try { - const IntersectionObserverCtor = ( - globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } - ).IntersectionObserver + // ────────────────────── Private helpers ────────────────────── - if (typeof IntersectionObserverCtor !== 'function') return + private ensureTestimonialsViewportId(): void { + const vp = this.viewport + if (!vp || this.viewportId) return - this.intersectionObserver = new IntersectionObserverCtor( - entries => { - const entry = entries[0] - const ratio = entry?.intersectionRatio ?? 0 - this.isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) - this.syncAutoplayWithViewport() - }, - { threshold: [0, 0.999] } - ) - - const observedTarget = this.emblaRoot ?? this - this.intersectionObserver.observe(observedTarget) - } catch { - // Best effort only - } - } - - private teardownViewportObserver(): void { - try { - this.intersectionObserver?.disconnect() - this.intersectionObserver = undefined - this.isFullyInViewport = true - } catch { - // Best effort only - } - } - - private syncAutoplayWithViewport(): void { - const shouldPlay = this.requestedAutoplayState === 'playing' && this.isFullyInViewport - this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') + const existingId = vp.getAttribute('id') + const nextId = + existingId && existingId.length > 0 ? existingId : `${this.animationInstanceId}-viewport` + vp.setAttribute('id', nextId) + this.viewportId = nextId } - private setAutoplayState(state: 'playing' | 'paused'): void { - this.setAttribute('data-carousel-autoplay', state) - this.syncAutoplayToggleButton(state) + private setupAriaControls(): void { + if (!this.viewportId) return + this.prevBtn?.setAttribute('aria-controls', this.viewportId) + this.nextBtn?.setAttribute('aria-controls', this.viewportId) } private setupAutoplayToggle(): void { - if (!this.autoplayToggleBtn) { - return - } + if (!this.autoplayToggleBtn) return - this.ensureViewportId() if (this.viewportId) { this.autoplayToggleBtn.setAttribute('aria-controls', this.viewportId) } @@ -511,9 +146,7 @@ export class TestimonialsCarouselElement extends HTMLElement { } private syncAutoplayToggleButton(state: 'playing' | 'paused'): void { - if (!this.autoplayToggleBtn) { - return - } + if (!this.autoplayToggleBtn) return const pauseIcon = queryTestimonialsAutoplayPauseIcon(this.autoplayToggleBtn) const playIcon = queryTestimonialsAutoplayPlayIcon(this.autoplayToggleBtn) @@ -531,93 +164,6 @@ export class TestimonialsCarouselElement extends HTMLElement { if (isType1Element(pauseIcon)) pauseIcon.classList.add('hidden') if (isType1Element(playIcon)) playIcon.classList.remove('hidden') } - - private ensureViewportId(): void { - if (!this.viewport || this.viewportId) { - return - } - - const existingId = this.viewport.getAttribute('id') - const nextId = - existingId && existingId.length > 0 ? existingId : `${this.animationInstanceId}-viewport` - this.viewport.setAttribute('id', nextId) - this.viewportId = nextId - } - - private updateAutoplayState(state: AnimationPlayState): void { - if ( - !this.hasAutoplaySupport || - !this.autoplayPlugin || - !this.emblaApi || - !this.initialized || - !this.autoplayReady - ) { - if (this.hasAutoplaySupport) { - this.pendingAutoplayState = state - this.setAutoplayState(state) - } else { - this.pendingAutoplayState = null - this.setAutoplayState('paused') - } - return - } - - this.pendingAutoplayState = null - if (state === 'playing') { - this.autoplayPlugin.play() - } else { - this.autoplayPlugin.stop() - } - this.setAutoplayState(state) - } - - private flushPendingAutoplayState(): void { - if (!this.pendingAutoplayState || !this.autoplayReady || !this.autoplayPlugin) return - - const pendingState = this.pendingAutoplayState - this.pendingAutoplayState = null - - try { - this.updateAutoplayState(pendingState) - } catch (error) { - handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'flushAutoplay' }) - } - } - - private registerAnimationLifecycle(): void { - if (this.animationController || !this.hasAutoplaySupport) return - this.animationController = createAnimationController({ - animationId: 'testimonials-carousel', - instanceId: this.animationInstanceId, - debugLabel: SCRIPT_NAME, - onPlay: () => { - this.resume() - }, - onPause: () => { - this.pause() - }, - }) - } - - private scheduleAutoplayReady(): void { - if (this.autoplayReadyScheduled || !this.autoplayPlugin) return - this.autoplayReadyScheduled = true - - const markReady = () => { - this.autoplayReadyScheduled = false - this.autoplayReadyTimer = null - if (!this.initialized || !this.autoplayPlugin) return - this.autoplayReady = true - this.flushPendingAutoplayState() - } - - if (typeof window === 'undefined') { - markReady() - return - } - - this.autoplayReadyTimer = window.setTimeout(markReady, 0) - } } declare global { diff --git a/src/components/Testimonials/index.astro b/src/components/Testimonials/index.astro index 4bce0f2cb..54c66e47d 100644 --- a/src/components/Testimonials/index.astro +++ b/src/components/Testimonials/index.astro @@ -4,9 +4,9 @@ import { getCollection } from 'astro:content' import Icon from '@components/Icon/index.astro' import Card from './Card.astro' -import type { TestimonialsProps } from './props' +import type { TestimonialsProps } from './@types' -export type { TestimonialsProps as Props } from './props' +export type { TestimonialsProps as Props } from './@types' const { title = 'Testimonials', @@ -14,6 +14,7 @@ const { limit, count, organization, + showControls = true, } = Astro.props as TestimonialsProps // Get all testimonials from content collection @@ -57,20 +58,18 @@ const singleTestimonial = shouldRenderSingle ? testimonials.at(0) : undefined { testimonials.length > 0 && (shouldRenderSingle ? ( -
-
-
-

{title}

- {leadText && ( -

{leadText}

- )} -
- -
- {singleTestimonial && } -
+
+
+

{title}

+ {leadText && ( +

{leadText}

+ )} +
+ +
+ {singleTestimonial && }
-
+

) : ( -
-
-
-

{title}

- {leadText && ( -

{leadText}

- )} -
- - {/* Embla Carousel for Testimonials */} -
-
-
- {testimonials.map((testimonial, index) => ( -
- -
- ))} -
+
+
+

+ {title} +

+ {leadText && ( +

{leadText}

+ )} +
+ + {/* Embla Carousel for Testimonials */} +
+
+
+ {testimonials.map((testimonial, index) => ( +
+ +
+ ))}
+
- {/* Navigation Buttons */} - {testimonials.length > 1 && ( - <> - - - + + + + + + {showControls && (
- +
- - )} -
+ )} + + )}
-
+
)) } diff --git a/src/components/ThemePicker/client/index.ts b/src/components/ThemePicker/client/index.ts index aa9f0a0c1..c324b88d5 100644 --- a/src/components/ThemePicker/client/index.ts +++ b/src/components/ThemePicker/client/index.ts @@ -6,6 +6,10 @@ import { LitElement } from 'lit' import EmblaCarousel, { type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel' +import { + createEmblaNavStateUpdater, + type EmblaNavButtonHandle, +} from '@components/scripts/embla' import { setTheme, toggleThemePicker, @@ -69,6 +73,7 @@ export class ThemePickerElement extends LitElement { private emblaPrevBtn!: HTMLButtonElement private emblaNextBtn!: HTMLButtonElement private emblaApi: EmblaCarouselType | null = null + private emblaNavHandle: EmblaNavButtonHandle | null = null private emblaControlsBound = false private lastIsOpen: boolean | null = null private lastTheme: ThemeId | null = null @@ -80,7 +85,7 @@ export class ThemePickerElement extends LitElement { private readonly tooltipId = 'theme-picker-tooltip' - private readonly emblaUpdateHandler = () => this.updateEmblaNavState() + private readonly emblaUpdateHandler = () => this.updateEmblaOverflowVisibility() // Track View Transitions private isTransitioning = false @@ -231,7 +236,7 @@ export class ThemePickerElement extends LitElement { scheduleNextFrame(() => { try { this.emblaApi?.reInit() - this.updateEmblaNavState() + this.updateEmblaOverflowVisibility() this.scrollThemeIntoView(currentTheme) } catch (error) { handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:reInit' }) @@ -250,7 +255,16 @@ export class ThemePickerElement extends LitElement { try { this.emblaApi = EmblaCarousel(this.emblaViewport, THEME_PICKER_EMBLA_OPTIONS) - this.updateEmblaNavState() + + // Disabled-state tracking via shared utility (re-binds Embla events each setup) + this.emblaNavHandle = createEmblaNavStateUpdater( + this.emblaApi, + this.emblaPrevBtn, + this.emblaNextBtn + ) + + // ThemePicker-specific: hide buttons when no overflow + this.updateEmblaOverflowVisibility() const emblaWithEvents = this.emblaApi as EmblaCarouselType & { on: (_event: string, _handler: () => void) => EmblaCarouselType @@ -259,6 +273,7 @@ export class ThemePickerElement extends LitElement { emblaWithEvents.on('select', this.emblaUpdateHandler) emblaWithEvents.on('reInit', this.emblaUpdateHandler) + // Click handlers bound only once (Embla instances are recreated on open/close) if (!this.emblaControlsBound) { addButtonEventListeners( this.emblaPrevBtn, @@ -301,13 +316,18 @@ export class ThemePickerElement extends LitElement { } private teardownEmbla(): void { + this.emblaNavHandle = null if (this.emblaApi) { this.emblaApi.destroy() this.emblaApi = null } } - private updateEmblaNavState(): void { + /** + * ThemePicker-specific: hide nav buttons entirely when the carousel + * has no overflow (all themes visible without scrolling). + */ + private updateEmblaOverflowVisibility(): void { if (!this.emblaApi || !this.emblaPrevBtn || !this.emblaNextBtn) return const hasOverflow = this.emblaApi.scrollSnapList().length > 1 @@ -322,17 +342,8 @@ export class ThemePickerElement extends LitElement { this.emblaPrevBtn.removeAttribute('hidden') this.emblaNextBtn.removeAttribute('hidden') - if (this.emblaApi.canScrollPrev()) { - this.emblaPrevBtn.removeAttribute('disabled') - } else { - this.emblaPrevBtn.setAttribute('disabled', 'true') - } - - if (this.emblaApi.canScrollNext()) { - this.emblaNextBtn.removeAttribute('disabled') - } else { - this.emblaNextBtn.setAttribute('disabled', 'true') - } + // Delegate disabled state to the shared nav handle + this.emblaNavHandle?.update() } private scrollThemeIntoView(themeId: ThemeId): void { @@ -344,7 +355,7 @@ export class ThemePickerElement extends LitElement { try { this.emblaApi.scrollTo(index, true) - this.updateEmblaNavState() + this.updateEmblaOverflowVisibility() } catch (error) { handleScriptError(error, { scriptName: 'ThemePickerElement', @@ -521,7 +532,7 @@ export class ThemePickerElement extends LitElement { portal.setAttribute('id', this.tooltipId) portal.setAttribute('role', 'tooltip') portal.className = - 'fixed left-0 top-0 z-(--z-modal) pointer-events-none opacity-0 transition-opacity duration-150 ease-out' + 'fixed left-0 top-0 z-(--z-theme-picker) pointer-events-none opacity-0 transition-opacity duration-150 ease-out' const content = document.createElement('div') content.className = diff --git a/src/components/Toc/index.astro b/src/components/Toc/index.astro index 19365ea93..fd79ed014 100644 --- a/src/components/Toc/index.astro +++ b/src/components/Toc/index.astro @@ -62,7 +62,7 @@ const tocTree = buildTocTree(items) href={`#${node.slug}`} data-toc-link data-toc-slug={node.slug} - class="block mb-2 text-sm sm:text-md text-content-offset transition-colors hover:text-content-active data-[current=true]:text-content no-underline hover:underline" + class="block mb-2 text-sm sm:text-base text-content-offset transition-colors hover:text-content-active data-[current=true]:text-content no-underline hover:underline" > {node.text} diff --git a/src/components/scripts/@types/webComponentModule.ts b/src/components/scripts/@types/webComponentModule.ts index ecc0f9692..d6af99a43 100644 --- a/src/components/scripts/@types/webComponentModule.ts +++ b/src/components/scripts/@types/webComponentModule.ts @@ -1,4 +1,6 @@ -export interface WebComponentModule { +import type { LitElement } from 'lit' + +export interface WebComponentModule { registeredName: string componentCtor: CustomElementConstructor & { prototype: TElement } registerWebComponent: (_tagName?: string) => Promise | void diff --git a/src/components/scripts/elementListeners/index.ts b/src/components/scripts/elementListeners/index.ts index be7b89a16..7c074dcd1 100644 --- a/src/components/scripts/elementListeners/index.ts +++ b/src/components/scripts/elementListeners/index.ts @@ -50,9 +50,17 @@ const getTouchendEventListener = (handler: eventHandler, context?: unknown) => { return touchendEventHandler } -const getEscapeKeyEventListener = (handler: eventHandler, context?: unknown) => { +type WrapperKeyupOptions = { + allowedKeys?: string[] +} + +const getKeyupEventListener = ( + handler: eventHandler, + context?: unknown, + options?: WrapperKeyupOptions +) => { /** - * Escape key event listener wrapper + * Keyup event listener wrapper * @param event - Keyboard event */ function keypressListener(event: KeyboardEvent) { @@ -63,7 +71,12 @@ const getEscapeKeyEventListener = (handler: eventHandler, context?: unknown) => * `keydown` event and a key being held down. */ if (event.isComposing || event.repeat) return - if (event.key === `Escape`) handler.call(context, event) + + if (options?.allowedKeys?.length && !options.allowedKeys.includes(event.key)) { + return + } + + handler.call(context, event) } return keypressListener } @@ -96,9 +109,13 @@ export const addLinkEventListeners = ( export const addWrapperEventListeners = ( element: HTMLElement, handler: eventHandler, - context?: unknown + context?: unknown, + options?: WrapperKeyupOptions ) => { - element.addEventListener(`keyup`, getEscapeKeyEventListener(handler, context)) + const fallbackOptions: WrapperKeyupOptions = { allowedKeys: ['Escape'] } + const resolvedOptions = options?.allowedKeys?.length ? options : fallbackOptions + + element.addEventListener(`keyup`, getKeyupEventListener(handler, context, resolvedOptions)) } // Export the type for external use diff --git a/src/components/scripts/embla/EmblaCarouselBase.ts b/src/components/scripts/embla/EmblaCarouselBase.ts new file mode 100644 index 000000000..020a1b21f --- /dev/null +++ b/src/components/scripts/embla/EmblaCarouselBase.ts @@ -0,0 +1,720 @@ +/** + * Abstract base class for Embla-powered carousels rendered as Lit custom elements. + * + * Encapsulates the shared behaviour that was previously duplicated across the + * Carousel and Testimonials components: + * + * - Embla initialization / teardown (with optional Autoplay plugin) + * - Animation-lifecycle store integration (reduced-motion, visibility, user preferences) + * - IntersectionObserver viewport gating (pause when off-screen) + * - Focus-visible pause management + * - Previous / Next navigation button wiring + * - Dot-navigation rendering (with overridable active/inactive styling) + * - E2E logging + * + * Subclasses must implement the abstract members listed below and may + * optionally override the dot-styling hooks and `onInitialized()` / + * `onTeardown()` for component-specific setup. + */ + +import { LitElement } from 'lit' +import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel' +import Autoplay from 'embla-carousel-autoplay' +import { addButtonEventListeners } from '@components/scripts/elementListeners' +import { addScriptBreadcrumb } from '@components/scripts/errors' +import { handleScriptError } from '@components/scripts/errors/handler' +import { + createAnimationController, + type AnimationControllerHandle, + type AnimationPlayState, +} from '@components/scripts/store' +import { createE2ELogger } from './logging' +import type { + EmblaCarouselConfig, + EmblaElementHandles, + EmblaRootElement, + TimerHandle, +} from './types' + +export abstract class EmblaCarouselBase extends LitElement { + // ────────────────────── Light DOM ────────────────────── + override createRenderRoot() { + return this + } + + // ────────────────────── Abstract / Overridable ────────────────────── + + /** Return component-specific Embla + autoplay configuration. */ + protected abstract getConfig(): EmblaCarouselConfig + + /** Query and return the DOM handles for this instance. */ + protected abstract queryElements(): EmblaElementHandles + + /** + * Hook called after successful base initialization. + * Subclasses can wire up component-specific behaviour here + * (keyboard handling, autoplay toggle buttons, status regions, etc.). + */ + protected onInitialized(): void { + /* no-op by default */ + } + + /** + * Hook called during teardown, before the base class cleans up. + * Subclasses should remove any listeners added in `onInitialized()`. + */ + protected onTeardown(): void { + /* no-op by default */ + } + + /** + * Apply "active" styling to a dot button. + * Override in subclasses that need different active-dot visuals. + */ + protected applyDotActiveStyle(dot: HTMLButtonElement): void { + dot.classList.add('is-active', 'bg-primary') + dot.classList.remove('bg-content-active') + dot.setAttribute('aria-current', 'true') + } + + /** + * Apply "inactive" styling to a dot button. + * Override in subclasses that need different inactive-dot visuals. + */ + protected applyDotInactiveStyle(dot: HTMLButtonElement): void { + dot.classList.remove('is-active', 'bg-primary') + dot.classList.add('bg-content-active') + dot.removeAttribute('aria-current') + } + + /** + * Return the `aria-label` text for a dot at the given index. + * Override for custom wording (e.g. "Go to testimonial 3"). + */ + protected getDotAriaLabel(index: number): string { + return `Go to slide ${index + 1}` + } + + /** + * Hook called whenever the autoplay data-attribute changes. + * Override in subclasses that need to react to play/pause transitions + * (e.g. syncing an autoplay toggle button icon). + */ + protected onAutoplayStateChange(_state: 'playing' | 'paused'): void { + /* no-op by default */ + } + + /** + * Return `true` when a slide child currently matches `:focus-visible`. + * Override to use a component-specific selector helper (e.g. + * `hasCarouselFocusVisibleSlide(this)`). The default returns `false`. + */ + protected hasFocusVisibleSlide(): boolean { + return false + } + + /** + * Return `true` when the focused element is inside a slide container. + * Used by the base class to detect focus-visible pause triggers. + * Override to use a component-specific slide-container selector. + */ + protected isFocusInsideSlide(target: HTMLElement): boolean { + void target + return false + } + + // ────────────────────── Protected read-only accessors ────────────────────── + + /** The live Embla API instance, or `null` before initialization. */ + protected get emblaApi(): EmblaCarouselType | null { + return this._emblaApi + } + + /** Whether the carousel was successfully initialized. */ + protected get isInitialized(): boolean { + return this._initialized + } + + /** Whether the autoplay subsystem is active for this instance. */ + protected get hasAutoplaySupport(): boolean { + return this._hasAutoplaySupport + } + + /** The unique instance identifier (e.g. `carousel-1`). */ + protected get animationInstanceId(): string { + return this._animationInstanceId + } + + /** The Embla viewport element, or `null` before initialization. */ + protected get viewport(): HTMLElement | null { + return this._viewport + } + + /** The previous-slide button, or `null` if not present. */ + protected get prevBtn(): HTMLButtonElement | null { + return this._prevBtn + } + + /** The next-slide button, or `null` if not present. */ + protected get nextBtn(): HTMLButtonElement | null { + return this._nextBtn + } + + // ────────────────────── Private state ────────────────────── + + private _emblaApi: EmblaCarouselType | null = null + private _autoplayPlugin: ReturnType | null = null + private _emblaRoot: EmblaRootElement | null = null + private _viewport: HTMLElement | null = null + private _dotsContainer: HTMLElement | null = null + private _prevBtn: HTMLButtonElement | null = null + private _nextBtn: HTMLButtonElement | null = null + private _initialized = false + private _animationController: AnimationControllerHandle | undefined + private _pendingAutoplayState: AnimationPlayState | null = null + private _hasAutoplaySupport = false + private _autoplayReady = false + private _autoplayReadyScheduled = false + private _autoplayReadyTimer: TimerHandle | null = null + private _intersectionObserver: IntersectionObserver | undefined + private _isFullyInViewport = true + private _requestedAutoplayState: AnimationPlayState = 'paused' + private _focusVisibleCheckRafId: number | null = null + private _animationInstanceId = '' + private _viewportId: string | null = null + + private _config!: EmblaCarouselConfig + private _logForE2E!: ReturnType + + // Bound handlers (stable references for add/removeEventListener) + private readonly _autoplayPlayHandler = () => this.setAutoplayDataAttr('playing') + private readonly _autoplayStopHandler = () => this.setAutoplayDataAttr('paused') + private readonly _focusInHandler = (event: FocusEvent) => this.handleFocusIn(event) + private readonly _focusOutHandler = () => this.scheduleFocusVisiblePauseSync() + + private static _instanceCounter = 0 + + // ────────────────────── Lit Lifecycle ────────────────────── + + override connectedCallback(): void { + super.connectedCallback() + if (typeof document === 'undefined') return + + EmblaCarouselBase._instanceCounter += 1 + this._config = this.getConfig() + this._animationInstanceId = `${this._config.logPrefix}-${EmblaCarouselBase._instanceCounter}` + this._logForE2E = createE2ELogger(this._config.logPrefix) + + const context = { scriptName: this._config.scriptName, operation: 'connectedCallback' } + addScriptBreadcrumb(context) + + try { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => this.initialize(), { once: true }) + return + } + this.initialize() + } catch (error) { + handleScriptError(error, context) + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback() + if (typeof document === 'undefined') return + this.teardown() + } + + // ────────────────────── initialization ────────────────────── + + private initialize(): void { + if (this._initialized) return + + const context = { scriptName: this._config.scriptName, operation: 'initialize' } + addScriptBreadcrumb(context) + this._logForE2E('info', 'initialize:start', { + id: this._animationInstanceId, + existingReadyState: this.getAttribute('data-carousel-ready') ?? 'missing', + }) + + try { + const handles = this.queryElements() + this._emblaRoot = handles.emblaRoot + this._viewport = handles.viewport + this._dotsContainer = handles.dotsContainer ?? null + this._prevBtn = handles.prevBtn ?? null + this._nextBtn = handles.nextBtn ?? null + + this.ensureViewportId() + + const viewport = handles.viewport + const slideCount = handles.slideCount + + // Autoplay plugin (only if options provided and multiple slides) + const autoplayOpts = this._config.autoplayOptions + const requestedAutoplay = + autoplayOpts && slideCount > 1 ? Autoplay({ ...autoplayOpts }) : null + this._autoplayReady = false + this._autoplayReadyScheduled = false + const plugins = requestedAutoplay ? [requestedAutoplay] : [] + + this._emblaApi = EmblaCarousel(viewport, this._config.emblaOptions, plugins) + this._emblaRoot.__emblaApi__ = this._emblaApi + + const emblaSnapCount = this._emblaApi.scrollSnapList().length + const supportsAutoplay = slideCount > 1 && emblaSnapCount > 1 && requestedAutoplay !== null + this._hasAutoplaySupport = supportsAutoplay + this._autoplayPlugin = supportsAutoplay ? requestedAutoplay : null + this.setAutoplayDataAttr('paused') + this._requestedAutoplayState = 'paused' + + if (this._autoplayPlugin) { + const emblaWithEvents = this._emblaApi as EmblaCarouselType & { + on: (_event: string, _handler: () => void) => EmblaCarouselType + } + emblaWithEvents.on('autoplay:play', this._autoplayPlayHandler) + emblaWithEvents.on('autoplay:stop', this._autoplayStopHandler) + } + + this.setupNavigationButtons() + this.setupDotsNavigation() + this.addEventListener('focusin', this._focusInHandler) + this.addEventListener('focusout', this._focusOutHandler) + + this._initialized = true + this.setAttribute('data-carousel-ready', 'true') + + this._logForE2E('info', 'initialize:complete', { + id: this._animationInstanceId, + slideCount, + supportsAutoplay: this._hasAutoplaySupport, + }) + + if (this._hasAutoplaySupport) { + this.registerAnimationLifecycle() + this.setupViewportObserver() + this.scheduleAutoplayReady() + this.scheduleFocusVisiblePauseSync() + } + + // Subclass hook + this.onInitialized() + } catch (error) { + this.teardown() + this._logForE2E('error', 'initialize:failed', { + id: this._animationInstanceId, + message: error instanceof Error ? error.message : String(error), + }) + handleScriptError(error, context) + } + } + + // ────────────────────── Teardown ────────────────────── + + private teardown(): void { + // Subclass hook (runs first so it can still read base state) + this.onTeardown() + + this.teardownViewportObserver() + this.removeEventListener('focusin', this._focusInHandler) + this.removeEventListener('focusout', this._focusOutHandler) + + if (typeof window !== 'undefined' && this._focusVisibleCheckRafId !== null) { + window.cancelAnimationFrame(this._focusVisibleCheckRafId) + this._focusVisibleCheckRafId = null + } + + if (this._emblaApi) { + const emblaWithEvents = this._emblaApi as EmblaCarouselType & { + off: (_event: string, _handler: () => void) => EmblaCarouselType + } + if (this._autoplayPlugin) { + emblaWithEvents.off('autoplay:play', this._autoplayPlayHandler) + emblaWithEvents.off('autoplay:stop', this._autoplayStopHandler) + } + this._emblaApi.destroy() + } + + if (this._emblaRoot && '__emblaApi__' in this._emblaRoot) { + delete this._emblaRoot.__emblaApi__ + } + + this._emblaApi = null + this._autoplayPlugin = null + this._viewport = null + this._viewportId = null + this._initialized = false + this.removeAttribute('data-carousel-ready') + this.removeAttribute('data-carousel-autoplay') + this._animationController?.setInstancePauseState('focus-visible', false) + this._animationController?.destroy() + this._animationController = undefined + this._pendingAutoplayState = null + this._hasAutoplaySupport = false + + if (this._autoplayReadyTimer) { + clearTimeout(this._autoplayReadyTimer) + this._autoplayReadyTimer = null + } + this._autoplayReadyScheduled = false + this._autoplayReady = false + + if (this._dotsContainer) { + this._dotsContainer.innerHTML = '' + } + } + + // ────────────────────── Focus-visible pause ────────────────────── + + private handleFocusIn(event: FocusEvent): void { + if (!this._hasAutoplaySupport || !this._animationController) return + + const target = event.target + if (!(target instanceof HTMLElement)) return + + if (!this.isFocusInsideSlide(target)) return + if (!this.isElementFocusVisible(target)) return + this._animationController.setInstancePauseState('focus-visible', true) + } + + private scheduleFocusVisiblePauseSync(): void { + if (!this._hasAutoplaySupport || !this._animationController) return + + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + this.syncFocusVisiblePauseState() + return + } + + if (this._focusVisibleCheckRafId !== null) { + window.cancelAnimationFrame(this._focusVisibleCheckRafId) + } + + this._focusVisibleCheckRafId = window.requestAnimationFrame(() => { + this._focusVisibleCheckRafId = null + this.syncFocusVisiblePauseState() + }) + } + + private syncFocusVisiblePauseState(): void { + if (!this._animationController) return + this._animationController.setInstancePauseState('focus-visible', this.hasFocusVisibleSlide()) + } + + private isElementFocusVisible(element: HTMLElement): boolean { + try { + return element.matches(':focus-visible') + } catch { + return false + } + } + + // ────────────────────── Navigation buttons ────────────────────── + + private setupNavigationButtons(): void { + if (!this._emblaApi || !this._prevBtn || !this._nextBtn) return + + const scriptName = this._config.scriptName + const viewportId = this.getResolvedViewportId() + + const updateButtonStates = () => { + if (!this._emblaApi || !this._prevBtn || !this._nextBtn) return + + if (this._emblaApi.canScrollPrev()) { + this._prevBtn.removeAttribute('disabled') + this._prevBtn.classList.remove('opacity-30', 'cursor-not-allowed') + } else { + this._prevBtn.setAttribute('disabled', 'true') + this._prevBtn.classList.add('opacity-30', 'cursor-not-allowed') + } + + if (this._emblaApi.canScrollNext()) { + this._nextBtn.removeAttribute('disabled') + this._nextBtn.classList.remove('opacity-30', 'cursor-not-allowed') + } else { + this._nextBtn.setAttribute('disabled', 'true') + this._nextBtn.classList.add('opacity-30', 'cursor-not-allowed') + } + } + + addButtonEventListeners( + this._prevBtn, + () => { + try { + this._emblaApi?.scrollPrev() + } catch (error) { + handleScriptError(error, { scriptName, operation: 'scrollPrev' }) + } + }, + this + ) + + if (viewportId) { + this._prevBtn.setAttribute('aria-controls', viewportId) + this._nextBtn.setAttribute('aria-controls', viewportId) + } + + addButtonEventListeners( + this._nextBtn, + () => { + try { + this._emblaApi?.scrollNext() + } catch (error) { + handleScriptError(error, { scriptName, operation: 'scrollNext' }) + } + }, + this + ) + + this._emblaApi.on('select', updateButtonStates) + this._emblaApi.on('reInit', updateButtonStates) + updateButtonStates() + } + + // ────────────────────── Dots navigation ────────────────────── + + private setupDotsNavigation(): void { + if (!this._emblaApi || !this._dotsContainer) return + + const scriptName = this._config.scriptName + const dotsContainer = this._dotsContainer + const ownerDocument = this.ownerDocument ?? document + let dots: HTMLButtonElement[] = [] + + const rebuildDots = () => { + if (!this._emblaApi) return + dotsContainer.innerHTML = '' + dots = [] + + const viewportId = this.getResolvedViewportId() + + this._emblaApi.scrollSnapList().forEach((_, index) => { + const dot = ownerDocument.createElement('button') + dot.type = 'button' + dot.dataset['index'] = String(index) + dot.tabIndex = -1 + dot.setAttribute('tabindex', '-1') + dot.className = + 'embla__dot w-3 h-3 rounded-full bg-content-active transition-all duration-300 hover:bg-primary' + dot.setAttribute('aria-label', this.getDotAriaLabel(index)) + if (viewportId) { + dot.setAttribute('aria-controls', viewportId) + } + + addButtonEventListeners( + dot, + () => { + try { + this._emblaApi?.scrollTo(index) + } catch (error) { + handleScriptError(error, { scriptName, operation: 'scrollToDot' }) + } + }, + this + ) + + dotsContainer.appendChild(dot) + dots.push(dot) + }) + } + + const updateDots = () => { + if (!this._emblaApi) return + const selectedIndex = this._emblaApi.selectedScrollSnap() + + dots.forEach((dot, index) => { + if (index === selectedIndex) { + this.applyDotActiveStyle(dot) + } else { + this.applyDotInactiveStyle(dot) + } + }) + } + + rebuildDots() + updateDots() + + this._emblaApi.on('select', updateDots) + this._emblaApi.on('reInit', () => { + rebuildDots() + updateDots() + }) + } + + // ────────────────────── Pause / Resume (public API) ────────────────────── + + pause(): void { + try { + this._requestedAutoplayState = 'paused' + this.syncAutoplayWithViewport() + } catch (error) { + handleScriptError(error, { scriptName: this._config.scriptName, operation: 'pause' }) + } + } + + resume(): void { + try { + this._requestedAutoplayState = 'playing' + this.syncAutoplayWithViewport() + } catch (error) { + handleScriptError(error, { scriptName: this._config.scriptName, operation: 'resume' }) + } + } + + // ────────────────────── Viewport observer ────────────────────── + + private setupViewportObserver(): void { + if (typeof document === 'undefined') return + if (this._intersectionObserver) return + + try { + const IntersectionObserverCtor = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + ).IntersectionObserver + + if (typeof IntersectionObserverCtor !== 'function') return + + this._intersectionObserver = new IntersectionObserverCtor( + entries => { + const entry = entries[0] + const ratio = entry?.intersectionRatio ?? 0 + this._isFullyInViewport = Boolean(entry?.isIntersecting && ratio >= 0.999) + this.syncAutoplayWithViewport() + }, + { threshold: [0, 0.999] } + ) + + const observedTarget = this._emblaRoot ?? this + this._intersectionObserver.observe(observedTarget) + } catch { + // Best effort only + } + } + + private teardownViewportObserver(): void { + try { + this._intersectionObserver?.disconnect() + this._intersectionObserver = undefined + this._isFullyInViewport = true + } catch { + // Best effort only + } + } + + private syncAutoplayWithViewport(): void { + const shouldPlay = this._requestedAutoplayState === 'playing' && this._isFullyInViewport + this.updateAutoplayState(shouldPlay ? 'playing' : 'paused') + } + + // ────────────────────── Autoplay state management ────────────────────── + + private setAutoplayDataAttr(state: 'playing' | 'paused'): void { + this.setAttribute('data-carousel-autoplay', state) + this.onAutoplayStateChange(state) + } + + private updateAutoplayState(state: AnimationPlayState): void { + if ( + !this._hasAutoplaySupport || + !this._autoplayPlugin || + !this._emblaApi || + !this._initialized || + !this._autoplayReady + ) { + if (this._hasAutoplaySupport) { + this._pendingAutoplayState = state + this.setAutoplayDataAttr(state) + } else { + this._pendingAutoplayState = null + this.setAutoplayDataAttr('paused') + } + return + } + + this._pendingAutoplayState = null + if (state === 'playing') { + this._autoplayPlugin.play() + } else { + this._autoplayPlugin.stop() + } + this.setAutoplayDataAttr(state) + } + + private flushPendingAutoplayState(): void { + if (!this._pendingAutoplayState || !this._autoplayReady || !this._autoplayPlugin) return + + const pendingState = this._pendingAutoplayState + this._pendingAutoplayState = null + + try { + this.updateAutoplayState(pendingState) + } catch (error) { + handleScriptError(error, { + scriptName: this._config.scriptName, + operation: 'flushAutoplay', + }) + } + } + + // ────────────────────── Animation lifecycle ────────────────────── + + private registerAnimationLifecycle(): void { + if (this._animationController || !this._hasAutoplaySupport) return + this._animationController = createAnimationController({ + animationId: this._config.animationId, + instanceId: this._animationInstanceId, + debugLabel: this._config.scriptName, + onPlay: () => { + this.resume() + }, + onPause: () => { + this.pause() + }, + }) + } + + private scheduleAutoplayReady(): void { + if (this._autoplayReadyScheduled || !this._autoplayPlugin) return + this._autoplayReadyScheduled = true + + const markReady = () => { + this._autoplayReadyScheduled = false + this._autoplayReadyTimer = null + if (!this._initialized || !this._autoplayPlugin) return + this._autoplayReady = true + this.flushPendingAutoplayState() + } + + if (typeof window === 'undefined') { + markReady() + return + } + + this._autoplayReadyTimer = window.setTimeout(markReady, 0) + } + + private ensureViewportId(): void { + if (!this._viewport || this._viewportId) return + + const existingId = this._viewport.getAttribute('id') + const nextId = + existingId && existingId.length > 0 + ? existingId + : `${this._animationInstanceId}-viewport` + + this._viewport.setAttribute('id', nextId) + this._viewportId = nextId + } + + private getResolvedViewportId(): string | null { + if (this._viewportId) return this._viewportId + if (!this._viewport) return null + + const existingId = this._viewport.getAttribute('id') + if (existingId && existingId.length > 0) { + this._viewportId = existingId + return existingId + } + + return null + } +} diff --git a/src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts b/src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts new file mode 100644 index 000000000..3a0bce986 --- /dev/null +++ b/src/components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts @@ -0,0 +1,66 @@ +import { EmblaCarouselBase } from '../EmblaCarouselBase' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import type { EmblaCarouselConfig, EmblaElementHandles } from '../types' +import { + queryEmblaDotsContainer, + queryEmblaNextButton, + queryEmblaPrevButton, + queryEmblaRoot, + queryEmblaSlides, + queryEmblaViewport, +} from './selectors' + +export class EmblaBaseFixtureElement extends EmblaCarouselBase { + static registeredName = 'embla-base-fixture' + + protected getConfig(): EmblaCarouselConfig { + return { + scriptName: 'EmblaBaseFixture', + logPrefix: 'embla-fixture', + animationId: 'embla-base-fixture', + emblaOptions: { loop: false }, + autoplayOptions: { + delay: 3000, + stopOnInteraction: true, + stopOnMouseEnter: true, + playOnInit: false, + }, + } + } + + protected queryElements(): EmblaElementHandles { + const emblaRoot = queryEmblaRoot(this) + const viewport = queryEmblaViewport(this) + const dotsContainer = queryEmblaDotsContainer(this) + const prevBtn = queryEmblaPrevButton(this) + const nextBtn = queryEmblaNextButton(this) + const slides = queryEmblaSlides(this) + + if (!emblaRoot || !viewport) { + throw new Error('Fixture markup missing embla root or viewport') + } + + return { + emblaRoot, + viewport, + dotsContainer, + prevBtn, + nextBtn, + slideCount: slides.length, + } + } +} + +export const registerWebComponent = async ( + tagName = EmblaBaseFixtureElement.registeredName +): Promise => { + if (typeof window === 'undefined') return + defineCustomElement(tagName, EmblaBaseFixtureElement) +} + +export const webComponentModule: WebComponentModule = { + registeredName: EmblaBaseFixtureElement.registeredName, + componentCtor: EmblaBaseFixtureElement, + registerWebComponent, +} \ No newline at end of file diff --git a/src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro b/src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro new file mode 100644 index 000000000..3d247586a --- /dev/null +++ b/src/components/scripts/embla/__fixtures__/emblaCarouselBase.fixture.astro @@ -0,0 +1,15 @@ +--- +--- + + +
+
+
Slide 1
+
Slide 2
+
Slide 3
+
+
+
+ + +
\ No newline at end of file diff --git a/src/components/scripts/embla/__fixtures__/selectors.ts b/src/components/scripts/embla/__fixtures__/selectors.ts new file mode 100644 index 000000000..291a734cb --- /dev/null +++ b/src/components/scripts/embla/__fixtures__/selectors.ts @@ -0,0 +1,43 @@ +import { + isButtonElement, + isDivElement, +} from '@components/scripts/assertions/elements' +import type { EmblaRootElement } from '../types' + +const SELECTORS = { + emblaRoot: '[data-embla-root]', + viewport: '[data-embla-viewport]', + dots: '[data-embla-dots]', + prevBtn: '[data-embla-prev]', + nextBtn: '[data-embla-next]', + slide: '[data-embla-slide]', +} as const + +export const queryEmblaRoot = (scope: ParentNode): EmblaRootElement | null => { + const element = scope.querySelector(SELECTORS.emblaRoot) + return isDivElement(element) ? (element as EmblaRootElement) : null +} + +export const queryEmblaViewport = (scope: ParentNode): HTMLDivElement | null => { + const element = scope.querySelector(SELECTORS.viewport) + return isDivElement(element) ? element : null +} + +export const queryEmblaDotsContainer = (scope: ParentNode): HTMLDivElement | null => { + const element = scope.querySelector(SELECTORS.dots) + return isDivElement(element) ? element : null +} + +export const queryEmblaPrevButton = (scope: ParentNode): HTMLButtonElement | null => { + const element = scope.querySelector(SELECTORS.prevBtn) + return isButtonElement(element) ? element : null +} + +export const queryEmblaNextButton = (scope: ParentNode): HTMLButtonElement | null => { + const element = scope.querySelector(SELECTORS.nextBtn) + return isButtonElement(element) ? element : null +} + +export const queryEmblaSlides = (scope: ParentNode): HTMLDivElement[] => { + return Array.from(scope.querySelectorAll(SELECTORS.slide)).filter(isDivElement) +} \ No newline at end of file diff --git a/src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts b/src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts new file mode 100644 index 000000000..cb61de34e --- /dev/null +++ b/src/components/scripts/embla/__tests__/EmblaCarouselBase.spec.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { EmblaCarouselType } from 'embla-carousel' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import { executeRender } from '@test/unit/helpers/litRuntime' +import EmblaCarouselBaseFixture from '../__fixtures__/emblaCarouselBase.fixture.astro' +import type { EmblaBaseFixtureElement } from '../__fixtures__/EmblaBaseFixture.client' + +type EmblaBaseModule = WebComponentModule + +const addButtonEventListenersMock = vi.hoisted(() => vi.fn()) +const addScriptBreadcrumbMock = vi.hoisted(() => vi.fn()) +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) +const createAnimationControllerMock = vi.hoisted(() => vi.fn()) + +const emblaInstanceRef = { current: null as EmblaApiMock | null } +const autoplayInstanceRef = { current: null as { play: ReturnType; stop: ReturnType } | null } + +interface EmblaApiMock { + canScrollPrev: ReturnType + canScrollNext: ReturnType + on: ReturnType + off: ReturnType + scrollPrev: ReturnType + scrollNext: ReturnType + scrollTo: ReturnType + destroy: ReturnType + scrollSnapList: ReturnType + selectedScrollSnap: ReturnType + __setCanScrollPrev: (_value: boolean) => void + __setCanScrollNext: (_value: boolean) => void + __setSelectedIndex: (_value: number) => void + __trigger: (_event: 'select' | 'reInit' | 'autoplay:play' | 'autoplay:stop') => void +} + +const createEmblaApiMock = (options: { + canScrollPrev: boolean + canScrollNext: boolean + snapCount: number + selectedIndex: number +}): EmblaApiMock => { + let canScrollPrevValue = options.canScrollPrev + let canScrollNextValue = options.canScrollNext + let selectedIndexValue = options.selectedIndex + const handlers = new Map void>>() + + return { + canScrollPrev: vi.fn(() => canScrollPrevValue), + canScrollNext: vi.fn(() => canScrollNextValue), + on: vi.fn((event: string, handler: () => void) => { + const existing = handlers.get(event) ?? [] + handlers.set(event, [...existing, handler]) + return undefined as unknown as EmblaCarouselType + }), + off: vi.fn((event: string) => { + handlers.delete(event) + return undefined as unknown as EmblaCarouselType + }), + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + scrollTo: vi.fn(), + destroy: vi.fn(), + scrollSnapList: vi.fn(() => Array.from({ length: options.snapCount })), + selectedScrollSnap: vi.fn(() => selectedIndexValue), + __setCanScrollPrev: (value: boolean) => { + canScrollPrevValue = value + }, + __setCanScrollNext: (value: boolean) => { + canScrollNextValue = value + }, + __setSelectedIndex: (value: number) => { + selectedIndexValue = value + }, + __trigger: (event: 'select' | 'reInit' | 'autoplay:play' | 'autoplay:stop') => { + const handlersForEvent = handlers.get(event) ?? [] + handlersForEvent.forEach(handler => { + handler() + }) + }, + } +} + +vi.mock('embla-carousel', () => ({ + default: vi.fn(() => { + const emblaApi = createEmblaApiMock({ + canScrollPrev: false, + canScrollNext: true, + snapCount: 3, + selectedIndex: 0, + }) + emblaInstanceRef.current = emblaApi + return emblaApi + }), +})) + +vi.mock('embla-carousel-autoplay', () => ({ + default: vi.fn(() => { + const instance = { + play: vi.fn(), + stop: vi.fn(), + } + autoplayInstanceRef.current = instance + return instance + }), +})) + +vi.mock('@components/scripts/elementListeners', () => ({ + addButtonEventListeners: addButtonEventListenersMock, +})) + +vi.mock('@components/scripts/errors', () => ({ + addScriptBreadcrumb: addScriptBreadcrumbMock, +})) + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + +vi.mock('@components/scripts/store', async () => { + const actual = await vi.importActual( + '@components/scripts/store' + ) + + return { + ...actual, + createAnimationController: createAnimationControllerMock, + } +}) + +describe('EmblaCarouselBase', () => { + let container: AstroContainer + + beforeEach(async () => { + container = await AstroContainer.create() + emblaInstanceRef.current = null + autoplayInstanceRef.current = null + addButtonEventListenersMock.mockReset() + createAnimationControllerMock.mockReturnValue({ + requestPlay: vi.fn(), + requestPause: vi.fn(), + setInstancePauseState: vi.fn(), + clearUserPreference: vi.fn(), + destroy: vi.fn(), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + const renderFixture = async ( + assertion: (_context: { element: EmblaBaseFixtureElement }) => Promise | void + ): Promise => { + await executeRender({ + container, + component: EmblaCarouselBaseFixture, + moduleSpecifier: '@components/scripts/embla/__fixtures__/EmblaBaseFixture.client.ts', + selector: 'embla-base-fixture', + assert: async ({ element }) => { + await assertion({ element }) + }, + }) + } + + it('initializes embla, nav buttons, dots, and aria controls', async () => { + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + expect(emblaApi).toBeTruthy() + + expect(element.getAttribute('data-carousel-ready')).toBe('true') + + const viewport = element.querySelector('[data-embla-viewport]') as HTMLElement | null + expect(viewport).toBeTruthy() + const viewportId = viewport?.getAttribute('id') ?? '' + expect(viewportId.length).toBeGreaterThan(0) + + const prevBtn = element.querySelector('[data-embla-prev]') as HTMLButtonElement | null + const nextBtn = element.querySelector('[data-embla-next]') as HTMLButtonElement | null + expect(prevBtn?.getAttribute('aria-controls')).toBe(viewportId) + expect(nextBtn?.getAttribute('aria-controls')).toBe(viewportId) + + const dotsContainer = element.querySelector('[data-embla-dots]') as HTMLElement | null + expect(dotsContainer).toBeTruthy() + const dots = Array.from(dotsContainer?.querySelectorAll('button') ?? []) + expect(dots.length).toBe(3) + + const activeDot = dots[0] + const inactiveDot = dots[1] + if (!activeDot || !inactiveDot) { + throw new Error('Expected at least two dot buttons') + } + + expect(activeDot.getAttribute('aria-current')).toBe('true') + expect(activeDot.classList.contains('is-active')).toBe(true) + expect(activeDot.getAttribute('aria-controls')).toBe(viewportId) + + expect(inactiveDot.getAttribute('aria-current')).toBeNull() + expect(inactiveDot.classList.contains('is-active')).toBe(false) + }) + }) + + it('updates navigation and dot state on Embla events', async () => { + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + if (!emblaApi) { + throw new Error('Expected Embla API instance') + } + + const prevBtn = element.querySelector('[data-embla-prev]') as HTMLButtonElement | null + const nextBtn = element.querySelector('[data-embla-next]') as HTMLButtonElement | null + const dotsContainer = element.querySelector('[data-embla-dots]') as HTMLElement | null + const dots = Array.from(dotsContainer?.querySelectorAll('button') ?? []) + + expect(prevBtn?.getAttribute('disabled')).toBe('true') + expect(nextBtn?.hasAttribute('disabled')).toBe(false) + + emblaApi.__setCanScrollPrev(true) + emblaApi.__setCanScrollNext(false) + emblaApi.__setSelectedIndex(2) + emblaApi.__trigger('select') + + expect(prevBtn?.hasAttribute('disabled')).toBe(false) + expect(nextBtn?.getAttribute('disabled')).toBe('true') + + const thirdDot = dots[2] + if (!thirdDot) { + throw new Error('Expected third dot button to exist') + } + expect(thirdDot.getAttribute('aria-current')).toBe('true') + }) + }) + + it('wires dot and nav click handlers to Embla methods', async () => { + const buttonHandlers = new Map void>() + addButtonEventListenersMock.mockImplementation((button, handler) => { + buttonHandlers.set(button as HTMLElement, handler as () => void) + }) + + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + if (!emblaApi) { + throw new Error('Expected Embla API instance') + } + + const prevBtn = element.querySelector('[data-embla-prev]') as HTMLButtonElement | null + const nextBtn = element.querySelector('[data-embla-next]') as HTMLButtonElement | null + const dotsContainer = element.querySelector('[data-embla-dots]') as HTMLElement | null + const dots = Array.from(dotsContainer?.querySelectorAll('button') ?? []) + + if (!prevBtn || !nextBtn || dots.length === 0) { + throw new Error('Missing navigation elements') + } + + const prevHandler = buttonHandlers.get(prevBtn) + const nextHandler = buttonHandlers.get(nextBtn) + const secondDot = dots[1] + if (!secondDot) { + throw new Error('Expected second dot button to exist') + } + const secondDotHandler = buttonHandlers.get(secondDot) + + prevHandler?.() + nextHandler?.() + secondDotHandler?.() + + expect(emblaApi.scrollPrev).toHaveBeenCalled() + expect(emblaApi.scrollNext).toHaveBeenCalled() + expect(emblaApi.scrollTo).toHaveBeenCalledWith(1) + }) + }) + + it('schedules autoplay and plays after resume when ready', async () => { + vi.useFakeTimers() + + await renderFixture(async ({ element }) => { + const autoplayInstance = autoplayInstanceRef.current + if (!autoplayInstance) { + throw new Error('Expected autoplay instance') + } + + element.resume() + vi.runAllTimers() + + expect(autoplayInstance.play).toHaveBeenCalled() + expect(element.getAttribute('data-carousel-autoplay')).toBe('playing') + + element.pause() + expect(autoplayInstance.stop).toHaveBeenCalled() + expect(element.getAttribute('data-carousel-autoplay')).toBe('paused') + + expect(createAnimationControllerMock).toHaveBeenCalled() + }) + }) + + it('tears down Embla state on disconnect', async () => { + await renderFixture(async ({ element }) => { + const emblaApi = emblaInstanceRef.current + if (!emblaApi) { + throw new Error('Expected Embla API instance') + } + + expect(element.getAttribute('data-carousel-ready')).toBe('true') + + element.remove() + + expect(emblaApi.destroy).toHaveBeenCalled() + expect(element.getAttribute('data-carousel-ready')).toBeNull() + expect(element.getAttribute('data-carousel-autoplay')).toBeNull() + }) + }) +}) \ No newline at end of file diff --git a/src/components/scripts/embla/__tests__/logging.spec.ts b/src/components/scripts/embla/__tests__/logging.spec.ts new file mode 100644 index 000000000..09590d193 --- /dev/null +++ b/src/components/scripts/embla/__tests__/logging.spec.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import { createE2ELogger } from '../logging' + +describe('createE2ELogger', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does nothing when window is undefined', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const logger = createE2ELogger('Test') + + logger('info', 'hello') + + expect(infoSpy).not.toHaveBeenCalled() + }) + + it('does not log when Playwright flag is missing', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const logger = createE2ELogger('Test') + + await withJsdomEnvironment(({ window }) => { + delete (window as Window & { isPlaywrightControlled?: boolean }).isPlaywrightControlled + logger('info', 'hello') + }) + + expect(infoSpy).not.toHaveBeenCalled() + }) + + it('logs message and details when running under Playwright', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const logger = createE2ELogger('Embla') + const details = { count: 2 } + + await withJsdomEnvironment(({ window }) => { + ;(window as Window & { isPlaywrightControlled?: boolean }).isPlaywrightControlled = true + logger('info', 'ready', details) + }) + + expect(infoSpy).toHaveBeenCalledWith('[Embla] ready', details) + }) + + it('logs error when requested under Playwright', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const logger = createE2ELogger('Embla') + + await withJsdomEnvironment(({ window }) => { + ;(window as Window & { isPlaywrightControlled?: boolean }).isPlaywrightControlled = true + logger('error', 'failed') + }) + + expect(errorSpy).toHaveBeenCalledWith('[Embla] failed') + }) +}) \ No newline at end of file diff --git a/src/components/scripts/embla/__tests__/navigation.spec.ts b/src/components/scripts/embla/__tests__/navigation.spec.ts new file mode 100644 index 000000000..66a1b4cca --- /dev/null +++ b/src/components/scripts/embla/__tests__/navigation.spec.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import type { EmblaCarouselType } from 'embla-carousel' + +const addButtonEventListenersMock = vi.hoisted(() => vi.fn()) +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) + +vi.mock('@components/scripts/elementListeners', () => ({ + addButtonEventListeners: addButtonEventListenersMock, +})) + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + +import { createEmblaNavStateUpdater, setupEmblaNavButtons } from '../navigation' + +interface EmblaApiMock { + canScrollPrev: ReturnType + canScrollNext: ReturnType + on: ReturnType + scrollPrev: ReturnType + scrollNext: ReturnType + __setCanScrollPrev: (_value: boolean) => void + __setCanScrollNext: (_value: boolean) => void + __trigger: (_event: 'select' | 'reInit') => void +} + +/** Create a mock Embla API with mutable state and event handlers. */ +const createEmblaApiMock = (options: { canScrollPrev: boolean; canScrollNext: boolean }): EmblaApiMock => { + let canScrollPrevValue = options.canScrollPrev + let canScrollNextValue = options.canScrollNext + const handlers = new Map<'select' | 'reInit', () => void>() + + return { + canScrollPrev: vi.fn(() => canScrollPrevValue), + canScrollNext: vi.fn(() => canScrollNextValue), + on: vi.fn((event: 'select' | 'reInit', handler: () => void) => { + handlers.set(event, handler) + return undefined as unknown as EmblaCarouselType + }), + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + __setCanScrollPrev: (value: boolean) => { + canScrollPrevValue = value + }, + __setCanScrollNext: (value: boolean) => { + canScrollNextValue = value + }, + __trigger: (event: 'select' | 'reInit') => { + handlers.get(event)?.() + }, + } as EmblaApiMock +} + +/** Create button elements for navigation tests. */ +const createButtons = (window: Window) => ({ + prevBtn: window.document.createElement('button'), + nextBtn: window.document.createElement('button'), +}) + +describe('createEmblaNavStateUpdater', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('updates button disabled state and responds to Embla events', async () => { + await withJsdomEnvironment(({ window }) => { + const emblaApi = createEmblaApiMock({ canScrollPrev: false, canScrollNext: true }) + const { prevBtn, nextBtn } = createButtons(window) + + createEmblaNavStateUpdater(emblaApi as unknown as EmblaCarouselType, prevBtn, nextBtn) + + expect(prevBtn.getAttribute('disabled')).toBe('true') + expect(nextBtn.hasAttribute('disabled')).toBe(false) + + emblaApi.__setCanScrollPrev(true) + emblaApi.__setCanScrollNext(false) + emblaApi.__trigger('select') + + expect(prevBtn.hasAttribute('disabled')).toBe(false) + expect(nextBtn.getAttribute('disabled')).toBe('true') + + expect(emblaApi.on).toHaveBeenCalledWith('select', expect.any(Function)) + expect(emblaApi.on).toHaveBeenCalledWith('reInit', expect.any(Function)) + }) + }) +}) + +describe('setupEmblaNavButtons', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('wires click handlers and updates disabled state', async () => { + await withJsdomEnvironment(({ window }) => { + const emblaApi = createEmblaApiMock({ canScrollPrev: true, canScrollNext: true }) + const { prevBtn, nextBtn } = createButtons(window) + const listenerContext = { id: 'theme-picker' } + + let prevHandler: (() => void) | undefined + let nextHandler: (() => void) | undefined + + addButtonEventListenersMock.mockImplementation((button, handler, context) => { + if (button === prevBtn) prevHandler = handler as () => void + if (button === nextBtn) nextHandler = handler as () => void + expect(context).toBe(listenerContext) + }) + + const handle = setupEmblaNavButtons( + emblaApi as unknown as EmblaCarouselType, + prevBtn, + nextBtn, + { scriptName: 'ThemePicker' }, + listenerContext + ) + + expect(prevHandler).toBeDefined() + expect(nextHandler).toBeDefined() + + prevHandler?.() + nextHandler?.() + + expect(emblaApi.scrollPrev).toHaveBeenCalled() + expect(emblaApi.scrollNext).toHaveBeenCalled() + + emblaApi.__setCanScrollPrev(false) + emblaApi.__setCanScrollNext(false) + handle.update() + + expect(prevBtn.getAttribute('disabled')).toBe('true') + expect(nextBtn.getAttribute('disabled')).toBe('true') + }) + }) + + it('reports errors from scroll handlers', async () => { + await withJsdomEnvironment(({ window }) => { + const emblaApi = createEmblaApiMock({ canScrollPrev: true, canScrollNext: true }) + const { prevBtn, nextBtn } = createButtons(window) + + emblaApi.scrollPrev.mockImplementation(() => { + throw new Error('prev failed') + }) + + emblaApi.scrollNext.mockImplementation(() => { + throw new Error('next failed') + }) + + let prevHandler: (() => void) | undefined + let nextHandler: (() => void) | undefined + + addButtonEventListenersMock.mockImplementation((button, handler) => { + if (button === prevBtn) prevHandler = handler as () => void + if (button === nextBtn) nextHandler = handler as () => void + }) + + setupEmblaNavButtons(emblaApi as unknown as EmblaCarouselType, prevBtn, nextBtn, { + scriptName: 'ThemePicker', + }) + + prevHandler?.() + nextHandler?.() + + expect(handleScriptErrorMock).toHaveBeenCalledWith(expect.any(Error), { + scriptName: 'ThemePicker', + operation: 'scrollPrev', + }) + expect(handleScriptErrorMock).toHaveBeenCalledWith(expect.any(Error), { + scriptName: 'ThemePicker', + operation: 'scrollNext', + }) + }) + }) +}) \ No newline at end of file diff --git a/src/components/scripts/embla/index.ts b/src/components/scripts/embla/index.ts new file mode 100644 index 000000000..171c21d0b --- /dev/null +++ b/src/components/scripts/embla/index.ts @@ -0,0 +1,17 @@ +/** + * Shared Embla carousel utilities - Barrel Export + */ +export { EmblaCarouselBase } from './EmblaCarouselBase' +export { createE2ELogger } from './logging' +export { + createEmblaNavStateUpdater, + setupEmblaNavButtons, + type EmblaNavButtonHandle, +} from './navigation' +export type { + AutoplayOptions, + EmblaCarouselConfig, + EmblaElementHandles, + EmblaRootElement, + TimerHandle, +} from './types' diff --git a/src/components/scripts/embla/logging.ts b/src/components/scripts/embla/logging.ts new file mode 100644 index 000000000..7f27b87d3 --- /dev/null +++ b/src/components/scripts/embla/logging.ts @@ -0,0 +1,17 @@ +/** + * Factory for E2E-only console logging. + * Logs are emitted only when running under Playwright (`window.isPlaywrightControlled`). + */ +export const createE2ELogger = (prefix: string) => { + return ( + level: 'info' | 'error', + message: string, + details?: Record + ): void => { + if (typeof window === 'undefined' || window.isPlaywrightControlled !== true) return + const payload = details + ? [`[${prefix}] ${message}`, details] + : [`[${prefix}] ${message}`] + console[level](...payload) + } +} diff --git a/src/components/scripts/embla/navigation.ts b/src/components/scripts/embla/navigation.ts new file mode 100644 index 000000000..439774f98 --- /dev/null +++ b/src/components/scripts/embla/navigation.ts @@ -0,0 +1,96 @@ +/** + * Lightweight Embla navigation-button wiring. + * + * Used by components that need prev/next buttons but do NOT need the full + * autoplay / animation-lifecycle / viewport-observer stack provided by + * `EmblaCarouselBase` (e.g. ThemePicker). + */ + +import type { EmblaCarouselType } from 'embla-carousel' +import { addButtonEventListeners } from '@components/scripts/elementListeners' +import { handleScriptError } from '@components/scripts/errors/handler' + +export interface EmblaNavButtonHandle { + /** Call after Embla `reInit` or `select` to refresh disabled states. */ + update: () => void +} + +/** + * Bind Embla `select` and `reInit` events to keep prev/next button + * disabled states in sync. Does **not** bind click handlers — use this + * when click handlers are managed separately (e.g. ThemePicker, which + * recreates the Embla instance on every open/close cycle but binds + * click listeners only once). + * + * Returns a handle whose `update()` can be called manually to force a + * refresh (e.g. after `emblaApi.reInit()`). + */ +export function createEmblaNavStateUpdater( + emblaApi: EmblaCarouselType, + prevBtn: HTMLButtonElement, + nextBtn: HTMLButtonElement +): EmblaNavButtonHandle { + const updateState = () => { + if (emblaApi.canScrollPrev()) { + prevBtn.removeAttribute('disabled') + } else { + prevBtn.setAttribute('disabled', 'true') + } + + if (emblaApi.canScrollNext()) { + nextBtn.removeAttribute('disabled') + } else { + nextBtn.setAttribute('disabled', 'true') + } + } + + emblaApi.on('select', updateState) + emblaApi.on('reInit', updateState) + updateState() + + return { update: updateState } +} + +/** + * Wire prev/next buttons to an Embla instance with auto-updating + * disabled states and error-safe click handlers. + * + * Returns a handle whose `update()` can be called manually; the function + * also binds Embla's `select` and `reInit` events automatically. + * + * Use `createEmblaNavStateUpdater` instead when click handlers must be + * bound independently of the Embla instance lifecycle. + */ +export function setupEmblaNavButtons( + emblaApi: EmblaCarouselType, + prevBtn: HTMLButtonElement, + nextBtn: HTMLButtonElement, + context: { scriptName: string }, + listenerContext?: unknown +): EmblaNavButtonHandle { + addButtonEventListeners( + prevBtn, + () => { + try { + emblaApi.scrollPrev() + } catch (error) { + handleScriptError(error, { scriptName: context.scriptName, operation: 'scrollPrev' }) + } + }, + listenerContext + ) + + addButtonEventListeners( + nextBtn, + () => { + try { + emblaApi.scrollNext() + } catch (error) { + handleScriptError(error, { scriptName: context.scriptName, operation: 'scrollNext' }) + } + }, + listenerContext + ) + + return createEmblaNavStateUpdater(emblaApi, prevBtn, nextBtn) +} diff --git a/src/components/scripts/embla/types.ts b/src/components/scripts/embla/types.ts new file mode 100644 index 000000000..f66a84523 --- /dev/null +++ b/src/components/scripts/embla/types.ts @@ -0,0 +1,52 @@ +import type { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel' + +/** An HTML element augmented with a debug reference to its Embla API instance. */ +export type EmblaRootElement = HTMLElement & { __emblaApi__?: EmblaCarouselType } + +/** Mirrors `setTimeout` / `window.setTimeout` return type for timer handles. */ +export type TimerHandle = ReturnType | number + +/** Autoplay plugin configuration passed to `embla-carousel-autoplay`. */ +export interface AutoplayOptions { + delay: number + stopOnInteraction: boolean + stopOnMouseEnter: boolean + playOnInit: boolean +} + +/** + * Configuration that each concrete carousel subclass must provide to + * `EmblaCarouselBase` so the base class can wire up Embla and optional + * autoplay / animation lifecycle features. + */ +export interface EmblaCarouselConfig { + /** Embla options (loop, align, etc.). */ + emblaOptions: EmblaOptionsType + /** + * Autoplay options or `null` to disable autoplay entirely. + * When `null`, no autoplay plugin is loaded and the animation lifecycle + * / viewport observer / focus-pause subsystems are skipped. + */ + autoplayOptions: AutoplayOptions | null + /** Unique animation ID for the animation-lifecycle store (e.g. `'carousel'`). */ + animationId: string + /** Script name used in error breadcrumbs (e.g. `'CarouselElement'`). */ + scriptName: string + /** Short prefix for E2E console logs (e.g. `'carousel'`). */ + logPrefix: string +} + +/** + * The DOM handles a subclass must query and return from `queryElements()`. + * Only `emblaRoot`, `viewport`, and `slideCount` are required; everything + * else is optional and will gracefully degrade when absent. + */ +export interface EmblaElementHandles { + emblaRoot: EmblaRootElement + viewport: HTMLElement + /** Total number of slide elements — used to determine autoplay eligibility. */ + slideCount: number + dotsContainer?: HTMLElement | null + prevBtn?: HTMLButtonElement | null + nextBtn?: HTMLButtonElement | null +} diff --git a/src/content.config.ts b/src/content.config.ts index 39135ffe3..9f6f5931d 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -204,6 +204,8 @@ const tagsCollection = defineCollection({ cover: image(), coverAlt: z.string(), featured: z.boolean().default(false), + logo: image().optional(), + isSkill: z.boolean().default(false), }), }) diff --git a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx index 4571e9a01..bf8c8e5f5 100644 --- a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx +++ b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radar screen showing single bright alert ping surrounded by faded noise halo, representing critical actionable cloud monitoring alert" author: "kevin-brown" publishDate: 2025-10-05 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "prometheus"] featured: true --- diff --git a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx index bfacbceed..1b0a5ced7 100644 --- a/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx +++ b/src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radar screen showing single bright alert ping surrounded by faded noise halo, representing critical actionable cloud monitoring alert" author: "kevin-brown" publishDate: 2025-10-05 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "prometheus"] featured: true --- diff --git a/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx b/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx index 2fa740b6e..0f8b34590 100644 --- a/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx +++ b/src/content/articles/api-deprecation-sunset-headers-consumer-migration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Countdown timer showing 30 days remaining with API traffic streams redirecting from deprecated endpoints to new endpoints" author: "kevin-brown" publishDate: 2023-06-04 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "typescript"] featured: true --- diff --git a/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx b/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx index 03dbbb886..e051b5cfb 100644 --- a/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx +++ b/src/content/articles/api-gateway-metrics-traces-logs-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "API gateway shown as transparent structure with illuminated request path revealing internal components like auth, rate limiting, and routing" author: "kevin-brown" publishDate: 2024-09-01 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "prometheus", "aws"] featured: true --- diff --git a/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx b/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx index a246340a9..c140d563a 100644 --- a/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx +++ b/src/content/articles/api-gateway-metrics-traces-logs-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "API gateway shown as transparent structure with illuminated request path revealing internal components like auth, rate limiting, and routing" author: "kevin-brown" publishDate: 2024-09-01 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "prometheus", "grafana", "aws", "terraform"] featured: true --- diff --git a/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx b/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx index 9eaa3e844..c87eb009c 100644 --- a/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx +++ b/src/content/articles/api-usage-metering-quotas-cost-attribution/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data flowing through metering pipeline with API requests passing through measurement gates and aggregating into usage buckets for cost calculations" author: "kevin-brown" publishDate: 2023-08-06 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "prometheus", "python"] featured: true --- diff --git a/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx b/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx index 47d8fdbcb..1af3642c9 100644 --- a/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx +++ b/src/content/articles/api-usage-metering-quotas-cost-attribution/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data flowing through metering pipeline with API requests passing through measurement gates and aggregating into usage buckets for cost calculations" author: "kevin-brown" publishDate: 2023-08-06 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "prometheus", "grafana", "python"] featured: true --- diff --git a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx index 3db2e857e..0402d7a70 100644 --- a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx +++ b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "ArgoCD dashboard showing application in degraded state with sync waves as horizontal tracks and stuck/failed resource indicators" author: "kevin-brown" publishDate: 2022-06-05 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "argo-cd", "kubernetes", "helm"] featured: true --- diff --git a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx index 8c9e6b07b..f6a9ab3be 100644 --- a/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx +++ b/src/content/articles/argocd-sync-failures-gitops-debugging-troubleshooting/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "ArgoCD dashboard showing application in degraded state with sync waves as horizontal tracks and stuck/failed resource indicators" author: "kevin-brown" publishDate: 2022-06-05 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "argo-cd", "kubernetes", "helm"] featured: true --- diff --git a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx index f53973d2f..5ca261f67 100644 --- a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx +++ b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Availability ruler showing exponentially increasing gaps between 99%, 99.9%, 99.99%, and 99.999% with dollar signs and magnifying glass highlighting cost of five nines" author: "kevin-brown" publishDate: 2023-02-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "python"] featured: true --- diff --git a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx index c037e2a5b..45cdea155 100644 --- a/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx +++ b/src/content/articles/availability-targets-five-nines-cost-benefit-analysis/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Availability ruler showing exponentially increasing gaps between 99%, 99.9%, 99.99%, and 99.999% with dollar signs and magnifying glass highlighting cost of five nines" author: "kevin-brown" publishDate: 2023-02-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "python"] featured: true --- diff --git a/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx b/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx index 1ae598e1d..9ee0fdb79 100644 --- a/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx +++ b/src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway interchange with traffic signals and metering lights managing flow, showing smooth lanes and redirected overflow traffic under heavy load" author: "kevin-brown" publishDate: 2022-08-07 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "aws", "typescript", "python", "go"] featured: true --- diff --git a/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx b/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx index 0ef482ecf..8df204d51 100644 --- a/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx +++ b/src/content/articles/backpressure-load-shedding-admission-control-overload/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway interchange with traffic signals and metering lights managing flow, showing smooth lanes and redirected overflow traffic under heavy load" author: "kevin-brown" publishDate: 2022-08-07 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "aws", "typescript", "python", "go", "grafana", "prometheus"] featured: true --- diff --git a/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx b/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx index fc02846eb..15c757db2 100644 --- a/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx +++ b/src/content/articles/blue-green-canary-deployment-strategy-comparison/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Deployment strategy spectrum showing progression from simple to complex: Recreate, Rolling, Blue/Green, Canary, and Progressive deployment patterns" author: "kevin-brown" publishDate: 2023-08-20 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "aws", "python", "prometheus", "typescript"] featured: true --- diff --git a/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx b/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx index 290c25115..cb5809b56 100644 --- a/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx +++ b/src/content/articles/blue-green-canary-deployment-strategy-comparison/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Deployment strategy spectrum showing progression from simple to complex: Recreate, Rolling, Blue/Green, Canary, and Progressive deployment patterns" author: "kevin-brown" publishDate: 2023-08-20 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "aws", "python", "prometheus", "typescript"] featured: true --- diff --git a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx index 0cdb5e0cd..8aa4aa2fa 100644 --- a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx +++ b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing performance versus correctness trade-off, with cache misconfiguration tipping the scale dangerously toward performance" author: "kevin-brown" publishDate: 2022-03-05 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "typescript"] featured: true --- diff --git a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx index bee96b0b5..3cc28b2ea 100644 --- a/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx +++ b/src/content/articles/cdn-edge-caching-cache-keys-vary-headers/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing performance versus correctness trade-off, with cache misconfiguration tipping the scale dangerously toward performance" author: "kevin-brown" publishDate: 2022-03-05 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "aws", "typescript"] featured: true --- diff --git a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx index 7a3828b1f..1bc1ac680 100644 --- a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx +++ b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Laboratory setting with engineers conducting controlled experiments on miniature server infrastructure in place of traditional lab equipment" author: "kevin-brown" publishDate: 2022-03-19 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "kubernetes", "python", "go"] featured: false --- diff --git a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx index 09943c062..c395a4f73 100644 --- a/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx +++ b/src/content/articles/chaos-engineering-failure-injection-low-cost-experiments/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Laboratory setting with engineers conducting controlled experiments on miniature server infrastructure in place of traditional lab equipment" author: "kevin-brown" publishDate: 2024-01-15 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "kubernetes", "python", "go"] featured: true --- diff --git a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx index c0c2386d8..77375ba1c 100644 --- a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx +++ b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI pipeline racing track with builds as cars, showing cached fast lanes with motion blur versus slower uncached sections and reduced build time at finish" author: "kevin-brown" publishDate: 2025-09-06 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "docker", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx index f78cb7cbb..6b276b2fd 100644 --- a/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx +++ b/src/content/articles/ci-pipeline-caching-docker-layers-dependency-cache/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI pipeline racing track with builds as cars, showing cached fast lanes with motion blur versus slower uncached sections and reduced build time at finish" author: "kevin-brown" publishDate: 2025-09-06 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "docker", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx index 7a5740276..5a5912cef 100644 --- a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx +++ b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Retry budget gauge showing partial depletion with replenishment pipe from successful requests feeding back into the meter" author: "kevin-brown" publishDate: 2024-09-15 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus", "typescript", "go", "python"] featured: true --- diff --git a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx index a8759550a..ee656d8dc 100644 --- a/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx +++ b/src/content/articles/circuit-breaker-retry-budget-cascade-failure-prevention/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Retry budget gauge showing partial depletion with replenishment pipe from successful requests feeding back into the meter" author: "kevin-brown" publishDate: 2024-09-15 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus", "typescript", "go", "python"] featured: true --- diff --git a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx index 8f4858afc..92ff10c4d 100644 --- a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx +++ b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Consumer and provider services separated by two-way mirror with glowing contract showing mutual expectations between them" author: "kevin-brown" publishDate: 2024-04-07 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "docker", "kubernetes", "typescript", "python", "go", "ruby", "dotnet"] featured: true --- @@ -74,9 +74,8 @@ Here's what this looks like in practice. A consumer test defines the expected re ```typescript title="user-api.pact.spec.ts" import { PactV3, MatchersV3 } from '@pact-foundation/pact'; - +tags: ["apis-and-gateways", "docker", "kubernetes", "typescript", "python", "go", "ruby", "dotnet"] // like() matches type/structure, not exact values—keeps contracts flexible -const { like } = MatchersV3; const provider = new PactV3({ consumer: 'OrderService', diff --git a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx index a9b26b21a..af562ed2f 100644 --- a/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx +++ b/src/content/articles/consumer-driven-contract-testing-pact-internal-apis/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Consumer and provider services separated by two-way mirror with glowing contract showing mutual expectations between them" author: "kevin-brown" publishDate: 2024-04-07 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "docker", "kubernetes", "typescript", "python", "go", "ruby", "dotnet"] featured: true --- diff --git a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx index 9783512ec..69f0bd405 100644 --- a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx +++ b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Container security checkpoint with scanner showing X-ray views, green checkmarks for safe containers and red warnings for detected vulnerabilities" author: "kevin-brown" publishDate: 2023-01-01 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "docker", "python"] featured: true --- diff --git a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx index bc8d2d6a1..9120a7392 100644 --- a/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx +++ b/src/content/articles/container-vulnerability-scanning-ci-shift-left-security/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Container security checkpoint with scanner showing X-ray views, green checkmarks for safe containers and red warnings for detected vulnerabilities" author: "kevin-brown" publishDate: 2023-01-01 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "docker", "python", "go"] featured: true --- diff --git a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx index 221975611..43084542d 100644 --- a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx +++ b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bridge construction showing new lane being built alongside existing lane with continuous traffic flow, representing zero-downtime schema migration" author: "kevin-brown" publishDate: 2022-09-04 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "ruby", "dotnet"] featured: true --- diff --git a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx index a75cde218..31f4801e7 100644 --- a/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx +++ b/src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bridge construction showing new lane being built alongside existing lane with continuous traffic flow, representing zero-downtime schema migration" author: "kevin-brown" publishDate: 2022-09-04 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "kubernetes", "typescript"] featured: true --- diff --git a/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx b/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx index 6f93c61d9..fac8cfe25 100644 --- a/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx +++ b/src/content/articles/dead-letter-queue-design-replay-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hospital recovery ward for failed messages with nurse-bots monitoring recovery progress and discharging healthy messages for replay" author: "kevin-brown" publishDate: 2024-02-04 -tags: ["systems-and-development"] +tags: ["systems-and-development", "aws", "go", "dotnet"] featured: true --- diff --git a/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx b/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx index fd24b04b9..8693fbb0a 100644 --- a/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx +++ b/src/content/articles/dead-letter-queue-design-replay-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hospital recovery ward for failed messages with nurse-bots monitoring recovery progress and discharging healthy messages for replay" author: "kevin-brown" publishDate: 2024-02-04 -tags: ["systems-and-development"] +tags: ["systems-and-development", "aws", "go", "dotnet", "python"] featured: true --- diff --git a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx index 4a3c99bad..de15222a2 100644 --- a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx +++ b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing observability value versus cost trade-off, with sample rate dial controlling the equilibrium point" author: "kevin-brown" publishDate: 2024-10-06 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescrip t", "python", "go", "dotnet", "ruby", "grafana"] featured: true --- diff --git a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx index 652b201f3..c5dba8246 100644 --- a/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx +++ b/src/content/articles/distributed-tracing-sampling-strategies-head-tail/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Balance scale showing observability value versus cost trade-off, with sample rate dial controlling the equilibrium point" author: "kevin-brown" publishDate: 2024-10-06 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescript", "python", "go", "dotnet", "ruby", "grafana"] featured: true --- diff --git a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx index e4c7ae4a3..a2e6fbce3 100644 --- a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx +++ b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Application breaking free from old runtimes (Node 16, .NET Framework, CentOS 7) with cut chains, moving upward toward newer runtime versions" author: "kevin-brown" publishDate: 2025-01-04 -tags: ["system-modernization", "platform-engineering"] +tags: ["system-modernization", "platform-engineering", "dotnet", "python", "docker"] featured: true --- diff --git a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx index 498d2a596..1736439ed 100644 --- a/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx +++ b/src/content/articles/eol-runtime-upgrade-dependency-hell-migration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Application breaking free from old runtimes (Node 16, .NET Framework, CentOS 7) with cut chains, moving upward toward newer runtime versions" author: "kevin-brown" publishDate: 2025-01-04 -tags: ["system-modernization", "platform-engineering"] +tags: ["system-modernization", "platform-engineering", "dotnet", "python", "docker"] featured: true --- diff --git a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx index c2cc02a3d..eed713a0c 100644 --- a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx +++ b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hotel with rooms as preview environments, showing check-in/check-out with TTL management, extended stays, cleaning crew, and real-time cost billing" author: "kevin-brown" publishDate: 2025-10-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws"] featured: true --- diff --git a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx index 9bc213b15..708caf3b4 100644 --- a/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx +++ b/src/content/articles/ephemeral-preview-environments-cost-control-cleanup/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Hotel with rooms as preview environments, showing check-in/check-out with TTL management, extended stays, cleaning crew, and real-time cost billing" author: "kevin-brown" publishDate: 2025-10-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws"] featured: true --- diff --git a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx index ace3c870d..b798b4098 100644 --- a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx +++ b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radio operator tuning between real failures and flaky noise signals, filtering static to find true test failure signal" author: "kevin-brown" publishDate: 2025-11-02 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript"] featured: true --- diff --git a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx index 40d8d2ead..fdceaab1a 100644 --- a/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx +++ b/src/content/articles/flaky-test-diagnosis-race-conditions-e2e-stabilization/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Radio operator tuning between real failures and flaky noise signals, filtering static to find true test failure signal" author: "kevin-brown" publishDate: 2025-11-02 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript"] featured: true --- diff --git a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx index d341b96bd..e6b6523f7 100644 --- a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx +++ b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transit hub showing highlighted recommended route alongside alternative paths, representing golden path choice architecture for developers" author: "kevin-brown" publishDate: 2024-04-21 -tags: ["platform-engineering"] +tags: ["platform-engineering", "backstage", "kubernetes", "typescript", "python", "go", "grafana", "docker"] featured: true --- diff --git a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx index 9fc066eb3..430defda4 100644 --- a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx +++ b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transit hub showing highlighted recommended route alongside alternative paths, representing golden path choice architecture for developers" author: "kevin-brown" publishDate: 2024-04-21 -tags: ["platform-engineering"] +tags: ["platform-engineering", "backstage", "kubernetes", "typescript", "python", "go", "grafana", "docker"] featured: true --- diff --git a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx index 6cd836e37..0de7ead2e 100644 --- a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx +++ b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Library with organized and neglected sections, librarian curating books into archive and discard piles, representing dashboard hygiene and metric pruning" author: "kevin-brown" publishDate: 2025-03-02 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "grafana", "python", "terraform"] featured: true --- diff --git a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx index 0bf248034..c3f9e6a0f 100644 --- a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx +++ b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Library with organized and neglected sections, librarian curating books into archive and discard piles, representing dashboard hygiene and metric pruning" author: "kevin-brown" publishDate: 2025-03-02 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "grafana", "python", "terraform"] featured: true --- diff --git a/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx b/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx index d46ec9c21..34e285b48 100644 --- a/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx +++ b/src/content/articles/helm-release-management-drift-detection-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Factory assembly line showing Helm charts progressing through quality control stations from lint to verify, with rejected releases diverted to repair" author: "kevin-brown" publishDate: 2023-03-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "helm", "kubernetes", "python", "argo-cd", "prometheus"] featured: true --- diff --git a/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx b/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx index d8ffc906e..3a323234c 100644 --- a/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx +++ b/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Factory assembly line showing Helm charts progressing through quality control stations from lint to verify, with rejected releases diverted to repair" author: "kevin-brown" publishDate: 2023-03-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "helm", "kubernetes", "python", "argo-cd", "prometheus"] featured: true --- diff --git a/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx b/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx index fd1a2c3a3..f1e70a405 100644 --- a/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx +++ b/src/content/articles/idempotent-message-handlers-deduplication-retries/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure vault with guardian checking incoming messages against ledger of processed keys, turning away duplicates while admitting new messages" author: "kevin-brown" publishDate: 2022-05-01 -tags: ["systems-and-development"] +tags: ["systems-and-development", "python", "aws"] featured: true --- diff --git a/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx b/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx index 1f80b2b20..6b6b3fb18 100644 --- a/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx +++ b/src/content/articles/idempotent-message-handlers-deduplication-retries/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure vault with guardian checking incoming messages against ledger of processed keys, turning away duplicates while admitting new messages" author: "kevin-brown" publishDate: 2022-05-01 -tags: ["systems-and-development"] +tags: ["systems-and-development", "python", "typescript", "aws"] featured: true --- diff --git a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx index 236b0773a..d66b6f2d6 100644 --- a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx +++ b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transparent toolbox with organized compartments holding kubectl, terraform, and helm tools, labeled by environment with smart access controls" author: "kevin-brown" publishDate: 2024-02-18 -tags: ["platform-engineering"] +tags: ["platform-engineering","kubernetes","helm","terraform","python"] featured: true --- diff --git a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx index c17d84610..edfaed1b6 100644 --- a/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx +++ b/src/content/articles/internal-cli-kubectl-terraform-wrapper-abstraction/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Transparent toolbox with organized compartments holding kubectl, terraform, and helm tools, labeled by environment with smart access controls" author: "kevin-brown" publishDate: 2024-02-18 -tags: ["platform-engineering"] +tags: ["platform-engineering","kubernetes","helm","terraform","python"] featured: true --- diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx index 3a5469f88..850d6d79f 100644 --- a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx +++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Developer platform marketplace showing self-service offerings like Kubernetes namespaces and databases with delivery times and shopping cart checkout" author: "kevin-brown" publishDate: 2022-02-06 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","terraform","kubernetes","aws"] featured: true --- diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx index a4a2b74d4..d05325393 100644 --- a/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx +++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx @@ -5,14 +5,11 @@ cover: "./cover.jpg" coverAlt: "Developer platform marketplace showing self-service offerings like Kubernetes namespaces and databases with delivery times and shopping cart checkout" author: "kevin-brown" publishDate: 2022-02-06 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","terraform","kubernetes","aws"] featured: true --- *[IDP]: Internal Developer Portal/Platform -*[API]: Application Programming Interface -*[CI]: Continuous Integration -*[CD]: Continuous Deployment *[RBAC]: Role-Based Access Control *[SSO]: Single Sign-On *[K8s]: Kubernetes diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx index 2337ef924..b0eb74b99 100644 --- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx +++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx @@ -9,9 +9,6 @@ tags: ["platform-engineering"] featured: true --- -*[API]: Application Programming Interface -*[CI]: Continuous Integration - Last week, a platform team I know shipped what they called a "small cleanup" to their deployment API. They renamed a field from `service_id` to `serviceId`, removed an endpoint that was "barely used," and updated the response format to match their new schema. No version bump. No deprecation notice. Just a Friday afternoon deploy and a Slack message in `#platform-updates` that nobody read. Monday morning, 40 CI pipelines failed. Three teams scrambled to update their deployment scripts. A critical security hotfix got blocked because the team couldn't deploy. The platform team spent the entire week doing emergency migrations instead of planned work. And the trust they'd built over the previous year? Gone. diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx index beb7dc6e6..0459f2937 100644 --- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx +++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx @@ -9,7 +9,6 @@ tags: ["platform-engineering"] featured: true --- -*[API]: Application Programming Interface *[SDK]: Software Development Kit *[SLA]: Service Level Agreement *[CI]: Continuous Integration diff --git a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx index dead0d650..bc19e596b 100644 --- a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx +++ b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Kubernetes cluster upgrade assembly line with quality control stations from pre-check through validation, with rollback lane and certification" author: "kevin-brown" publishDate: 2025-08-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx index 606c25847..6f8b79a90 100644 --- a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx +++ b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Kubernetes cluster upgrade assembly line with quality control stations from pre-check through validation, with rollback lane and certification" author: "kevin-brown" publishDate: 2025-08-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx index 7af5b80e5..ceebf1c54 100644 --- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx +++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tetris-style pod packing visualization showing efficient resource allocation in Kubernetes nodes with cost savings scoreboard and highlighted waste" author: "kevin-brown" publishDate: 2025-06-01 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx index 6e075f3f6..238f36109 100644 --- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx +++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tetris-style pod packing visualization showing efficient resource allocation in Kubernetes nodes with cost savings scoreboard and highlighted waste" author: "kevin-brown" publishDate: 2025-06-01 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx index fd1eeeeb5..9309cdb0d 100644 --- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx +++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tool shed showing oversized Kubernetes machine alongside simpler alternatives like VMs, PaaS, and Serverless for straightforward tasks" author: "kevin-brown" publishDate: 2022-11-06 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","helm"] featured: true abbreviations: - K8s diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx index f1d35711d..58417e59d 100644 --- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx +++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Tool shed showing oversized Kubernetes machine alongside simpler alternatives like VMs, PaaS, and Serverless for straightforward tasks" author: "kevin-brown" publishDate: 2022-11-06 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","helm","aws","azure"] featured: true --- diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx index 76a621790..088f13c6b 100644 --- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx +++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNS resolution pipeline showing queries flowing through resolv.conf, CoreDNS, cache, and upstream filters with efficiency visualization" author: "kevin-brown" publishDate: 2024-05-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx index 857915c08..d2df80394 100644 --- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx +++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNS resolution pipeline showing queries flowing through resolv.conf, CoreDNS, cache, and upstream filters with efficiency visualization" author: "kevin-brown" publishDate: 2024-05-05 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx index e1989b8cf..e001d2f4e 100644 --- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx +++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with HPA dashboards showing traffic patterns and replica counts, operators tuning stabilization and scaling parameters to minimize capacity gaps" author: "kevin-brown" publishDate: 2024-11-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx index 26711119a..9bfb6ab00 100644 --- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx +++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with HPA dashboards showing traffic patterns and replica counts, operators tuning stabilization and scaling parameters to minimize capacity gaps" author: "kevin-brown" publishDate: 2024-11-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx index d991ca592..6b773348f 100644 --- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx +++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Fork in road showing Ingress path (shorter, simpler, well-worn) versus Gateway API path (longer, more features, newer pavement) with feature signposts" author: "kevin-brown" publishDate: 2024-01-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx index 71dd1312e..42bc287b0 100644 --- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx +++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Fork in road showing Ingress path (shorter, simpler, well-worn) versus Gateway API path (longer, more features, newer pavement) with feature signposts" author: "kevin-brown" publishDate: 2024-01-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes"] featured: true --- diff --git a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx index 2c4b36e3a..12c379eef 100644 --- a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx +++ b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Orchestra with sections playing from same sheet music with conductor ensuring synchronization, highlighting musicians drifting off tempo representing cluster drift detection" author: "kevin-brown" publishDate: 2025-02-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","argo-cd","helm"] featured: true --- diff --git a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx index fc330b3e2..8e61a7d8b 100644 --- a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx +++ b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Orchestra with sections playing from same sheet music with conductor ensuring synchronization, highlighting musicians drifting off tempo representing cluster drift detection" author: "kevin-brown" publishDate: 2025-02-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","argo-cd","helm"] featured: true --- diff --git a/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx index 465f77d43..e75a4e418 100644 --- a/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx +++ b/src/content/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Air traffic controller managing planes (pods) on runways (nodes) with minimum availability requirements during runway maintenance for operational continuity" author: "kevin-brown" publishDate: 2024-08-03 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus","aws"] featured: true --- diff --git a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx index ffb2bb370..a4961baad 100644 --- a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx +++ b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Airplane seating classes showing first class (Guaranteed), business (Burstable), and economy (BestEffort) with priority boarding representing Kubernetes QoS resource tiering" author: "kevin-brown" publishDate: 2022-10-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx index 50f4a5089..acc388adf 100644 --- a/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx +++ b/src/content/articles/kubernetes-pod-resource-requests-limits-qos-classes/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Airplane seating classes showing first class (Guaranteed), business (Burstable), and economy (BestEffort) with priority boarding representing Kubernetes QoS resource tiering" author: "kevin-brown" publishDate: 2022-10-02 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- diff --git a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx index 62347570f..9dd5271b5 100644 --- a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx +++ b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bank vault with three secret access methods: scheduled escort (ESO periodic sync), direct access (CSI at pod start), and custom robot retrieval (init container)" author: "kevin-brown" publishDate: 2022-10-16 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws", "azure"] featured: true --- diff --git a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx index 743ba8384..f2db2e40e 100644 --- a/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx +++ b/src/content/articles/kubernetes-secrets-external-secrets-operator-csi-vault/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bank vault with three secret access methods: scheduled escort (ESO periodic sync), direct access (CSI at pod start), and custom robot retrieval (init container)" author: "kevin-brown" publishDate: 2022-10-16 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "kubernetes", "aws", "azure"] featured: true --- diff --git a/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx b/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx index f05e6e0b6..ea9e4685d 100644 --- a/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx +++ b/src/content/articles/legacy-code-testing-characterization-tests-seams/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bomb disposal expert analyzing complex device with mirrors and identifying safe cutting points, representing careful legacy code refactoring with characterization tests" author: "kevin-brown" publishDate: 2023-03-19 -tags: ["system-modernization"] +tags: ["system-modernization", "ruby", "typescript", "dotnet"] featured: true --- diff --git a/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx b/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx index fbe3cf7b8..a6c891638 100644 --- a/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx +++ b/src/content/articles/legacy-code-testing-characterization-tests-seams/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Bomb disposal expert analyzing complex device with mirrors and identifying safe cutting points, representing careful legacy code refactoring with characterization tests" author: "kevin-brown" publishDate: 2023-03-19 -tags: ["system-modernization"] +tags: ["system-modernization", "ruby", "typescript", "dotnet"] featured: true --- diff --git a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx index 0b9e29aaa..7c20cd2c4 100644 --- a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx +++ b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Assembly line showing build process with cached components pre-made at most stations, workers handling custom work, displaying 78% cache hit rate" author: "kevin-brown" publishDate: 2025-05-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "typescript", "aws", "azure", "docker", "kubernetes"] featured: true --- diff --git a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx index 7bdc51232..ddde48a0c 100644 --- a/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx +++ b/src/content/articles/monorepo-affected-builds-remote-caching-ci-optimization/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Assembly line showing build process with cached components pre-made at most stations, workers handling custom work, displaying 78% cache hit rate" author: "kevin-brown" publishDate: 2025-05-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy", "typescript", "aws", "azure", "docker", "kubernetes"] featured: true --- diff --git a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx index f339bbcaf..33f95decb 100644 --- a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx +++ b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Robotic hands exchanging glowing certificates forming encrypted tunnel with automatic certificate rotation, representing mTLS mutual authentication" author: "kevin-brown" publishDate: 2025-09-20 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx index c59455b88..b68c5a9fa 100644 --- a/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx +++ b/src/content/articles/mtls-certificate-rotation-service-mesh-authentication/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Robotic hands exchanging glowing certificates forming encrypted tunnel with automatic certificate rotation, representing mTLS mutual authentication" author: "kevin-brown" publishDate: 2025-09-20 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx index 1b451503f..59a2f5337 100644 --- a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx +++ b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with proxy metrics dashboards showing connection counts, latency, and error rates, engineer adjusting timeout and buffer settings" author: "kevin-brown" publishDate: 2024-08-17 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx index 52ecc691b..daec667f3 100644 --- a/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx +++ b/src/content/articles/nginx-haproxy-reverse-proxy-production-tuning/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with proxy metrics dashboards showing connection counts, latency, and error rates, engineer adjusting timeout and buffer settings" author: "kevin-brown" publishDate: 2024-08-17 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "kubernetes", "prometheus"] featured: true --- diff --git a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx index ebf4de840..0cc2d9097 100644 --- a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx +++ b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Automated security checkpoint scanning infrastructure configurations with green checkmarks for compliance and violation flags with fix suggestions" author: "kevin-brown" publishDate: 2023-05-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "terraform", "kubernetes", "aws", "docker"] featured: true --- diff --git a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx index d0a01836f..66c5fbcb6 100644 --- a/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx +++ b/src/content/articles/opa-conftest-policy-as-code-infrastructure-guardrails/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Automated security checkpoint scanning infrastructure configurations with green checkmarks for compliance and violation flags with fix suggestions" author: "kevin-brown" publishDate: 2023-05-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "terraform", "kubernetes", "aws", "docker"] featured: true --- diff --git a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx index 8869e89e0..e1a2fe129 100644 --- a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx +++ b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Central glowing OpenAPI spec document with streams flowing to documentation pages, SDK code in multiple languages, and validation shields" author: "kevin-brown" publishDate: 2023-12-03 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "typescript", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx index 0d9115c91..461658709 100644 --- a/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx +++ b/src/content/articles/openapi-spec-documentation-sdk-generation-validation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Central glowing OpenAPI spec document with streams flowing to documentation pages, SDK code in multiple languages, and validation shields" author: "kevin-brown" publishDate: 2023-12-03 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways", "typescript", "python", "ruby", "go"] featured: true --- diff --git a/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx b/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx index 1d705b31a..e6b24e221 100644 --- a/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx +++ b/src/content/articles/opentelemetry-span-design-granularity-overhead/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two city maps comparison: over-detailed map with every feature marked versus clear map showing major roads and landmarks for easy navigation" author: "kevin-brown" publishDate: 2026-01-04 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescript"] featured: true --- diff --git a/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx b/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx index cacd65003..19e439e04 100644 --- a/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx +++ b/src/content/articles/opentelemetry-span-design-granularity-overhead/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two city maps comparison: over-detailed map with every feature marked versus clear map showing major roads and landmarks for easy navigation" author: "kevin-brown" publishDate: 2026-01-04 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry", "typescript"] featured: true --- diff --git a/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx b/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx index 271bc4402..e2e46c552 100644 --- a/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx +++ b/src/content/articles/performance-testing-load-models-benchmark-accuracy/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Architect studying traffic pattern blueprints overlaid on city showing congested hot paths and empty cold paths for realistic performance test design" author: "kevin-brown" publishDate: 2023-06-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript", "python"] featured: true --- diff --git a/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx b/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx index 205d2e159..6ad42af83 100644 --- a/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx +++ b/src/content/articles/performance-testing-load-models-benchmark-accuracy/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Architect studying traffic pattern blueprints overlaid on city showing congested hot paths and empty cold paths for realistic performance test design" author: "kevin-brown" publishDate: 2023-06-11 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing", "typescript", "python"] featured: true --- diff --git a/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx b/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx index 6d6de31bc..94a76c129 100644 --- a/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx +++ b/src/content/articles/platform-architecture-control-plane-data-plane-separation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two towers representing control plane (with decision-making systems) and data plane (with traffic processing) connected by bridges carrying configurations and status updates" author: "kevin-brown" publishDate: 2023-01-15 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "terraform", "grafana", "argo-cd", "aws", "backstage"] featured: true --- diff --git a/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx b/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx index 2c349fff3..2ad2f2a54 100644 --- a/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx +++ b/src/content/articles/platform-architecture-control-plane-data-plane-separation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Two towers representing control plane (with decision-making systems) and data plane (with traffic processing) connected by bridges carrying configurations and status updates" author: "kevin-brown" publishDate: 2023-01-15 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "terraform", "grafana", "argo-cd", "aws", "backstage"] featured: true --- diff --git a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx index e25a9e653..3e8767a44 100644 --- a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx +++ b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Developer friction gauge showing needle moving from red painful zone to green smooth zone after platform improvements" author: "kevin-brown" publishDate: 2025-02-16 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "helm", "terraform", "docker", "crossplane", "argo-cd", "grafana", "aws", "python"] featured: true --- diff --git a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx index bbe7b6a06..ba9d0968c 100644 --- a/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx +++ b/src/content/articles/platform-engineering-metrics-lead-time-developer-friction/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Developer friction gauge showing needle moving from red painful zone to green smooth zone after platform improvements" author: "kevin-brown" publishDate: 2025-02-16 -tags: ["platform-engineering"] +tags: ["platform-engineering", "kubernetes", "helm", "terraform", "docker", "crossplane", "argo-cd", "grafana", "aws", "python"] featured: true --- diff --git a/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx b/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx index 7778df30d..73b1e04ed 100644 --- a/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx +++ b/src/content/articles/postgresql-connection-pooling-saturation-sizing/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Traffic intersection with controller (PgBouncer) efficiently managing many cars (app connections) crossing through limited lanes (database connections)" author: "kevin-brown" publishDate: 2023-11-05 -tags: ["systems-and-development"] +tags: ["systems-and-development", "typescript", "kubernetes"] featured: true --- diff --git a/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx b/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx index 162434c04..0fbd01198 100644 --- a/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx +++ b/src/content/articles/postgresql-connection-pooling-saturation-sizing/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Traffic intersection with controller (PgBouncer) efficiently managing many cars (app connections) crossing through limited lanes (database connections)" author: "kevin-brown" publishDate: 2023-11-05 -tags: ["systems-and-development"] +tags: ["systems-and-development", "typescript", "kubernetes"] featured: true --- diff --git a/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx b/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx index 224996fe3..02b41bc25 100644 --- a/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx +++ b/src/content/articles/private-networking-dns-routing-tls-debugging/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure tunnel through mountain with checkpoints for DNS, routing, firewalls, and TLS verification, contrasting with exposed open roads outside" author: "kevin-brown" publishDate: 2024-05-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "aws", "azure"] featured: true --- diff --git a/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx b/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx index 336ec1618..33ec7e3f2 100644 --- a/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx +++ b/src/content/articles/private-networking-dns-routing-tls-debugging/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Secure tunnel through mountain with checkpoints for DNS, routing, firewalls, and TLS verification, contrasting with exposed open roads outside" author: "kevin-brown" publishDate: 2024-05-19 -tags: ["cloud-platforms"] +tags: ["cloud-platforms", "aws", "azure"] featured: true --- diff --git a/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx b/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx index 2925d0220..b21b6e963 100644 --- a/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx +++ b/src/content/articles/prometheus-high-cardinality-metrics-label-design/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Prometheus pressure cooker with memory gauge spiking to red zone from unbounded labels, showing bounded versus unbounded label impact" author: "kevin-brown" publishDate: 2022-09-18 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana","python","kubernetes"] featured: true --- diff --git a/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx b/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx index 82d51f59f..40ddbbbaa 100644 --- a/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx +++ b/src/content/articles/prometheus-high-cardinality-metrics-label-design/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Prometheus pressure cooker with memory gauge spiking to red zone from unbounded labels, showing bounded versus unbounded label impact" author: "kevin-brown" publishDate: 2022-09-18 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana","python","kubernetes"] featured: true --- diff --git a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx index dd8dccedb..850a11178 100644 --- a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx +++ b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Sliding window visualization showing window frame moving across timeline counting request dots, comparing fixed versus sliding window boundaries" author: "kevin-brown" publishDate: 2026-01-18 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways","aws","typescript","python"] featured: true --- diff --git a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx index 503aa8faf..7858c30fa 100644 --- a/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx +++ b/src/content/articles/rate-limiting-token-bucket-leaky-bucket-implementation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Sliding window visualization showing window frame moving across timeline counting request dots, comparing fixed versus sliding window boundaries" author: "kevin-brown" publishDate: 2026-01-18 -tags: ["apis-and-gateways"] +tags: ["apis-and-gateways","aws","azure","terraform","typescript","python"] featured: true --- diff --git a/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx b/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx index 36e27f8e8..2d6b8be69 100644 --- a/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx +++ b/src/content/articles/release-quality-gates-automated-deployment-validation/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway with quality checkpoint traffic lights showing green passing gates, red failing gate stopping one lane, and secure override lane" author: "kevin-brown" publishDate: 2025-08-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","aws","azure","grafana","typescript"] featured: true --- diff --git a/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx b/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx index 7c326d824..51bfeea69 100644 --- a/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx +++ b/src/content/articles/release-quality-gates-automated-deployment-validation/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Highway with quality checkpoint traffic lights showing green passing gates, red failing gate stopping one lane, and secure override lane" author: "kevin-brown" publishDate: 2025-08-17 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","aws","azure","grafana","typescript"] featured: true --- diff --git a/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx b/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx index 9d7432098..15ac80c25 100644 --- a/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx +++ b/src/content/articles/reverse-engineering-documentation-legacy-systems/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "X-ray view of mechanical device showing internal gears, springs, and linkages with layered transparency revealing hidden architecture" author: "kevin-brown" publishDate: 2023-12-17 -tags: ["system-modernization"] +tags: ["system-modernization","typescript"] featured: true --- diff --git a/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx b/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx index 51c5d20f3..8c3f9cdf5 100644 --- a/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx +++ b/src/content/articles/reverse-engineering-documentation-legacy-systems/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "X-ray view of mechanical device showing internal gears, springs, and linkages with layered transparency revealing hidden architecture" author: "kevin-brown" publishDate: 2023-12-17 -tags: ["system-modernization"] +tags: ["system-modernization","typescript"] featured: true --- diff --git a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx index 470edbc13..da11feaa4 100644 --- a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx +++ b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Three-dimensional network topology diagram with illuminated node spheres connected by glowing lines of varying thickness representing traffic importance" author: "kevin-brown" publishDate: 2022-06-19 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","kubernetes","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx index 6a7ed3845..67500b3e6 100644 --- a/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx +++ b/src/content/articles/service-catalog-metadata-schema-ownership-tracking/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Three-dimensional network topology diagram with illuminated node spheres connected by glowing lines of varying thickness representing traffic importance" author: "kevin-brown" publishDate: 2022-06-19 -tags: ["platform-engineering"] +tags: ["platform-engineering","backstage","kubernetes","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx b/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx index 9f0687fdc..65d4dfd2b 100644 --- a/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx +++ b/src/content/articles/service-decommissioning-scream-test-shutdown/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Industrial control panel with switches in off position and red indicators, steam in background showing controlled machinery shutdown" author: "kevin-brown" publishDate: 2024-06-02 -tags: ["system-modernization"] +tags: ["system-modernization","aws","kubernetes"] featured: true --- diff --git a/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx b/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx index a5782cb3e..fe7386ec3 100644 --- a/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx +++ b/src/content/articles/service-decommissioning-scream-test-shutdown/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Industrial control panel with switches in off position and red indicators, steam in background showing controlled machinery shutdown" author: "kevin-brown" publishDate: 2024-06-02 -tags: ["system-modernization"] +tags: ["system-modernization","aws","kubernetes"] featured: true --- diff --git a/src/content/articles/slo-error-budget-practical-guide/index.mdx b/src/content/articles/slo-error-budget-practical-guide/index.mdx index d0ce8748c..472c394eb 100644 --- a/src/content/articles/slo-error-budget-practical-guide/index.mdx +++ b/src/content/articles/slo-error-budget-practical-guide/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Operations control room with multiple monitoring screens displaying graphs, gauges, and status indicators for system health visibility" author: "kevin-brown" publishDate: 2022-11-20 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/slo-error-budget-practical-guide/pdf.mdx b/src/content/articles/slo-error-budget-practical-guide/pdf.mdx index a95876661..2bac360c0 100644 --- a/src/content/articles/slo-error-budget-practical-guide/pdf.mdx +++ b/src/content/articles/slo-error-budget-practical-guide/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Operations control room with multiple monitoring screens displaying graphs, gauges, and status indicators for system health visibility" author: "kevin-brown" publishDate: 2022-11-20 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx index c33985eb6..6fdc7f9ba 100644 --- a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx +++ b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/index.mdx @@ -5,13 +5,11 @@ cover: "./cover.jpg" coverAlt: "Manufacturing assembly line with products being scanned with QR codes and digital tags for provenance tracking through production" author: "kevin-brown" publishDate: 2024-03-16 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","docker","kubernetes"] featured: true --- *[CA]: Certificate Authority -*[CI]: Continuous Integration -*[CD]: Continuous Deployment *[CVE]: Common Vulnerabilities and Exposures *[GPG]: GNU Privacy Guard *[HSM]: Hardware Security Module diff --git a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx index b1d1e9d46..73bd5fb54 100644 --- a/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx +++ b/src/content/articles/slsa-build-provenance-artifact-signing-supply-chain/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Manufacturing assembly line with products being scanned with QR codes and digital tags for provenance tracking through production" author: "kevin-brown" publishDate: 2024-03-16 -tags: ["build-and-deploy"] +tags: ["build-and-deploy","docker","kubernetes"] featured: true --- diff --git a/src/content/articles/strangler-fig-migration-complete-guide/index.mdx b/src/content/articles/strangler-fig-migration-complete-guide/index.mdx index ca20403cc..d154e6f4e 100644 --- a/src/content/articles/strangler-fig-migration-complete-guide/index.mdx +++ b/src/content/articles/strangler-fig-migration-complete-guide/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with side-by-side monitors comparing legacy and new system dashboard metrics for migration validation" author: "kevin-brown" publishDate: 2025-01-18 -tags: ["system-modernization"] +tags: ["system-modernization","azure","python"] featured: true --- diff --git a/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx b/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx index d024806bb..e1b0661e2 100644 --- a/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx +++ b/src/content/articles/strangler-fig-migration-complete-guide/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Control room with side-by-side monitors comparing legacy and new system dashboard metrics for migration validation" author: "kevin-brown" publishDate: 2025-01-18 -tags: ["system-modernization"] +tags: ["system-modernization","azure","python"] featured: true --- diff --git a/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx b/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx index badefb9f7..79297027b 100644 --- a/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx +++ b/src/content/articles/structured-logging-correlation-ids-log-schema-design/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNA helix with field names and values as base pairs representing log data schema structure" author: "kevin-brown" publishDate: 2022-05-15 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx b/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx index bd2587a8a..f9920b7a4 100644 --- a/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx +++ b/src/content/articles/structured-logging-correlation-ids-log-schema-design/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "DNA helix with field names and values as base pairs representing log data schema structure" author: "kevin-brown" publishDate: 2022-05-15 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","typescript"] featured: true --- diff --git a/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx b/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx index 7590370eb..326006428 100644 --- a/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx +++ b/src/content/articles/symptom-based-alerting-runbooks-alert-design/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Firefighter at control panel deciding which emergency to respond to, representing alert prioritization and triage decisions" author: "kevin-brown" publishDate: 2022-02-20 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx b/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx index 759689729..2f980f87f 100644 --- a/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx +++ b/src/content/articles/symptom-based-alerting-runbooks-alert-design/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Firefighter at control panel deciding which emergency to respond to, representing alert prioritization and triage decisions" author: "kevin-brown" publishDate: 2022-02-20 -tags: ["observability-and-telemetry"] +tags: ["observability-and-telemetry","prometheus","grafana"] featured: true --- diff --git a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx index b22ae2f1f..e65a838f9 100644 --- a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx +++ b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data points transforming as they pass through anonymization barrier, changing color and shape to represent data anonymization process" author: "kevin-brown" publishDate: 2024-12-14 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","python"] featured: true --- diff --git a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx index 81e21d25b..dc1a76965 100644 --- a/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx +++ b/src/content/articles/synthetic-test-data-pii-anonymization-fixtures/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Data points transforming as they pass through anonymization barrier, changing color and shape to represent data anonymization process" author: "kevin-brown" publishDate: 2024-12-14 -tags: ["reliability-and-testing"] +tags: ["reliability-and-testing","python"] featured: true --- diff --git a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx index febb59278..4b9f4e025 100644 --- a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx +++ b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Technical blueprint with version numbers, revision marks, and change annotations documenting interface evolution over time" author: "kevin-brown" publishDate: 2025-12-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws"] featured: true --- diff --git a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx index 374a6a523..0e53a888e 100644 --- a/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx +++ b/src/content/articles/terraform-module-design-defaults-versioning-interfaces/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Technical blueprint with version numbers, revision marks, and change annotations documenting interface evolution over time" author: "kevin-brown" publishDate: 2025-12-07 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws"] featured: true --- diff --git a/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx b/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx index 81b299b49..6c6f68465 100644 --- a/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx +++ b/src/content/articles/terraform-state-locking-corruption-recovery-backend/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI platform and cloud provider exchanging trust tokens through secure channels for workload identity federation" author: "kevin-brown" publishDate: 2023-04-22 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws","azure"] featured: true --- diff --git a/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx b/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx index 70b048f56..5ee1fa3ac 100644 --- a/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx +++ b/src/content/articles/terraform-state-locking-corruption-recovery-backend/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "CI platform and cloud provider exchanging trust tokens through secure channels for workload identity federation" author: "kevin-brown" publishDate: 2023-04-22 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","terraform","aws","azure"] featured: true --- diff --git a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx index 648f4cc90..f224c71b7 100644 --- a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx +++ b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/index.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Antique lock and key overlaid with digital timestamps representing state locking mechanism with temporal operations" author: "kevin-brown" publishDate: 2023-06-25 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","azure","terraform","aws","kubernetes"] featured: true --- diff --git a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx index 6d1581b6b..222630ab5 100644 --- a/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx +++ b/src/content/articles/workload-identity-federation-keyless-cloud-authentication/pdf.mdx @@ -5,7 +5,7 @@ cover: "./cover.jpg" coverAlt: "Antique lock and key overlaid with digital timestamps representing state locking mechanism with temporal operations" author: "kevin-brown" publishDate: 2023-06-25 -tags: ["cloud-platforms"] +tags: ["cloud-platforms","azure","terraform","aws","kubernetes"] featured: true --- diff --git a/src/content/tags/argo-cd/argo-cd.svg b/src/content/tags/argo-cd/argo-cd.svg new file mode 100644 index 000000000..af646f0f9 --- /dev/null +++ b/src/content/tags/argo-cd/argo-cd.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/argo-cd/cover.jpg b/src/content/tags/argo-cd/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/argo-cd/cover.jpg differ diff --git a/src/content/tags/argo-cd/index.mdx b/src/content/tags/argo-cd/index.mdx new file mode 100644 index 000000000..fad32a2ae --- /dev/null +++ b/src/content/tags/argo-cd/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "argo-cd" +displayName: "Argo CD" +description: "Kubernetes-native continuous delivery with GitOps, declarative application management, sync strategies, and automated rollback for platform engineering workflows." +cover: "./cover.jpg" +coverAlt: "Stylized Argo CD octopus logo merged with a Kubernetes cluster sync diagram" +featured: false +logo: "./argo-cd.svg" +isSkill: true +--- + +Argo CD is a declarative, GitOps-based continuous delivery tool built specifically for Kubernetes. It treats Git repositories as the single source of truth for application definitions and cluster state, continuously reconciling what is declared in version control with what is actually running. When drift occurs, Argo CD detects it and can automatically or manually sync the cluster back to the desired state. + +In platform engineering workflows, Argo CD serves as the deployment backbone. It replaces imperative CI/CD push models with a pull-based approach where the cluster watches for changes rather than receiving commands. This pattern simplifies multi-cluster deployments, enables progressive rollouts through sync waves and hooks, and provides a clear audit trail of every change applied to a cluster. ApplicationSets allow platform teams to template deployments across hundreds of environments from a single definition. + +The operational reality of Argo CD involves managing sync failures, understanding resource ordering, handling secrets that cannot live in Git, and debugging the gap between what a manifest declares and what a cluster actually does. These articles cover practical Argo CD usage from the perspective of teams running it in production. diff --git a/src/content/tags/aws/aws.svg b/src/content/tags/aws/aws.svg new file mode 100644 index 000000000..c05ff4961 --- /dev/null +++ b/src/content/tags/aws/aws.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/src/content/tags/aws/cover.jpg b/src/content/tags/aws/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/aws/cover.jpg differ diff --git a/src/content/tags/aws/index.mdx b/src/content/tags/aws/index.mdx new file mode 100644 index 000000000..241dafe5c --- /dev/null +++ b/src/content/tags/aws/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "aws" +displayName: "AWS" +description: "Amazon Web Services cloud infrastructure for platform engineering, from IAM and networking to managed Kubernetes, serverless, and infrastructure-as-code workflows." +cover: "./cover.jpg" +coverAlt: "Abstract cloud architecture diagram with interconnected AWS service icons" +featured: false +logo: "./aws.svg" +isSkill: true +--- + +AWS dominates the cloud infrastructure market for good reason: its breadth of services lets platform teams assemble opinionated internal platforms without building everything from scratch. EKS for managed Kubernetes, CodePipeline and CodeBuild for CI/CD, CloudFormation and CDK for infrastructure-as-code, and IAM for fine-grained access control form the backbone of most enterprise platform engineering stacks. The ecosystem is deep enough that nearly any operational pattern has a managed-service answer. + +Platform engineers working with AWS spend significant time on IAM policy design, VPC networking, and service quotas—the unglamorous connective tissue that determines whether a self-service platform actually works at scale. Getting cross-account access right with Organizations and Control Tower, wiring up PrivateLink endpoints, and tuning autoscaling policies across EKS node groups are where real operational expertise lives. + +The tradeoff is complexity. AWS offers multiple ways to accomplish any goal, and choosing between them has long-term consequences for cost, maintainability, and team cognitive load. A well-built AWS platform abstracts that complexity behind golden paths so application teams get the reliability of battle-tested infrastructure without needing to understand every service interaction underneath. diff --git a/src/content/tags/azure/azure.svg b/src/content/tags/azure/azure.svg new file mode 100644 index 000000000..29cb4e62a --- /dev/null +++ b/src/content/tags/azure/azure.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/azure/cover.jpg b/src/content/tags/azure/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/azure/cover.jpg differ diff --git a/src/content/tags/azure/index.mdx b/src/content/tags/azure/index.mdx new file mode 100644 index 000000000..7391a226e --- /dev/null +++ b/src/content/tags/azure/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "azure" +displayName: "Azure" +description: "Microsoft Azure cloud platform for enterprise platform engineering, covering AKS, Entra ID, Azure DevOps pipelines, and infrastructure-as-code with Bicep and Terraform." +cover: "./cover.jpg" +coverAlt: "Geometric Azure diamond logo surrounded by interlocking cloud infrastructure blocks" +featured: false +logo: "./azure.svg" +isSkill: true +--- + +Azure occupies a unique position in enterprise platform engineering because of its deep integration with Microsoft's identity and productivity stack. Entra ID (formerly Azure AD) gives platform teams federated identity, conditional access, and workload identity federation out of the box—capabilities that take significant effort to replicate on other clouds. AKS provides managed Kubernetes with tight Azure networking integration, and Azure DevOps offers a mature CI/CD pipeline system that many enterprises already have in place. + +For platform engineers, Azure's strength is its enterprise governance tooling. Management Groups, Azure Policy, and Blueprints let you enforce guardrails across hundreds of subscriptions. Landing Zones provide prescriptive architecture for multi-team environments, and Bicep offers a cleaner infrastructure-as-code experience than ARM templates while remaining a first-party citizen. Combined with Terraform's AzureRM provider, teams have real flexibility in how they define and provision infrastructure. + +The challenge is navigating Azure's layered service model and frequent rebranding. Services shift names, preview features graduate unpredictably, and the relationship between Azure DevOps and GitHub Actions continues to evolve. Successful Azure platform teams build abstraction layers that shield application developers from these shifts while still leveraging the platform's enterprise integration advantages. diff --git a/src/content/tags/backstage-idp/cover.jpg b/src/content/tags/backstage-idp/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/backstage-idp/cover.jpg differ diff --git a/src/content/tags/backstage-idp/index.mdx b/src/content/tags/backstage-idp/index.mdx new file mode 100644 index 000000000..c23b61eac --- /dev/null +++ b/src/content/tags/backstage-idp/index.mdx @@ -0,0 +1,14 @@ +--- +slug: "backstage-idp" +displayName: "Backstage IDP" +description: "Spotify's open-source internal developer portal for building self-service platforms with software catalogs, scaffolding templates, and a plugin ecosystem." +cover: "./cover.jpg" +coverAlt: "Stylized developer portal dashboard with interconnected service catalog tiles" +featured: false +--- + +Backstage is Spotify's open-source framework for building internal developer portals, and it has become the de facto standard for platform teams that want a single pane of glass over their engineering ecosystem. Its software catalog gives every service, library, and infrastructure component a discoverable home with ownership metadata, documentation links, and dependency graphs. Software templates let platform engineers offer golden-path scaffolding so new projects start with the right CI/CD pipelines, observability, and security defaults baked in. + +The plugin architecture is where Backstage earns its place in the platform engineering stack. Kubernetes dashboards, Argo CD sync status, PagerDuty on-call schedules, and cost dashboards all surface inside a single portal instead of forcing developers to context-switch between a dozen tools. The TechDocs system turns markdown in repos into browsable documentation that stays next to the services it describes. + +Running Backstage in production is nontrivial. It requires a PostgreSQL backend, an authentication provider, and ongoing plugin maintenance as upstream versions evolve. The reward is a developer experience layer that turns a collection of loosely connected DevOps tools into a coherent self-service platform—exactly the abstraction that platform engineering teams exist to build. diff --git a/src/content/tags/crossplane/cover.jpg b/src/content/tags/crossplane/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/crossplane/cover.jpg differ diff --git a/src/content/tags/crossplane/crossplane.svg b/src/content/tags/crossplane/crossplane.svg new file mode 100644 index 000000000..6ea33f009 --- /dev/null +++ b/src/content/tags/crossplane/crossplane.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/crossplane/index.mdx b/src/content/tags/crossplane/index.mdx new file mode 100644 index 000000000..cca3b9a34 --- /dev/null +++ b/src/content/tags/crossplane/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "crossplane" +displayName: "Crossplane" +description: "Kubernetes-native cloud infrastructure provisioning using custom resources, compositions, and provider integrations for self-service platform engineering workflows." +cover: "./cover.jpg" +coverAlt: "Abstract Kubernetes control plane diagram branching into multi-cloud resource nodes" +featured: false +logo: "./crossplane.svg" +isSkill: true +--- + +Crossplane extends the Kubernetes API to manage cloud infrastructure the same way you manage workloads—through declarative YAML and the reconciliation loop. Instead of writing Terraform modules that run in a separate pipeline, platform teams define Composite Resource Definitions (XRDs) and Compositions that let application developers request databases, queues, and storage buckets through familiar kubectl commands. The infrastructure becomes a native part of the cluster's desired-state model. + +This approach shines in platform engineering because it enables genuine self-service without giving developers direct cloud console access. A platform team publishes an opinionated XRD for a PostgreSQL instance, encoding VPC placement, backup schedules, and encryption defaults into the Composition. Developers claim one with a short manifest, and the Crossplane controller handles provisioning through AWS, Azure, or GCP providers. Drift detection and remediation happen continuously, just like any other Kubernetes resource. + +The operational cost is real. Crossplane providers generate hundreds of CRDs that increase API server memory pressure, and debugging failed reconciliations requires understanding both the Crossplane machinery and the underlying cloud API. Teams that invest in solid composition testing and provider version pinning get a powerful infrastructure abstraction layer; those that skip that discipline get a different flavor of infrastructure sprawl. diff --git a/src/content/tags/docker/cover.jpg b/src/content/tags/docker/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/docker/cover.jpg differ diff --git a/src/content/tags/docker/docker.svg b/src/content/tags/docker/docker.svg new file mode 100644 index 000000000..c773a053a --- /dev/null +++ b/src/content/tags/docker/docker.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/src/content/tags/docker/index.mdx b/src/content/tags/docker/index.mdx new file mode 100644 index 000000000..4e84cd7dd --- /dev/null +++ b/src/content/tags/docker/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "docker" +displayName: "Docker" +description: "Container images, multi-stage builds, and runtime tooling for packaging applications with reproducible dependencies across development, CI, and production environments." +cover: "./cover.jpg" +coverAlt: "Stacked shipping containers arranged into an organized modular grid pattern" +featured: false +logo: "./docker.svg" +isSkill: true +--- + +Docker changed how platform engineers think about deployment artifacts. Instead of configuring servers to match application requirements, teams package applications with their exact dependencies into images that run identically everywhere. Multi-stage builds keep production images lean, BuildKit caching speeds up CI pipelines, and OCI-compliant registries give organizations a standard way to store and distribute artifacts across environments. + +For platform engineering, Docker sits at the foundation layer. Golden-path Dockerfiles enforce security baselines—non-root users, minimal base images, pinned package versions—before code ever reaches a cluster. Build pipelines scan images for CVEs, sign them with cosign or Notary, and push to private registries with promotion policies that gate what reaches production. The container image becomes the unit of trust between development teams and the platform. + +Docker's role has shifted as Kubernetes handles orchestration and containerd runs workloads directly. The Docker CLI and Compose remain essential for local development, letting engineers run multi-service stacks on their laptops with a single command. Understanding image layering, build context optimization, and runtime security constraints still matters daily—even in environments where Docker Engine itself never touches a production node. diff --git a/src/content/tags/dotnet/cover.jpg b/src/content/tags/dotnet/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/dotnet/cover.jpg differ diff --git a/src/content/tags/dotnet/dotnet.svg b/src/content/tags/dotnet/dotnet.svg new file mode 100644 index 000000000..b2406bbd9 --- /dev/null +++ b/src/content/tags/dotnet/dotnet.svg @@ -0,0 +1,64 @@ + + + + + + + + + + diff --git a/src/content/tags/dotnet/index.mdx b/src/content/tags/dotnet/index.mdx new file mode 100644 index 000000000..fb09a3308 --- /dev/null +++ b/src/content/tags/dotnet/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "dotnet" +displayName: ".NET / C#" +description: "C# and the .NET platform for building platform engineering tooling, cloud-native microservices, internal APIs, and DevOps automation in enterprise environments." +cover: "./cover.jpg" +coverAlt: "Abstract .NET logo formed from interlocking geometric code blocks" +featured: false +logo: "./dotnet.svg" +isSkill: true +--- + +.NET and C# dominate enterprise backend development, and that makes them unavoidable in platform engineering. Many of the internal services that platform teams build on top of—identity providers, API gateways, legacy monoliths being decomposed into microservices—run on .NET. Understanding the runtime, its deployment model, and its operational characteristics is essential for building platforms that serve .NET application teams well. + +From a platform engineering perspective, .NET's strengths include first-class Azure integration, mature observability through OpenTelemetry's .NET SDK, and a container story that has improved dramatically with minimal APIs and AOT compilation. Health check endpoints, structured logging with Serilog or the built-in logging abstractions, and gRPC support are all part of the standard toolkit. Platform teams can publish opinionated project templates via `dotnet new` that enforce organizational standards from the first commit. + +The DevOps angle matters too. .NET builds are resource-intensive compared to Go or Python, so CI pipeline optimization—NuGet caching, incremental builds, and parallelized test execution—directly affects developer feedback loops. Understanding MSBuild, solution structure, and the NuGet dependency graph helps platform engineers design build systems that scale across hundreds of repositories without becoming a bottleneck. diff --git a/src/content/tags/go/cover.jpg b/src/content/tags/go/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/go/cover.jpg differ diff --git a/src/content/tags/go/go.svg b/src/content/tags/go/go.svg new file mode 100644 index 000000000..a7ccb386b --- /dev/null +++ b/src/content/tags/go/go.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/go/index.mdx b/src/content/tags/go/index.mdx new file mode 100644 index 000000000..1d0a54e2f --- /dev/null +++ b/src/content/tags/go/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "go" +displayName: "Go Language" +description: "Go for platform engineering and DevOps tooling, from Kubernetes controllers and CLI tools to high-concurrency infrastructure services and cloud-native operators." +cover: "./cover.jpg" +coverAlt: "Minimalist gopher mascot silhouette integrated into a circuit board pattern" +featured: false +logo: "./go.svg" +isSkill: true +--- + +Go is the lingua franca of cloud-native infrastructure. Kubernetes, Docker, Terraform, Prometheus, and Argo CD are all written in it, which means platform engineers who read and write Go can extend, debug, and contribute to the tools they depend on daily. When a Kubernetes controller misbehaves or a Terraform provider has a gap, the ability to trace through Go source code turns a black box into a solvable problem. + +The language's practical strengths align with platform engineering work. Static binaries with zero runtime dependencies simplify distribution—a CLI tool or operator ships as a single file that runs anywhere. Goroutines and channels handle the concurrent I/O that infrastructure tools demand, and the standard library covers HTTP servers, JSON handling, and cryptography without pulling in external dependencies. Fast compilation means tight feedback loops even in large codebases. + +Go's opinionated design—explicit error handling, no generics until recently, a deliberately small feature set—keeps codebases readable across teams. For platform teams maintaining controllers, admission webhooks, and internal CLIs that outlive any single engineer, that readability is a feature worth the verbosity trade-off. The ecosystem's convention over configuration ethos matches the reliability-first mindset that SRE and platform engineering demand. diff --git a/src/content/tags/grafana/cover.jpg b/src/content/tags/grafana/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/grafana/cover.jpg differ diff --git a/src/content/tags/grafana/grafana.svg b/src/content/tags/grafana/grafana.svg new file mode 100644 index 000000000..ef8329175 --- /dev/null +++ b/src/content/tags/grafana/grafana.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + diff --git a/src/content/tags/grafana/index.mdx b/src/content/tags/grafana/index.mdx new file mode 100644 index 000000000..a682c240e --- /dev/null +++ b/src/content/tags/grafana/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "grafana" +displayName: "Grafana" +description: "Grafana dashboards and the LGTM observability stack for metrics visualization, log exploration, distributed tracing, and SLO-driven alerting in platform engineering." +cover: "./cover.jpg" +coverAlt: "Dashboard panel grid displaying time-series graphs and heatmaps in warm orange tones" +featured: false +logo: "./grafana.svg" +isSkill: true +--- + +Grafana has grown from a metrics dashboard into the visualization layer for the entire observability stack. Paired with Prometheus for metrics, Loki for logs, Tempo for traces, and Mimir for long-term storage, it gives platform teams a single interface to correlate signals across infrastructure and applications. The ability to jump from a spike on a dashboard panel to the exact log lines and traces that explain it is what turns monitoring from a passive display into an active debugging tool. + +For platform engineering, Grafana's value is in standardization. Dashboard-as-code with Grafonnet or Terraform's Grafana provider lets teams version-control their observability views alongside the infrastructure they describe. Alerting rules defined in code, provisioned through CI, and routed through Alertmanager or Grafana's built-in contact points create a repeatable incident response foundation. SLO dashboards backed by real error budgets give service owners a shared language for reliability conversations. + +The operational reality is dashboard sprawl. Without governance, every team creates bespoke dashboards that nobody else can interpret. Platform teams that invest in golden-signal dashboard templates, consistent label taxonomies, and self-service provisioning through Backstage or internal tooling get observability that scales. Those that don't end up with hundreds of dashboards and no shared understanding of system health. diff --git a/src/content/tags/helm/cover.jpg b/src/content/tags/helm/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/helm/cover.jpg differ diff --git a/src/content/tags/helm/helm.svg b/src/content/tags/helm/helm.svg new file mode 100644 index 000000000..77233bbc6 --- /dev/null +++ b/src/content/tags/helm/helm.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/helm/index.mdx b/src/content/tags/helm/index.mdx new file mode 100644 index 000000000..1f50086dd --- /dev/null +++ b/src/content/tags/helm/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "helm" +displayName: "Helm" +description: "Helm charts for Kubernetes application packaging, templated deployments, release management, and standardized service delivery across platform engineering workflows." +cover: "./cover.jpg" +coverAlt: "Ship's helm wheel with spokes connecting to Kubernetes resource manifest icons" +featured: false +logo: "./helm.svg" +isSkill: true +--- + +Helm is the package manager for Kubernetes, and for platform engineers it serves as the primary abstraction layer between raw manifests and repeatable deployments. A well-structured Helm chart encapsulates an application's Deployments, Services, ConfigMaps, and RBAC rules into a versioned, configurable artifact. Teams install the same chart across development, staging, and production by swapping values files—keeping the manifest logic consistent while varying environment-specific configuration. + +Platform teams use Helm to publish opinionated chart libraries that enforce organizational standards. A base chart might set resource limits, pod disruption budgets, security contexts, and standard labels that every service inherits. Argo CD and Flux both natively understand Helm charts, so GitOps pipelines can reference chart versions in Git and let the controller handle rendering and deployment. OCI-compatible registries now store charts alongside container images, simplifying artifact management. + +The friction comes from Helm's Go templating engine. Complex conditionals, nested loops, and sprig functions produce templates that are difficult to read and harder to debug when rendering fails. Teams that keep charts simple—thin value layers over well-tested base charts—avoid the worst of the templating complexity. Those that try to encode every edge case into a single chart end up with unmaintainable YAML generation logic that nobody wants to touch. diff --git a/src/content/tags/kubernetes/cover.jpg b/src/content/tags/kubernetes/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/kubernetes/cover.jpg differ diff --git a/src/content/tags/kubernetes/index.mdx b/src/content/tags/kubernetes/index.mdx new file mode 100644 index 000000000..ad3b59f37 --- /dev/null +++ b/src/content/tags/kubernetes/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "kubernetes" +displayName: "Kubernetes" +description: "Kubernetes container orchestration for platform engineering, covering cluster operations, workload scheduling, networking, RBAC, and the extension points that make internal platforms possible." +cover: "./cover.jpg" +coverAlt: "Kubernetes helm wheel logo at the center of an interconnected node and pod network" +featured: false +logo: "./kubernetes.svg" +isSkill: true +--- + +Kubernetes is the operating system of platform engineering. Its declarative API, reconciliation loop, and extensibility model provide the foundation that tools like Argo CD, Crossplane, and Helm build on. For platform teams, Kubernetes is less about running containers and more about providing a consistent control plane where infrastructure, deployments, and policies converge into a single programmable surface that application teams consume through self-service abstractions. + +The depth of Kubernetes knowledge that platform engineering demands goes well beyond deploying workloads. Cluster networking with CNI plugins, ingress controller tuning, pod security standards, RBAC policy design, and resource quota management are the daily concerns that determine whether a multi-tenant cluster is secure and stable or a shared liability. Custom Resource Definitions and operator patterns let platform teams extend the API server with domain-specific abstractions—turning Kubernetes into a platform-building framework rather than just a runtime. + +Operational maturity means understanding failure modes: etcd latency under load, node pressure evictions, webhook timeout cascading, and the subtle ways misconfigured HPA and PDB interact during rollouts. Platform engineers who invest in cluster observability, upgrade automation, and capacity planning build platforms that application teams trust. Those who treat Kubernetes as a black-box deployment target inevitably face reliability surprises at scale. diff --git a/src/content/tags/kubernetes/kubernetes.svg b/src/content/tags/kubernetes/kubernetes.svg new file mode 100644 index 000000000..143a92d20 --- /dev/null +++ b/src/content/tags/kubernetes/kubernetes.svg @@ -0,0 +1,86 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/content/tags/openstack/cover.jpg b/src/content/tags/openstack/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/openstack/cover.jpg differ diff --git a/src/content/tags/openstack/index.mdx b/src/content/tags/openstack/index.mdx new file mode 100644 index 000000000..fdf1c213e --- /dev/null +++ b/src/content/tags/openstack/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "openstack" +displayName: "Open Stack" +description: "OpenStack private cloud infrastructure for compute, networking, and storage provisioning in regulated environments where public cloud adoption faces compliance or sovereignty constraints." +cover: "./cover.jpg" +coverAlt: "Layered infrastructure stack with compute, network, and storage tiers in red and white" +featured: false +logo: "./openstack.svg" +isSkill: true +--- + +OpenStack provides the building blocks for private cloud infrastructure—compute (Nova), networking (Neutron), block storage (Cinder), object storage (Swift), and identity (Keystone)—that organizations operate on their own hardware. In industries where data sovereignty, regulatory compliance, or latency requirements rule out public cloud, OpenStack remains the most mature open-source option for delivering cloud-like self-service to internal teams without ceding control of the underlying infrastructure. + +For platform engineers, OpenStack is both an infrastructure provider and an operational challenge. Deploying and upgrading a production OpenStack cluster requires deep knowledge of Linux networking, storage backends, message queues, and database clustering. Tools like Kolla-Ansible and TripleO automate deployment, but day-two operations—capacity planning, tenant isolation, quota management, and certificate rotation—demand ongoing engineering investment that public cloud providers absorb behind their APIs. + +The modern platform engineering angle often involves running Kubernetes on top of OpenStack, using Magnum or Cluster API providers to offer managed clusters to application teams. This hybrid approach gives organizations the compliance posture of on-premises infrastructure with the developer experience of container orchestration. The operational cost is higher than managed cloud services, but for the right constraints it delivers control that no public cloud can match. diff --git a/src/content/tags/openstack/openstack.svg b/src/content/tags/openstack/openstack.svg new file mode 100644 index 000000000..df4a58b97 --- /dev/null +++ b/src/content/tags/openstack/openstack.svg @@ -0,0 +1,82 @@ + + + + + + + + image/svg+xml + + OpenStack_Logo2016 + + + + + OpenStack_Logo2016 + Created with Sketch. + + + + + + diff --git a/src/content/tags/prometheus/cover.jpg b/src/content/tags/prometheus/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/prometheus/cover.jpg differ diff --git a/src/content/tags/prometheus/index.mdx b/src/content/tags/prometheus/index.mdx new file mode 100644 index 000000000..0b03a9ad5 --- /dev/null +++ b/src/content/tags/prometheus/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "prometheus" +displayName: "Prometheus" +description: "Prometheus metrics collection, PromQL querying, and Alertmanager routing for infrastructure monitoring, SLO tracking, and reliability engineering in Kubernetes environments." +cover: "./cover.jpg" +coverAlt: "Flame-inspired gauge dial surrounded by radiating time-series metric lines" +featured: false +logo: "./prometheus.svg" +isSkill: true +--- + +Prometheus is the metrics backbone of most Kubernetes-native observability stacks. Its pull-based scraping model, dimensional data model with labels, and powerful PromQL query language give platform teams the foundation for monitoring infrastructure health, tracking service-level objectives, and powering alerting pipelines. As a CNCF graduated project, it defines the standard that exporters, client libraries, and compatible systems like Thanos and Mimir build against. + +For platform engineers, Prometheus work centers on designing a metrics architecture that scales. That means configuring ServiceMonitors and PodMonitors through the Prometheus Operator, setting up federation or remote-write for multi-cluster aggregation, and tuning retention and storage to balance query performance against disk costs. PromQL fluency is essential—writing recording rules that pre-aggregate expensive queries, defining multi-window burn-rate alerts for SLO monitoring, and building dashboards that surface actionable signals instead of vanity metrics. + +The operational challenge is cardinality. Every unique combination of metric name and label values creates a time series, and unbounded labels from request paths, user IDs, or pod names can explode storage and query latency. Platform teams that enforce labeling conventions, set per-tenant series limits, and instrument cardinality dashboards keep Prometheus healthy. Those that skip cardinality governance learn about it during their next outage investigation when queries time out. diff --git a/src/content/tags/prometheus/prometheus.svg b/src/content/tags/prometheus/prometheus.svg new file mode 100644 index 000000000..dcad9d6fc --- /dev/null +++ b/src/content/tags/prometheus/prometheus.svg @@ -0,0 +1,54 @@ + + + +image/svg+xml diff --git a/src/content/tags/python/cover.jpg b/src/content/tags/python/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/python/cover.jpg differ diff --git a/src/content/tags/python/index.mdx b/src/content/tags/python/index.mdx new file mode 100644 index 000000000..bff8faf2b --- /dev/null +++ b/src/content/tags/python/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "python" +displayName: "Python" +description: "Python for DevOps automation, infrastructure scripting, cloud SDK integrations, observability tooling, and rapid prototyping across platform engineering workflows." +cover: "./cover.jpg" +coverAlt: "Intertwined Python serpent shapes forming an automation pipeline diagram" +featured: false +logo: "./python.svg" +isSkill: true +--- + +Python is the default scripting language of platform engineering. When a team needs to automate a cloud workflow, parse API responses, generate Terraform variable files, or build a quick CLI tool, Python is almost always the fastest path from idea to working code. Every major cloud provider ships a Python SDK, Ansible is built on it, and the ecosystem of infrastructure libraries—boto3, azure-sdk, google-cloud-python, kubernetes-client—covers virtually any integration a platform team encounters. + +The language excels at glue code and automation. Migration scripts that shuffle data between systems, cost analysis tools that query cloud billing APIs, incident response runbooks that execute remediation steps, and custom Prometheus exporters that scrape proprietary systems all land naturally in Python. Its readability means on-call engineers can understand and modify scripts written by someone else at 3 AM without deciphering clever abstractions. + +The tradeoff is runtime performance and packaging complexity. Python scripts need a runtime and dependency management—virtual environments, pip, and version pinning—that adds friction compared to Go's static binaries. For long-running services or high-throughput data pipelines, the GIL and startup overhead matter. Platform teams that use Python for automation and scripting while reaching for Go or Rust for performance-critical services get the best of both worlds. diff --git a/src/content/tags/python/python.svg b/src/content/tags/python/python.svg new file mode 100644 index 000000000..36ec557b2 --- /dev/null +++ b/src/content/tags/python/python.svg @@ -0,0 +1,70 @@ + + diff --git a/src/content/tags/ruby/cover.jpg b/src/content/tags/ruby/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/ruby/cover.jpg differ diff --git a/src/content/tags/ruby/index.mdx b/src/content/tags/ruby/index.mdx new file mode 100644 index 000000000..d43f33b0c --- /dev/null +++ b/src/content/tags/ruby/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "ruby" +displayName: "Ruby" +description: "Ruby for infrastructure automation with Chef, Vagrant, and Puppet, plus DevOps scripting, internal tooling, and legacy platform modernization in enterprise environments." +cover: "./cover.jpg" +coverAlt: "Faceted ruby gemstone with internal reflections showing infrastructure code patterns" +featured: false +logo: "./ruby.svg" +isSkill: true +--- + +Ruby's role in platform engineering is rooted in the configuration management era. Chef and Puppet—two of the most widely deployed infrastructure automation frameworks—are written in Ruby and use Ruby-based DSLs for defining system state. Vagrant, the tool that standardized reproducible development environments before containers took over, is also Ruby. Organizations with years of investment in these tools still depend on Ruby expertise to maintain and evolve their automation codebases. + +Beyond legacy tooling, Ruby remains practical for internal platform tools. Its expressive syntax and metaprogramming capabilities make it well-suited for building domain-specific languages, CLI utilities, and API wrapper libraries where developer ergonomics matter more than raw throughput. Rails applications still power internal dashboards, deployment portals, and workflow engines at companies that adopted the framework early and built institutional knowledge around it. + +The platform engineering reality is that Ruby occupies a maintenance and migration position in most infrastructure stacks. Teams are gradually moving Chef cookbooks to Ansible or Terraform, and new CLI tools tend to ship in Go or Python. But understanding Ruby remains valuable for platform engineers who inherit Chef-managed infrastructure, contribute to Homebrew formulas, or work in organizations where Ruby services are part of the production footprint being containerized and moved to Kubernetes. diff --git a/src/content/tags/ruby/ruby.svg b/src/content/tags/ruby/ruby.svg new file mode 100644 index 000000000..5d8ec2656 --- /dev/null +++ b/src/content/tags/ruby/ruby.svg @@ -0,0 +1,967 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/content/tags/terraform/cover.jpg b/src/content/tags/terraform/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/terraform/cover.jpg differ diff --git a/src/content/tags/terraform/index.mdx b/src/content/tags/terraform/index.mdx new file mode 100644 index 000000000..b9cd5668a --- /dev/null +++ b/src/content/tags/terraform/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "terraform" +displayName: "Terraform" +description: "Terraform infrastructure-as-code for multi-cloud provisioning, state management, module composition, and self-service platform engineering with HCL and provider ecosystems." +cover: "./cover.jpg" +coverAlt: "Interlocking hexagonal Terraform tiles forming a multi-cloud infrastructure map" +featured: false +logo: "./terraform.svg" +isSkill: true +--- + +Terraform is the most widely adopted infrastructure-as-code tool in platform engineering. Its declarative HCL syntax, plan-and-apply workflow, and provider ecosystem covering every major cloud and SaaS platform give teams a consistent way to define, version, and provision infrastructure regardless of where it runs. The state file acts as a source of truth that tracks what exists, what changed, and what needs to be created or destroyed on the next apply. + +Platform teams build on Terraform by composing reusable modules that encode organizational standards. A VPC module enforces network segmentation policies, a Kubernetes cluster module wires up node pools with the right instance types and autoscaling rules, and a database module handles encryption, backup schedules, and parameter groups. Published to private registries and consumed through version-pinned module calls, these become the building blocks of self-service infrastructure that application teams request without writing HCL themselves. + +The operational challenges are state management and drift. Remote state backends, state locking, and workspace isolation prevent concurrent modifications from corrupting infrastructure, but they add coordination overhead. Large monolithic state files slow plan times and increase blast radius. Teams that invest in state decomposition, automated drift detection, and policy-as-code with Sentinel or OPA build Terraform workflows that scale across hundreds of engineers and thousands of resources. diff --git a/src/content/tags/terraform/terraform.svg b/src/content/tags/terraform/terraform.svg new file mode 100644 index 000000000..89f81e0ed --- /dev/null +++ b/src/content/tags/terraform/terraform.svg @@ -0,0 +1,53 @@ + + + + + + + + diff --git a/src/content/tags/typescript/cover.jpg b/src/content/tags/typescript/cover.jpg new file mode 100644 index 000000000..cc0c5c7db Binary files /dev/null and b/src/content/tags/typescript/cover.jpg differ diff --git a/src/content/tags/typescript/index.mdx b/src/content/tags/typescript/index.mdx new file mode 100644 index 000000000..aba46735d --- /dev/null +++ b/src/content/tags/typescript/index.mdx @@ -0,0 +1,16 @@ +--- +slug: "typescript" +displayName: "TypeScript" +description: "TypeScript for platform engineering tooling, CDK infrastructure definitions, Backstage plugin development, internal CLI tools, and type-safe API contracts across DevOps workflows." +cover: "./cover.jpg" +coverAlt: "Abstract TypeScript logo badge overlaid on structured type definition patterns" +featured: false +logo: "./typescript.svg" +isSkill: true +--- + +TypeScript has moved well beyond frontend development into platform engineering territory. AWS CDK, Pulumi, and CDKTF all use TypeScript as a first-class language for defining infrastructure, giving teams the ability to use loops, conditionals, and type-checked abstractions instead of declarative configuration languages. Backstage plugins are written in TypeScript, GitHub Actions can be authored in it, and internal CLI tools built with libraries like oclif or commander ship with the same type safety that application code enjoys. + +For platform engineers, TypeScript's value is its type system applied to infrastructure concerns. Defining typed interfaces for deployment configurations, API contracts between services, and shared schema definitions catches entire categories of integration errors at compile time rather than at deploy time. When a platform team publishes an SDK for internal service consumption, TypeScript's autocompletion and type inference make that SDK self-documenting in ways that dynamically typed alternatives cannot match. + +The tradeoff is the Node.js runtime dependency and build step overhead. TypeScript tools require transpilation, and the node_modules ecosystem brings dependency management complexity that Go and Python avoid. For long-running infrastructure services, Node.js event-loop characteristics differ from threaded runtimes. Teams that use TypeScript for CDK constructs, Backstage development, and API tooling while reaching for Go for controllers and CLIs that need static binaries strike a practical balance. diff --git a/src/content/tags/typescript/typescript.svg b/src/content/tags/typescript/typescript.svg new file mode 100644 index 000000000..f632b266c --- /dev/null +++ b/src/content/tags/typescript/typescript.svg @@ -0,0 +1,45 @@ + + + + + + + diff --git a/src/lib/config/markdown.ts b/src/lib/config/markdown.ts index 00a6b4020..7cf41ef7d 100644 --- a/src/lib/config/markdown.ts +++ b/src/lib/config/markdown.ts @@ -203,7 +203,7 @@ export const rehypeAutolinkHeadingsConfig: RehypeAutolinkHeadingsOptions = { type: 'element', tagName: 'span', properties: { - className: ['anchor-link', 'text-md', 'sm:text-lg'], + className: ['anchor-link', 'text-base', 'sm:text-lg'], ariaHidden: 'true', }, children: [ diff --git a/src/pages/articles/[...slug].astro b/src/pages/articles/[...slug].astro index 181cec50f..ed5a4a811 100644 --- a/src/pages/articles/[...slug].astro +++ b/src/pages/articles/[...slug].astro @@ -107,6 +107,13 @@ const section = `Articles` Other things I've written - +
diff --git a/src/pages/articles/index.astro b/src/pages/articles/index.astro index 3376288bc..0dc93e730 100644 --- a/src/pages/articles/index.astro +++ b/src/pages/articles/index.astro @@ -59,6 +59,7 @@ const section = 'Tags' limit={articlesForTag.length} type="articles" items={articlesForTag} + showReadMore={false} /> ) diff --git a/src/pages/case-studies/[slug].astro b/src/pages/case-studies/[slug].astro index 7adeb0158..037f85b13 100644 --- a/src/pages/case-studies/[slug].astro +++ b/src/pages/case-studies/[slug].astro @@ -113,6 +113,7 @@ const section = 'Case Studies' currentSlug={caseStudy.id} limit={4} type="case-studies" + showReadMore={false} /> diff --git a/src/pages/hero.astro b/src/pages/hero.astro index fc9623fed..eda4b310f 100644 --- a/src/pages/hero.astro +++ b/src/pages/hero.astro @@ -6,361 +6,6 @@ const path = '/hero/' --- - -
-
- -
-

- Make production - boring again -

-

- I'm a platform engineer who fixes the reliability gaps that slow teams down: fragile releases, missing - observability, and legacy systems nobody wants to touch. -

-
-
- -
- -
-
-
-

SLO Snapshot

-

Last 7 days

-
-
-
-

99.95%

-

Availability

-
-
-

8m

-

MTTR

-
-
-

0

-

Sev-1s

-
-
-
-
-
- -
- -
-
-

-

Blueprint

-

-
    -
  1. - 01. - -

    Release Safety

    -

    Progressive delivery, rollback paths, guardrails.

    -
    -
  2. -
  3. - 02. - -

    Observability

    -

    Logs, metrics, traces that answer "why".

    -
    -
  4. -
  5. - 03. - -

    DX & Tooling

    -

    Golden paths, templates, consistent environments.

    -
    -
  6. -
  7. - 04. - -

    Legacy Modernization

    -

    Safe migration plans for hard systems.

    -
    -
  8. -
-
-
-
-
- - - -
-
-

Newsletter CTA Variants

- -
-

NEWSLETTER CTA VARIANTS - Variant 1: Enhanced Value Proposition

- -
- -
- - - - - - - - -
- - -
- - -
- -
- - -
- - - -
-
-
- Monthly Dispatch -
-

- Engineering at Scale -

-

- Join 2,000+ platform engineers. Get the operational patterns, post-mortems, and architectural decisions that don't make it into the docs. -

-
- -
    -
  • - - Incident analysis -
  • -
  • - - Scaling strategies -
  • -
  • - - Tool evaluations -
  • -
  • - - Architecture reviews -
  • -
-
-
- - -
-
-
- - - - - - -
- -
-
- - -
- - - -
- -
-
-
-
- -
-
-
- - -
-

NEWSLETTER CTA VARIANTS - Variant 2: Terminal Aesthetic

- -
- -
-
-
-
- newsletter.subscribe -
- -
-

- $ subscribe --platform-engineering -

-

- > Monthly dispatches: infra migrations, observability stacks, and lessons from production incidents. -

- - -
-
- $ - -
- -
- - -
- -
-
-
-
-
-
- - - - -
-
-

Contact CTA Variants

-
-
-
-
-
-
- -
-
-
-

- Need a second set of eyes on production? -

-

- Migrations, CI/CD, observability, and reliability fixes for teams that ship. -

-
- - -
-
-
-
-
-
- @@ -802,6 +447,6 @@ const path = '/hero/'
Trace ID and request ID correlation connect all the pieces.
- + diff --git a/src/pages/index.astro b/src/pages/index.astro index 53f010e86..099737844 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,7 +3,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import Backstage from '@components/Home/Backstage/index.astro' import Carousel from '@components/Carousel/index.astro' import Contact from '@components/CallToAction/Contact/index.astro' -import Hero from '@components/Animations/Hero/index.astro' +import Hero from '@components/Home/Hero/index.astro' import Intro from '@components/Home/Intro/index.astro' import Newsletter from '@components/CallToAction/Newsletter/index.astro' import Skills from '@components/Home/Skills/index.astro' @@ -11,97 +11,147 @@ import Testimonials from '@components/Testimonials/index.astro' const pageDescription = 'Webstack Builders, Inc. Home Page' const pageTitle = 'Web Development & Digital Solutions - Webstack Builders, Inc.' +const sectionClasses = 'container mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8 lg:py-12' + --- - +
+ +
- +
+ +
- +
+ +
- +
+ +
-
-
-
-

- Latest Insights -

-

- Stay up-to-date with the latest trends, best practices, and insights in software - development and platform engineering. -

-
- - +
+ - -
+
- - - -
-
- -
+
+
-
-
- -
+
+ +
+ + +
+
diff --git a/src/pages/testing/animations-computers.astro b/src/pages/testing/animations-terraform.astro similarity index 69% rename from src/pages/testing/animations-computers.astro rename to src/pages/testing/animations-terraform.astro index b624092f2..07f9b6a1e 100644 --- a/src/pages/testing/animations-computers.astro +++ b/src/pages/testing/animations-terraform.astro @@ -1,17 +1,17 @@ --- import BaseLayout from '@layouts/BaseLayout.astro' -import ComputersAnimation from '@components/Animations/Computers/index.astro' +import TerraformAnimation from '@components/Animations/Terraform/index.astro' -const pageTitle = 'Computers Animation Fixture' +const pageTitle = 'Terraform Animation Fixture' const pageDescription = - 'Isolated test route for the hero computers animation and its lifecycle controller.' -const pagePath = '/testing/animations-computers' + 'Isolated test route for the hero terraform animation and its lifecycle controller.' +const pagePath = '/testing/animations-terraform' ---

- This dedicated route hosts the hero computers animation inside a stable shell so automated + This dedicated route hosts the hero terraform animation inside a stable shell so automated tests can validate pause and play semantics without relying on home page content or layout changes.

@@ -22,6 +22,6 @@ const pagePath = '/testing/animations-computers'
- +
diff --git a/src/pages/testing/carousel.astro b/src/pages/testing/carousel.astro index 154393df7..2de61f1bc 100644 --- a/src/pages/testing/carousel.astro +++ b/src/pages/testing/carousel.astro @@ -28,5 +28,6 @@ const pagePath = '/testing/carousel' limit={6} currentSlug="carousel-testing" type="case-studies" + showReadMore={false} /> diff --git a/src/pages/testing/newsletter.astro b/src/pages/testing/newsletter.astro index 9204f0af5..99c6b5f06 100644 --- a/src/pages/testing/newsletter.astro +++ b/src/pages/testing/newsletter.astro @@ -26,6 +26,7 @@ const pagePath = '/testing/newsletter' description="Use this isolated page to validate newsletter flows without the rest of the marketing site." placeholder="Enter an email for automated testing" buttonText="Subscribe" + variant="home" />
diff --git a/src/styles/theme-inline.css b/src/styles/theme-inline.css index de13bde9a..a0f4f1706 100644 --- a/src/styles/theme-inline.css +++ b/src/styles/theme-inline.css @@ -63,7 +63,7 @@ --color-content-offset: var(--theme-color-content-offset); /* Light theme: #9ca3af */ /** A shade of the text color for hover and focus states */ - --color-content-active: var(--theme-color-content-active); /* Light theme: #9ca3af */ + --color-content-active: var(--theme-color-content-active); /* Light theme: #001a39 */ /** Main content text color */ --color-content-inverse: var(--theme-color-content-inverse); /* Light theme: #f3f4f6 */ diff --git a/src/styles/themes/a11y.css b/src/styles/themes/a11y.css index 4c3dda9ec..97ffa1023 100644 --- a/src/styles/themes/a11y.css +++ b/src/styles/themes/a11y.css @@ -9,7 +9,7 @@ * can reuse them without duplicating hex values. */ --a11y-theme-color-primary: #000; - --a11y-theme-color-primary-offset: #111827; + --a11y-theme-color-primary-offset: #374151; --a11y-theme-color-primary-inverse: #fff; --a11y-theme-color-secondary: #000; --a11y-theme-color-secondary-offset: #111827; @@ -25,7 +25,7 @@ --a11y-theme-color-content-inverse: #fff; --a11y-theme-color-content-inverse-active: #d1d5db; --a11y-theme-color-trim: #111827; - --a11y-theme-color-spotlight: #374151; + --a11y-theme-color-spotlight: #d1d5db; --a11y-theme-color-spotlight-offset: #111827; --a11y-theme-color-success: #16a34a; --a11y-theme-color-success-offset: #22c55e; diff --git a/src/styles/themes/dark.css b/src/styles/themes/dark.css index 57984ae6a..e524ff266 100644 --- a/src/styles/themes/dark.css +++ b/src/styles/themes/dark.css @@ -14,7 +14,7 @@ --theme-color-primary: #f3f4f6; /** A shade of the primary color for accent */ - --theme-color-primary-offset: #fff; + --theme-color-primary-offset: #d1d5db; /** A shade of the primary color for accent */ --theme-color-primary-inverse: #001A39; @@ -65,7 +65,7 @@ --theme-color-content-offset: #e5e7eb; /** --color-content-offset */ /** A shade of the text color for hover and focus states */ - --theme-color-content-active: #e5e7eb; /** --color-content-active */ + --theme-color-content-active: #9ca3af; /** --color-content-active */ /** Main content text color */ --theme-color-content-inverse: #001a39; /** --color-content */ diff --git a/src/styles/themes/light.css b/src/styles/themes/light.css index f6a864d52..8f4a03b3c 100644 --- a/src/styles/themes/light.css +++ b/src/styles/themes/light.css @@ -65,7 +65,7 @@ --theme-color-content-offset: #9ca3af; /** --color-content-offset */ /** A shade of the text color for hover and focus states */ - --theme-color-content-active: #9ca3af; /** --color-content-active */ + --theme-color-content-active: #001a39; /** --color-content-active */ /** Main content text color opposite */ --theme-color-content-inverse: #f3f4f6; /** --color-content */ diff --git a/src/styles/typography.css b/src/styles/typography.css index 0553fa3b6..3989f4ab5 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -112,7 +112,7 @@ * Help prevent overflow of long words/names/URLs * (Enhancement not in Tailwind Preflight) */ - hyphens: auto; + hyphens: manual; } /** Blockquote styling */ @@ -148,7 +148,7 @@ * Help prevent overflow of long words/names/URLs * (Enhancement not in Tailwind Preflight) */ - hyphens: auto; + hyphens: none; line-height: var(--line-height-tight); overflow-wrap: break-word; } @@ -225,7 +225,7 @@ */ li { /** Allow hyphens */ - hyphens: auto; + hyphens: manual; overflow-wrap: break-word; } diff --git a/test/e2e/POM_METHOD_USAGE.md b/test/e2e/POM_METHOD_USAGE.md index 7b64b6c94..c96e69625 100644 --- a/test/e2e/POM_METHOD_USAGE.md +++ b/test/e2e/POM_METHOD_USAGE.md @@ -453,7 +453,7 @@ has navigateToDiagnosticsPage and getSnapshot: - test/e2e/specs/01-smoke/dynamic-pages.spec.ts - test/e2e/specs/02-pages/services.spec.ts -- test/e2e/specs/04-components/animations-computers.spec.ts +- test/e2e/specs/04-components/animations-terraform.spec.ts - test/e2e/specs/04-components/carousel.spec.ts - test/e2e/specs/04-components/consentBanner.spec.ts - test/e2e/specs/04-components/footer.spec.ts diff --git a/test/e2e/specs/04-components/animations-computers.spec.ts b/test/e2e/specs/04-components/animations-terraform.spec.ts similarity index 91% rename from test/e2e/specs/04-components/animations-computers.spec.ts rename to test/e2e/specs/04-components/animations-terraform.spec.ts index 3704fe49e..aee0fbcf7 100644 --- a/test/e2e/specs/04-components/animations-computers.spec.ts +++ b/test/e2e/specs/04-components/animations-terraform.spec.ts @@ -4,7 +4,7 @@ import type { Page } from '@playwright/test' type AnimationPlayState = 'playing' | 'paused' | null const selectors = { - host: 'computers-animation', + host: 'terraform-animation', toggle: '[data-animation-toggle]', } @@ -19,10 +19,10 @@ const isMobileProject = (projectName: string): boolean => { return projectName.startsWith('mobile-') } -async function loadComputersFixture(playwrightPage: Page, options: FixtureOptions = {}): Promise { +async function loadTerraformFixture(playwrightPage: Page, options: FixtureOptions = {}): Promise { const page = await BasePage.init(playwrightPage) await page.page.emulateMedia({ reducedMotion: options.reducedMotion ?? 'no-preference' }) - await page.goto('/testing/animations-computers') + await page.goto('/testing/animations-terraform') // NOTE: The component is intentionally `hidden lg:flex` in production markup. // On mobile projects it will remain hidden, so waiting for "visible" will hang. await page.waitForSelector(selectors.host, { state: 'attached' }) @@ -32,7 +32,7 @@ async function loadComputersFixture(playwrightPage: Page, options: FixtureOption async function getAnimationState(page: BasePage): Promise { return await page.evaluate(() => { - return document.querySelector('computers-animation')?.getAttribute('data-animation-state') as AnimationPlayState + return document.querySelector('terraform-animation')?.getAttribute('data-animation-state') as AnimationPlayState }) } @@ -56,9 +56,9 @@ async function setOverlayPause(page: BasePage, isPaused: boolean): Promise }, { paused: isPaused, source: overlaySource }) } -test.describe('Computers Animation Component', () => { +test.describe('Terraform Animation Component', () => { test('plays by default when no pause sources exist', async ({ page: playwrightPage }, testInfo) => { - const page = await loadComputersFixture(playwrightPage) + const page = await loadTerraformFixture(playwrightPage) const host = page.locator(selectors.host) const toggle = page.locator(selectors.toggle) @@ -77,7 +77,7 @@ test.describe('Computers Animation Component', () => { }) test('honors prefers-reduced-motion by starting paused', async ({ page: playwrightPage }, testInfo) => { - const page = await loadComputersFixture(playwrightPage, { reducedMotion: 'reduce' }) + const page = await loadTerraformFixture(playwrightPage, { reducedMotion: 'reduce' }) const host = page.locator(selectors.host) await waitForAnimationState(page, 'paused') @@ -96,7 +96,7 @@ test.describe('Computers Animation Component', () => { test( 'responds to overlay pause and resume actions from the animation store', async ({ page: playwrightPage }, testInfo) => { - const page = await loadComputersFixture(playwrightPage) + const page = await loadTerraformFixture(playwrightPage) const host = page.locator(selectors.host) // On mobile projects the component is intentionally `hidden lg:flex` and must not start. diff --git a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts index 08fc71066..9fd187a31 100644 --- a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts +++ b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts @@ -116,6 +116,24 @@ describe('enforce-centralized-events', () => { this.addEventListener('keyup', this.handleKeyUp); } } + `, + filename: 'src/components/MyComponent/index.ts', + }, + + // ======================================== + // Valid: LitElement web components + // ======================================== + { + code: ` + import { LitElement } from 'lit'; + + class MyComponent extends LitElement { + connectedCallback() { + super.connectedCallback(); + this.addEventListener('click', this.handleClick); + this.addEventListener('keyup', this.handleKeyUp); + } + } `, filename: 'src/components/MyComponent/index.ts', }, diff --git a/test/eslint/enforce-centralized-events-rule.ts b/test/eslint/enforce-centralized-events-rule.ts index 25b005dd2..0afec12e2 100644 --- a/test/eslint/enforce-centralized-events-rule.ts +++ b/test/eslint/enforce-centralized-events-rule.ts @@ -78,7 +78,7 @@ const enforceCentralizedEventsRule: Rule.RuleModule = { let foundWebComponent = false const visitorKeys = sourceCode.visitorKeys ?? {} - const isHTMLElementSubclass = (node: Node): boolean => { + const isWebComponentSubclass = (node: Node): boolean => { if (node.type !== 'ClassDeclaration' && node.type !== 'ClassExpression') { return false } @@ -87,14 +87,14 @@ const enforceCentralizedEventsRule: Rule.RuleModule = { return Boolean( superClass && superClass.type === 'Identifier' && - superClass.name === 'HTMLElement', + (superClass.name === 'HTMLElement' || superClass.name === 'LitElement'), ) } const checkNode = (astNode: Node | null | undefined): void => { if (!astNode || foundWebComponent) return - if (isHTMLElementSubclass(astNode)) { + if (isWebComponentSubclass(astNode)) { foundWebComponent = true return }