adding skills and site

This commit is contained in:
2026-02-03 12:27:57 -08:00
parent ad37eda106
commit 2bc3b78be8
64 changed files with 20081 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Deploy Microsite
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
BASE_HREF: /${{ github.event.repository.name }}/
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./_site
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+2
View File
@@ -0,0 +1,2 @@
node_modules/
_site/
+58
View File
@@ -8,5 +8,63 @@ npx skills add rodydavis/skills
| Skill | Description |
|---|---|
| [how-to-run-astro-ssr-and-pocketbase-on-the-same-server](./skills/astro-ssr-pocketbase-single-server/SKILL.md) | Learn how to host PocketBase and an Astro SSR application on the same server, using PocketBase's Go integration and a reverse proxy to delegate requests to Astro for dynamic web content. |
| [async-preact-signals](./skills/async-preact-signal/SKILL.md) | Explore how to effectively manage asynchronous data with Preact Signals by creating a custom `asyncSignal` that handles loading, error, and data states without breaking the synchronous nature of signals. |
| [automate-flutter-app-releases](./skills/automate-flutter-apps/SKILL.md) | Automate your Flutter app releases to beta or production with this handy shell script that handles version bumping, formatting, cleaning, rebuilding, and deployment via Fastlane. |
| [dynamic-themes-with-codemirror](./skills/codemirror-dynamic-theme/SKILL.md) | Learn how to create a Lit web component with CodeMirror, dynamically themed using Material Design's color utilities, for a customizable code editing experience. |
| [how-to-create-html-web-components-with-dart](./skills/dart-html-web-components/SKILL.md) | Discover the power of Web Components and how to build them with both JavaScript and Dart for reusable, framework-agnostic UI elements. |
| [how-to-do-bitwise-operations-in-dart](./skills/dart_bitwise/SKILL.md) | Explore Dart's bitwise operations for both integers and booleans, including AND, OR (inclusive & exclusive), NAND, NOR, and XNOR, with practical code examples. |
| [various-ways-to-invoke-functions-in-dart](./skills/dart_function-invoking/SKILL.md) | Discover the surprising flexibility of calling Dart functions, including mixed positional and named arguments, the `.call` operator, and dynamic invocation with `Function.apply`. |
| [how-to-print-multiple-objects-to-the-console-with-print-in-dart](./skills/dart_print-multiple-objects/SKILL.md) | Learn how to print multiple objects to the console in Dart using Records, offering a similar experience to JavaScript's `console.log()` functionality. |
| [check-if-an-object-is-truthy-in-dart](./skills/dart_truthy/SKILL.md) | Learn how to extend Dart's functionality to implement JavaScript-style "truthy" checks for easier conditional logic and value evaluations. |
| [deep-linking-for-flutter-web](./skills/deep-linking-flutter-web/SKILL.md) | Learn how to implement proper URL navigation in your Flutter application, including deep linking to specific pages, handling protected routes, and creating custom transitions for a seamless user experience. |
| [displaying-html-in-flutter](./skills/display-html-in-flutter/SKILL.md) | Easily display and interact with HTML content in your Flutter app using the `easy_web_view` package, which supports both web and mobile platforms. |
| [how-to-export-sqlite-tables-to-create-statements](./skills/export-sqlite-dart/SKILL.md) | Learn how to export your entire SQLite database schema, including tables and indexes, into runnable CREATE statements at runtime using Flutter and the `sqlite3` package. |
| [using-fastlane-in-flutter-and-ci](./skills/fastlane-and-flutter/SKILL.md) | Automate Flutter app builds and deployments to both the App Store and Google Play using Fastlane with this step-by-step guide. |
| [lit-and-figma](./skills/figma-and-lit/SKILL.md) | Learn how to create a Figma plugin using Lit web components, including project setup, component creation, WASM integration, and building the final plugin for use in Figma. |
| [creating-your-first-flutter-project](./skills/first-flutter-project/SKILL.md) | Dive into the world of Flutter, Google's UI toolkit, and learn how to build cross-platform apps with ease using this introductory guide and accompanying "Flutter Take 5" video series. |
| [lit-and-flutter](./skills/flutter-and-lit/SKILL.md) | Learn how to embed a Lit web component directly within your Flutter app to leverage web-based UIs and features while accessing native device APIs for a powerful hybrid development approach. |
| [how-to-build-a-flutter-app-on-xcode-cloud](./skills/flutter-and-xcode-cloud/SKILL.md) | Learn how to set up Xcode Cloud to build and deploy your Flutter application to TestFlight and the App Store with this step-by-step guide. |
| [flutter-terminal-cheat-sheet](./skills/flutter-cheat-sheet/SKILL.md) | This post provides a handy collection of Flutter commands and scripts for web development, package creation, troubleshooting, testing, and more, streamlining your Flutter workflow. |
| [how-to-build-a-graph-database-with-flutter](./skills/flutter-graph-database/SKILL.md) | Learn how to build and utilize a graph database within your Flutter applications using SQLite and the Drift package to model relationships between data. |
| [multi-touch-canvas-with-flutter](./skills/flutter-multi-touch-canvas/SKILL.md) | Learn how to create a Flutter canvas with multi-touch support for panning, zooming, and object interaction, overcoming common gesture recognition conflicts. |
| [flutter-fastlane-one-click-beta](./skills/flutter-one-click-release/SKILL.md) | Deploy your Flutter app to the App Store and Google Play with ease using this step-by-step guide covering installation, project setup, Fastlane integration, and automated deployments with Automator. |
| [server-side-rendering-flutter-apps-with-rfw](./skills/flutter-ssr-rfw/SKILL.md) | Learn how to build a dynamic Flutter app using Server Side Rendering (SSR) with the rfw package, enabling UI updates driven by server logic and binary data exchange via HTTP. |
| [flutter-control-and-screenshot](./skills/flutter_driver_control/SKILL.md) | Guide on how to control a Flutter app using flutter_driver via MCP and capture screenshots. |
| [how-to-manage-multiple-flutter-versions-with-git-worktrees-and-z](./skills/flutter_git-worktree-channels/SKILL.md) | Manage multiple Flutter versions efficiently using Git worktrees, eliminating the need for external version managers like FVM. |
| [host-your-flutter-project-as-a-rest-api](./skills/host-flutter-rest-api/SKILL.md) | Learn how to structure a Flutter project to reuse models and business logic across iOS, Android, Web, desktop platforms, and a REST API deployable to Google Cloud Run, enabling a single codebase for both client and server. |
| [building-a-html-element-sandbox-with-lit](./skills/html-code-sandbox/SKILL.md) | Learn how to build a Lit web component to create a dynamic HTML element sandbox with live updates, perfect for experimenting with and showcasing web components. |
| [install-flutter-from-git](./skills/install-flutter-from-git/SKILL.md) | Install Flutter SDK via git clone and configure for all platforms |
| [draggable-dom-with-lit](./skills/lit-draggable-dom/SKILL.md) | Learn how to create an interactive, draggable DOM using a Lit web component with CSS transforms and slots, enabling you to manipulate HTML and SVG elements within a canvas-like environment. |
| [2d-or-3d-force-graph-with-lit](./skills/lit-force-graph/SKILL.md) | Learn how to create interactive 2D and 3D force graphs using Lit, a lightweight web component library, with this step-by-step tutorial. |
| [json-to-html-table-with-lit](./skills/lit-html-table/SKILL.md) | Learn how to create a dynamic HTML table from JSON data using a Lit web component, with examples for fetching data from a URL or using inline JSON, and the ability to make the table editable. |
| [lit-and-monaco-editor](./skills/lit-monaco-editor/SKILL.md) | Learn how to create a Lit web component that wraps the Monaco Editor (powering VSCode) to add a fully functional code editor to your web applications. |
| [building-a-rich-text-editor-with-lit](./skills/lit-rich-text-editor/SKILL.md) | Learn how to build a rich text editor using a Lit web component, complete with a toolbar for formatting text, links, and styles. |
| [lit-sheet-music](./skills/lit-sheet-music/SKILL.md) | Learn how to create a Lit web component that renders MusicXML using OpenSheetMusicDisplay, allowing you to display sheet music dynamically from a source attribute or inline XML. |
| [lit-and-vscode-extensions](./skills/lit-vscode-extension/SKILL.md) | Learn how to build a VSCode extension using a Lit web component, covering setup, template creation, component implementation, and extension activation. |
| [building-a-piano-with-flutter](./skills/making-a-piano/SKILL.md) | Build a Tiny Piano in Flutter: Learn how to create a fully functional, Flutter Create contest-winning piano app using just 5032 bytes of Dart code, complete with MIDI support and customizable features. |
| [migrating-my-blog-to-pocketbase](./skills/migrating-my-blog-to-pocketbase/SKILL.md) | This blog post chronicles a developer's fun and ongoing journey of migrating their personal website through various tech stacks, ultimately landing on a PocketBase and Coolify setup for simplified deployment and dynamic features. |
| [how-to-build-a-native-cross-platform-project-with-flutter](./skills/native-cross-platform-flutter/SKILL.md) | Learn how to import `dart:html` and `dart:io` in the same Flutter project to create cross-platform plugins that work seamlessly on mobile and web. |
| [how-to-do-offline-recommendations-with-sqlite-and-gemini](./skills/offline-vector-recommendations/SKILL.md) | Learn how to enhance your CMS like PocketBase with AI-powered content recommendations using text embeddings, SQLite, and k-nearest neighbor search for efficient and scalable related content suggestions. |
| [calling-the-palm-2-api-with-dart-and-flutter](./skills/palm-2-api-dart/SKILL.md) | Learn how to integrate the PaLM 2 API into your Flutter apps using Dart, including setting up API keys, creating prompt templates, and securely making API calls. |
| [how-to-deploy-pocketbase-to-cloud-run](./skills/pocketbase-cloudrun/SKILL.md) | Learn how to deploy PocketBase on Google Cloud Run using the new volume mounting feature, enabling scale-to-zero, infinite storage, and easy backups. |
| [how-to-build-a-webrtc-signal-server-with-pocketbase](./skills/pocketbase-webrtc-signal-server-js/SKILL.md) | Learn how to build a simple WebRTC video call application using PocketBase as a signaling server, enabling peer-to-peer communication with SQLite on the server and realtime updates via Server Sent Events. |
| [how-to-host-your-podcast-for-free-on-github-pages](./skills/podcast-github-pages/SKILL.md) | Launch your podcast for free by leveraging GitHub Pages, GitHub Actions, and a customizable podcast player template (fork this repo!) to share your stories and save on hosting costs. |
| [how-to-send-push-notifications-on-flutter-web-fcm](./skills/push-notifications-flutter-web/SKILL.md) | Learn how to implement Firebase Cloud Messaging (FCM) in your Flutter web app with this guide, covering service worker setup, helper methods, and testing to enable push notifications. |
| [the-perfect-brisket](./skills/recipes_the-perfect-brisket/SKILL.md) | Follow my evolving journey to the perfect smoked brisket with this recipe, meticulously updated after each low-and-slow, all-out cook. |
| [signals-and-flutter-hooks](./skills/signals-and-flutter-hooks/SKILL.md) | Explore state management in Flutter, from the basics of `setState` to advanced techniques using ValueNotifier, Signals, Flutter Hooks, and the new signals_hooks package for a reactive and efficient approach. |
| [flutter-infinite-canvas](./skills/snippets_flutter-infinite-canvas/SKILL.md) | Learn how to build an infinite, multi-touch canvas in Flutter using InteractiveViewer and CustomMultiChildLayout for a flexible and interactive user experience. |
| [flutter-input-output-preview](./skills/snippets_flutter-input-output-preview/SKILL.md) | Build responsive Flutter apps with a reusable `TwoPane` widget and an `InputOutputPreview` component for side-by-side code and preview display on both mobile and desktop. |
| [flutter-markdown-view-with-material-3](./skills/snippets_flutter-markdown-view-material-3/SKILL.md) | Learn how to customize the Flutter Markdown widget using Material 3 text and color styles for a visually appealing and consistent design. |
| [flutter-master-detail-view](./skills/snippets_flutter-master-detail-view/SKILL.md) | Learn how to implement a responsive Master-Detail interface in Flutter that adapts to different screen sizes, leveraging multi-column layouts on larger screens and pushing to detail screens on mobile. |
| [flutter-native-http-client](./skills/snippets_flutter-native-http-client/SKILL.md) | This blog post explores how to optimize HTTP client selection in Flutter applications based on the platform, using Cronet on Android and Cupertino's native client on iOS for improved performance and caching. |
| [flutter-stream-widget](./skills/snippets_flutter-stream-widget/SKILL.md) | Learn how to build dynamic Flutter UIs by directly using streams within your widget's build method, enabling reactive screen updates and more efficient data handling. |
| [lightweight-flutter-animations](./skills/snippets_lightweight-flutter-animations/SKILL.md) | Learn how to create a streamlined animation widget in Flutter that eliminates the need for `setState` by leveraging an abstract class and `SingleTickerProviderStateMixin` for efficient UI updates. |
| [material-3-to-material-2-theme-adapter](./skills/snippets_m3-to-m2-css-adapter/SKILL.md) | Learn how to seamlessly integrate Material Design 3's styling into your Material Design 2 components using CSS variable overrides. |
| [color-utilities-in-javascript](./skills/snippets_typescript-color-utilities/SKILL.md) | Explore helpful color utility functions, like RGB to HSL, HEX to RGB, and HSL to HEX, generated with the assistance of GitHub Copilot. |
| [ios-or-macos-lock-screen-nasa-image-of-the-day](./skills/snippets_workflow-nasa-image-of-day/SKILL.md) | Automate your daily dose of cosmic beauty by setting your lock or home screen to NASA's image of the day using Shortcuts and their public API. |
| [how-to-do-full-text-search-with-sqlite](./skills/sqlite_fts5/SKILL.md) | Learn how to supercharge your SQLite databases with full-text search capabilities using the built-in fts5 extension, enabling efficient and powerful querying with the `MATCH` keyword. |
| [using-sqlite-as-a-key-value-store](./skills/sqlite_key-value/SKILL.md) | Learn how to use SQLite as a simple and efficient key/value store for your applications, offering benefits like single-file data containment, attachment capabilities, and easy integration with tools like Drift. |
| [how-to-store-sqlite-as-nosql-store](./skills/sqlite_no-sql/SKILL.md) | Discover how to leverage SQLite's JSON support to build a NoSQL-like document store, complete with TTL-based expiration, within this powerful embedded database. |
| [sqlite-on-the-ui-thread](./skills/sqlite_ui-thread/SKILL.md) | Unlock the surprising speed of SQLite in Flutter for building responsive UIs, showcasing its ability to handle large datasets with synchronous queries and optimized configurations. |
| [file-based-routing-for-static-sites](./skills/static-site-file-based-routing/SKILL.md) | Learn how to create a multi-page static site with file-based routing using TypeScript, allowing for quick updates and easy content management. |
+80
View File
@@ -0,0 +1,80 @@
{
"name": "skills-microsite",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skills-microsite",
"version": "1.0.0",
"dependencies": {
"markdown-it": "^14.0.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "skills-microsite",
"version": "1.0.0",
"description": "Static site generator for skills microsite",
"main": "scripts/build_site.js",
"scripts": {
"build": "node scripts/build_site.js"
},
"dependencies": {
"markdown-it": "^14.0.0"
}
}
+322
View File
@@ -0,0 +1,322 @@
const fs = require('fs');
const path = require('path');
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
});
const skillsDir = path.join(__dirname, '../skills');
const outputDir = path.join(__dirname, '../_site');
const baseHref = process.env.BASE_HREF || '/';
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Copy assets if any (none for now, but good practice)
// Theme Colors & Styles
const css = `
:root {
--bg-color: oklch(0.15 0.02 260);
--card-bg: oklch(0.2 0.03 260);
--text-primary: oklch(0.95 0.01 260); /* Pearl White */
--text-secondary: oklch(0.7 0.02 260); /* Silver-ish */
--accent-blue: oklch(0.6 0.2 250); /* Electric Blue */
--accent-glow: oklch(0.6 0.2 250 / 0.5);
--border-color: oklch(0.3 0.05 260);
--shining-silver: oklch(0.85 0.01 260);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
line-height: 1.6;
overflow-x: hidden;
}
a {
color: var(--accent-blue);
text-decoration: none;
transition: all 0.3s ease;
}
a:hover {
text-shadow: 0 0 10px var(--accent-glow);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem 0;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(180deg, rgba(0,0,0,0) 0%, var(--bg-color) 100%);
}
h1 {
font-size: 3rem;
font-weight: 800;
background: linear-gradient(to right, var(--text-primary), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 2px;
}
h2, h3, h4, h5, h6 {
color: var(--shining-silver);
margin-top: 2rem;
margin-bottom: 1rem;
}
/* Grid Layout */
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.skill-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
transition: transform 0.3s ease, box-shadow 0.3s ease;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.skill-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, var(--accent-blue), var(--shining-silver));
opacity: 0;
transition: opacity 0.3s ease;
}
.skill-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px -10px var(--accent-glow);
border-color: var(--accent-blue);
}
.skill-card:hover::before {
opacity: 1;
}
.skill-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.skill-desc {
color: var(--text-secondary);
font-size: 0.95rem;
margin-bottom: 1.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--accent-blue);
color: var(--accent-blue);
border-radius: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.8rem;
transition: all 0.3s ease;
}
.btn:hover {
background: var(--accent-blue);
color: #fff;
box-shadow: 0 0 15px var(--accent-glow);
}
/* Detail Page */
.detail-content {
background-color: var(--card-bg);
padding: 3rem;
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow: 0 0 50px -20px #000;
}
.detail-content img {
max-width: 100%;
border-radius: 8px;
margin: 1rem 0;
border: 1px solid var(--border-color);
}
.detail-content pre {
background-color: #0d0d0d;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
border: 1px solid var(--border-color);
margin: 1.5rem 0;
}
.back-link {
display: inline-block;
margin-bottom: 2rem;
color: var(--text-secondary);
font-weight: 600;
}
.back-link:hover {
color: var(--accent-blue);
transform: translateX(-5px);
}
`;
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter = {};
const lines = match[1].split('\n');
lines.forEach(line => {
const [key, ...valueParts] = line.split(':');
if (key && valueParts.length > 0) {
frontmatter[key.trim()] = valueParts.join(':').trim();
}
});
const body = content.replace(match[0], '').trim();
return { frontmatter, body };
}
function renderPage(title, content, isIndex = false) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<base href="${baseHref}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
${!isIndex ? `<a href="." class="back-link">← Back to Skills</a>` : ''}
<header>
<h1>${isIndex ? 'Agent Skills' : title}</h1>
${isIndex ? `
<p style="color: var(--text-secondary); font-size: 1.2rem; opacity: 0.8; margin-bottom: 0.5rem;">by Rody Davis</p>
<div style="margin-top: 1.5rem;">
<a href="https://github.com/rodydavis/skills" class="btn" style="border-radius: 20px; padding: 0.5rem 1.5rem;">View on GitHub</a>
</div>
` : ''}
</header>
<main class="${isIndex ? 'skills-grid' : 'detail-content'}">
${content}
</main>
</div>
</body>
</html>`;
}
function build() {
console.log('Building site...');
// Write CSS file
fs.writeFileSync(path.join(outputDir, 'style.css'), css);
console.log('Generated style.css');
const skills = [];
// 1. Scan and Parse Skills - Recursively
function walk(dir) {
const list = fs.readdirSync(dir);
list.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walk(filePath);
} else if (file === 'SKILL.md') {
const rawContent = fs.readFileSync(filePath, 'utf8');
const { frontmatter, body } = parseFrontmatter(rawContent);
const skillName = path.basename(path.dirname(filePath));
skills.push({
name: frontmatter.name || skillName,
description: frontmatter.description || 'No description provided.',
path: skillName, // use folder name as path slug
content: body
});
}
});
}
walk(skillsDir);
// 2. Generate Skill Pages
skills.forEach(skill => {
const htmlContent = md.render(skill.content);
const pageHtml = renderPage(skill.name, htmlContent);
// Create directory for the skill page (prettier URLs: /skill-name/index.html)
const skillOutputDir = path.join(outputDir, skill.path);
if (!fs.existsSync(skillOutputDir)) {
fs.mkdirSync(skillOutputDir, { recursive: true });
}
fs.writeFileSync(path.join(skillOutputDir, 'index.html'), pageHtml);
});
// 3. Generate Index Page
const cardsHtml = skills.map(skill => `
<article class="skill-card">
<div>
<h2 class="skill-title">${skill.name}</h2>
<p class="skill-desc">${skill.description}</p>
</div>
<a href="${skill.path}/" class="btn">View Skill</a>
</article>
`).join('');
const indexHtml = renderPage('Agent Skills', cardsHtml, true);
fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml);
console.log(`Build complete! Generated ${skills.length} skill pages.`);
}
build();
+63
View File
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const skillsDir = path.join(__dirname, '../skills');
const dryRun = false; // Set to false to actually modify files
const prefix = 'https://rodydavis.com';
function walk(dir, fileList = []) {
if (!fs.existsSync(dir)) return fileList;
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walk(filePath, fileList);
} else {
if (file === 'SKILL.md') {
fileList.push(filePath);
}
}
});
return fileList;
}
console.log(`Scanning ${skillsDir} for relative image paths...`);
if (dryRun) console.log('--- DRY RUN MODE: No changes will be made ---');
const skillFiles = walk(skillsDir);
let changesCount = 0;
skillFiles.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
// Regex to find markdown images with relative paths starting with /
// Pattern: ![alt](url)
// We want to capture the whole link to be safe, or just the URL part.
// Let's replace `](/` with `](${prefix}/`
// This regex looks for:
// 1. `![` (start of image)
// 2. `...` (alt text, non-greedy)
// 3. `](` (start of url)
// 4. `/` (path starting with slash)
// 5. `...` (rest of url)
// 6. `)` (end of url)
// To safely replace, we can target the `](/` sequence that follows `![...]`
const regex = /(!\[.*?\])\((\/[^)]+)\)/g;
if (regex.test(content)) {
console.log(`[${dryRun ? 'DRY RUN' : 'FIX'}] Found relative image path in ${path.relative(skillsDir, file)}`);
if (!dryRun) {
const newContent = content.replace(regex, (match, alt, url) => {
return `${alt}(${prefix}${url})`;
});
fs.writeFileSync(file, newContent);
}
changesCount++;
}
});
console.log(`\n${dryRun ? 'Found' : 'Fixed'} relative image paths in ${changesCount} files.`);
+54
View File
@@ -0,0 +1,54 @@
const fs = require('fs');
const path = require('path');
const skillsDir = path.join(__dirname, '../skills');
const dryRun = false; // Set to false to actually modify files
function walk(dir, fileList = []) {
if (!fs.existsSync(dir)) return fileList;
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walk(filePath, fileList);
} else {
if (file === 'SKILL.md') {
fileList.push(filePath);
}
}
});
return fileList;
}
console.log(`Scanning ${skillsDir} for double headings...`);
if (dryRun) console.log('--- DRY RUN MODE: No changes will be made ---');
const skillFiles = walk(skillsDir);
let changesCount = 0;
skillFiles.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
// Regex to find two identical H1 headers separated by newlines/whitespace
// Captures:
// Group 1: Start of string or previous newline
// Group 2: The Heading line (e.g. "# Title")
// Group 3: The separation (newlines)
// Group 4: The repeated Heading line (backreference to \2)
const regex = /(^|\n)(# [^\r\n]+)(\r?\n\s*)+(\2)/g;
if (regex.test(content)) {
console.log(`[${dryRun ? 'DRY RUN' : 'FIX'}] Found double heading in ${path.relative(skillsDir, file)}`);
if (!dryRun) {
// Replace with just the first heading (plus preceding newline)
// We drop the intermediate newlines and the second heading.
const newContent = content.replace(regex, '$1$2');
fs.writeFileSync(file, newContent);
}
changesCount++;
}
});
console.log(`\n${dryRun ? 'Found' : 'Fixed'} double headings in ${changesCount} files.`);
+51
View File
@@ -0,0 +1,51 @@
const fs = require('fs');
const path = require('path');
const skillsDir = path.join(__dirname, '../skills');
const dryRun = false; // Set to false to actually modify files
function walk(dir, fileList = []) {
if (!fs.existsSync(dir)) return fileList;
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walk(filePath, fileList);
} else {
if (file === 'SKILL.md') {
fileList.push(filePath);
}
}
});
return fileList;
}
console.log(`Scanning ${skillsDir} for markdown links...`);
if (dryRun) console.log('--- DRY RUN MODE: No changes will be made ---');
const skillFiles = walk(skillsDir);
let changesCount = 0;
skillFiles.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
// Regex to find the specific link format: [](/api/posts/... "View as markdown")
// It seems to be on its own line usually, but we should target the exact string pattern.
// The pattern is roughly: \[\]\(/api/posts/[a-zA-Z0-9]+/markdown "View as markdown"\)
// We also want to handle potential surrounding whitespace/newlines if it's on a standalone line to leave it clean.
const regex = /(\r?\n|^)\[\]\(\/api\/posts\/[a-zA-Z0-9]+\/markdown "View as markdown"\)(\r?\n|$)/g;
if (regex.test(content)) {
console.log(`[${dryRun ? 'DRY RUN' : 'FIX'}] Found markdown link in ${path.relative(skillsDir, file)}`);
if (!dryRun) {
const newContent = content.replace(regex, '$2'); // Keep one newline if it was surrounded by them, or just empty if at start/end
fs.writeFileSync(file, newContent);
}
changesCount++;
}
});
console.log(`\n${dryRun ? 'Found' : 'Fixed'} markdown links in ${changesCount} files.`);
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');
const skillsDir = path.join(__dirname, '../skills');
const prefixToRemove = 'rodydavis_com_posts_';
console.log(`Scanning ${skillsDir} for folders starting with "${prefixToRemove}"...`);
if (!fs.existsSync(skillsDir)) {
console.error(`Skills directory not found at ${skillsDir}`);
process.exit(1);
}
const files = fs.readdirSync(skillsDir);
let count = 0;
files.forEach(file => {
const oldPath = path.join(skillsDir, file);
const stat = fs.statSync(oldPath);
if (stat.isDirectory() && file.startsWith(prefixToRemove)) {
const newName = file.substring(prefixToRemove.length);
const newPath = path.join(skillsDir, newName);
console.log(`Renaming: ${file} -> ${newName}`);
try {
if (fs.existsSync(newPath)) {
console.warn(`WARNING: Destination ${newName} already exists. Skipping.`);
} else {
fs.renameSync(oldPath, newPath);
count++;
}
} catch (e) {
console.error(`ERROR renaming ${file}: ${e.message}`);
}
}
});
console.log(`\nRenamed ${count} directories.`);
@@ -0,0 +1,305 @@
---
name: how-to-run-astro-ssr-and-pocketbase-on-the-same-server
description: Learn how to host PocketBase and an Astro SSR application on the same server, using PocketBase's Go integration and a reverse proxy to delegate requests to Astro for dynamic web content.
metadata:
url: https://rodydavis.com/posts/astro-ssr-pocketbase-single-server
last_modified: Tue, 03 Feb 2026 20:04:35 GMT
---
# How to Run Astro SSR and PocketBase on the Same Server
In this article I will show you how to host [PocketBase](https://pocketbase.io/) and [Astro in SSR](https://docs.astro.build/en/guides/server-side-rendering/) mode on the same server. PocketBase does let you [render templates](https://pocketbase.io/docs/go-rendering-templates/) on the server but requires [Go Templates](https://pkg.go.dev/text/template) or pre-building with Static Site Generation (SSG).
> This could also be modified to use your web server or framework of choice ([Next.js](https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering), [SvelteKit](https://kit.svelte.dev/docs/page-options), [Qwik](https://qwik.builder.io/), [Angular](https://angular.io/guide/ssr)).
Before getting started make sure you have the latest version of [Node](https://nodejs.org/en/blog/announcements/v19-release-announce) and [Go](https://go.dev/doc/install) installed locally.
## Getting started 
In a terminal run the following to create the base project:
```
mkdir pocketbase_astro_ssr
cd pocketbase_astro_ssr
mkdir server
mkdir www
```
This will create the `server` and `www` folders in our project needed for both Astro and PocketBase.
## Setting up the server 
Create a file at `server/main.go` and update it with the following:
```
package main
import (
"log"
"net/http/httputil"
"net/url"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:4321",
})
e.Router.Any("/*", echo.WrapHandler(proxy))
e.Router.Any("/", echo.WrapHandler(proxy))
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
Here we are extending [PocketBase with Go](https://pocketbase.io/docs/go-overview/) and taking advantage of the [Echo router](https://echo.labstack.com/docs/routing) integration and using a [reverse proxy](https://www.nginx.com/resources/glossary/reverse-proxy-server/#:~:text=A%20reverse%20proxy%20server%20is,traffic%20between%20clients%20and%20servers.) to handle all requests not defined by PocketBase already and delegating them to Astro.
Next run the following in a terminal to install the dependencies:
```
go mod init server
go mod tidy
```
Now we can start the server and move on to the client:
```
go run main.go serve
```
You should see the following and note that this will run in debug mode so all the SQL statements will start to show:
```
2023/11/09 10:28:52 Server started at http://127.0.0.1:8090
├─ REST API: http://127.0.0.1:8090/api/
└─ Admin UI: http://127.0.0.1:8090/_/
```
### Collections 
Open up the Admin UI url and after creating a new admin user, create a new collection `items` and add the following metadata:
Column Name
Column Type
Column Settings
title
Plain Text
 
![](https://rodydavis.com/_/../api/files/pbc_2708086759/0n5a8cv931nc4d8/astro_ssr_1_l8se0qq5gx.png?thumb=)
Then update the API Rules to allow read access for list and view.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/w5769j576712b2q/astro_ssr_2_nbohwwy3lp.png?thumb=)
> This is just for example purposes and on a production app you will rely on auth for ACLs
Create 3 new records with placeholder data.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/04aq788kp9vrr4r/astro_ssr_3_s8uxkyvvla.png?thumb=)
## Creating the client 
Now we can create the client that will be used to connect to PocketBase and serve all of the web traffic.
Navigate to the `www` directory and run the following in a terminal:
```
npm create astro@latest
```
Follow the prompts and enter the following:
Question
Answer
Where should we create your new project?
.
How would you like to start your new project?
Empty
Install dependencies?
Yes
Do you plan to write TypeScript?
Yes
How strict should TypeScript be?
Strict
Initialize a new git repository?
No
You can of course customize this as you need, but next we can install the dependencies needed by running the following in a terminal:
```
npm i -D @astrojs/node
npm i pocketbase
```
Next update `www/astro.config.mjs` and update it with the following:
```
import { defineConfig } from "astro/config";
import nodejs from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
adapter: nodejs({
mode: "standalone",
}),
output: "server",
});
```
This will use Server Side Rendering (SSR) instead of Static Site Generation (SSG) when we run the web server.
### UI 
#### Layouts 
We can start by creating a shared layout for all the routes. Create a file at `www/src/layouts/Root.astro` and update it with the following:
```
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
```
#### Routes 
Now we can update the index `/` route by updating the following file `www/src/pages/index.astro`:
```
---
import Root from "../layouts/Root.astro";
import PocketBase from "pocketbase";
const pb = new PocketBase("http://127.0.0.1:8090");
const items = pb.collection("items");
const records = await items.getFullList();
---
<Root title="Items">
<h1>Items</h1>
<ul>
{
records.map((record) => (
<li>
<a href={`/items/${record.id}`}>{record.title}</a>
</li>
))
}
</ul>
</Root>
```
This will call the `items` collection on the server and render it with 0 JS on the client.
Next create a file `www/src/pages/[...slug].astro` and update it with the following:
```
---
import Root from "../layouts/Root.astro";
import PocketBase from "pocketbase";
const slug = Astro.params.slug!;
const id = slug.split("/").pop()!;
const pb = new PocketBase("http://127.0.0.1:8090");
const items = pb.collection("items");
const records = await items.getList(1, 1, {
filter: `id = '${id}'`,
});
if (records.items.length === 0) {
return new Response("Not found", { status: 404 });
}
const {title} = records.items[0];
---
<Root {title}>
<a href="/">Back</a>
<h1>{title}</h1>
</Root>
```
This is almost like before but now we can return a proper `404` response if not found for an item.
#### Running 
Now we can run the web server with the following command:
```
npm run dev
```
You should see the following:
```
> dev
> astro dev
🚀 astro v3.4.4 started in 67ms
┃ Local http://localhost:4321/
┃ Network use --host to expose
```
Then if we open up the PocketBase url `http://127.0.0.1:8090` and you should see the following for the index route and detail routes:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/t8xy11r4nz16w56/astro_ssr_4_2jgteusxtt.png?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/mk5evib25fxj5wd/astro_ssr_5_3u7bekhf36.png?thumb=)
## Conclusion 
Now you can build a new binary for both the server and client and deploy them both on the same server instance. 🎉
You can find the final code [here](https://github.com/rodydavis/pocketbase_astro_ssr).
+339
View File
@@ -0,0 +1,339 @@
---
name: async-preact-signals
description: Explore how to effectively manage asynchronous data with Preact Signals by creating a custom `asyncSignal` that handles loading, error, and data states without breaking the synchronous nature of signals.
metadata:
url: https://rodydavis.com/posts/async-preact-signal
last_modified: Tue, 03 Feb 2026 20:04:16 GMT
---
# Async Preact Signals
When working with [signals](https://github.com/preactjs/signals) in Javascript, it is very common to work with async data from [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise).
## Async vs Sync
But unlike other state management libraries, signals do not have an _asynchronous_ state graph and all values must be computed _synchronously_.
When people first start using signals they want to simply add **async** to the function callback but this breaks how they work under the hood and leads to **undefined** behavior. ☹️
Async functions are a leaky abstraction and force you to handle them all the way up the graph. Async is also not always better and can have a [performance impact](https://madelinemiller.dev/blog/javascript-promise-overhead/). 😬
## Working with Promises
We can still do so much with sync operations, and make it eaiser to work with common async patterns.
For example when you make a **http** request using [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch), you want to return the data in the **Promise** and update some UI.
```
const el = document.querySelector('#output');
let postId = '123';
fetch(`/posts/${postId}`).then(res => res.json()).then(post => {
el.innerText = post.title;
})
```
Now when we add signals we can rerun the fetch everytime the post id changes.
```
import { effect, signal } from "@preact/signals-core";
const el = document.querySelector('#output');
const postId = signal( '123');
effect(() => {
fetch(`/posts/${postId.value}`).then(res => res.json()).then(post => {
el.innerText = post.title;
});
});
```
This is better, but now we need to handle stopping the previous request if the post id changes before the previous fetch completes.
```
import { effect, signal } from "@preact/signals-core";
const el = document.querySelector('#output');
const postId = signal( '123');
let controller;
effect(() => {
if (controller) {
controller.abort();
}
controller = new AbortController();
const signal = controller.signal;
try {
fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
el.innerText = post.title;
});
} catch (err) {
// todo: show error message
}
});
```
But this still skips a lot of things we normally want to show like loading states and error states.
```
import { effect, signal, batch } from "@preact/signals-core";
const el = document.querySelector('#output');
const postId = signal( '123');
const postData = signal({});
const errorMessage = signal('');
const loading = signal(false);
let controller;
effect(() => {
if (controller) {
controller.abort();
}
controller = new AbortController();
const signal = controller.signal;
batch(() => {
loading.value = true;
errorMessage.value = '';
postData.value = {};
});
try {
fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
batch(() => {
postData.value = post;
loading.value = false;
});
});
} catch (err) {
errorMessage.value = err.message;
}
});
effect(() => {
if (loading.value) {
el.innerText = 'Loading...';
} else if (errorMessage.value) {
el.innerText = `Error: ${errorMessage.value}`;
} else {
el.innerText = postData.value.title;
}
});
```
Now we can show the proper states, but this is only for one request...
We could wrap this up in a class to reuse or create a new type of signal that can work with asynchronous data.
## AsyncState
We want to have a base class that we can make our loading states easily extend from:
```
export class AsyncState<T> {
constructor() {}
get value(): T | null {
return null;
}
get requireValue(): T {
throw new Error("Value not set");
}
get error(): any {
return null;
}
get isLoading(): boolean {
return false;
}
get hasValue(): boolean {
return false;
}
get hasError(): boolean {
return false;
}
map<R>(builders: {
onLoading: () => R;
onError: (error: any) => R;
onData: (data: T) => R;
}): R {
if (this.hasError) {
return builders.onError(this.error);
}
if (this.hasValue) {
return builders.onData(this.requireValue);
}
return builders.onLoading();
}
}
```
> [This class](https://dartsignals.dev/async/state/) actually comes from a [Dart port of preact signals](https://github.com/rodydavis/signals.dart) I created.
This allows us to easily check if there is an actual value, error or if it is loading. It also provides an easy builder method to map the state to another value. 🤩
### AsyncData
The loading state extends **AsyncState** and passes the value in the constructor to the overriden methods.
```
export class AsyncData<T> extends AsyncState<T> {
private _value: T;
constructor(value: T) {
super();
this._value = value;
}
get requireValue(): T {
return this._value;
}
get hasValue(): boolean {
return true;
}
toString() {
return `AsyncData{${this._value}}`;
}
}
```
### AsyncLoading
For the loading state we override the methods like **AsyncData**.
```
export class AsyncLoading<T> extends AsyncState<T> {
get value(): T | null {
return null;
}
get isLoading(): boolean {
return true;
}
toString() {
return `AsyncLoading{}`;
}
}
```
### AsyncError
For the error state we can pass an object of any type to return the error as value instead of throwing an exception (like Go).
```
export class AsyncError<T> extends AsyncState<T> {
private _error: any;
constructor(error: any) {
super();
this._error = error;
}
get error(): any {
return this._error;
}
get hasError(): boolean {
return true;
}
toString() {
return `AsyncError{${this._error}}`;
}
}
```
## asyncSignal
Now we the state classes created, we can create a function to create an asynchronous signal with all the logic we talked about earlier.
We need to show the sync value at any time and have a way to abort previous requests.
```
export function asyncSignal<T>(
cb: () => Promise<T>
): ReadonlySignal<AsyncState<T>> {
const loading = new AsyncLoading<T>();
const reset = Symbol("reset");
const s = signal<AsyncState<T>>(loading);
const c = computed<Promise<T>>(cb);
let controller: AbortController | null;
let abortSignal: AbortSignal | null;
function execute(cb: Promise<T>, cancel: AbortSignal) {
(async () => {
s.value = loading;
try {
const result = await new Promise<T>(async (resolve, reject) => {
if (cancel.aborted) {
reject(cancel.reason);
}
cancel.addEventListener("abort", () => {
reject(cancel.reason);
});
try {
const result = await cb;
if (cancel.aborted) {
reject(cancel.reason);
return;
}
resolve(result);
} catch (error) {
reject(error);
}
});
s.value = new AsyncData<T>(result);
} catch (error) {
if (error === reset) {
s.value = loading;
} else {
s.value = new AsyncError<T>(error);
}
}
})();
}
effect(() => {
if (controller != null) {
controller.abort(reset);
}
controller = new AbortController();
abortSignal = controller.signal;
execute(c.value, abortSignal);
});
return s;
}
```
This makes it very easy to create multiple asynchronous signals and also use it anywhere else you have signals in the application like effects and computeds.
```
const el = document.querySelector('#output');
const postId = signal('123');
const result = asyncSignal(() => fetch(`/posts/${postId.value}`).then(res => res.json()));
effect(() => {
el.innerText = result.value.map({
onLoading: () => 'Loading...',
onError: (err) => `Error: ${err}`,
onData: (post) => post.title,
});
});
postId.value = '456';
```
## Conclusion
I have started a Preact Signals GitHub discussion [here](https://github.com/preactjs/signals/discussions/648) and you can find a gist with the [final source code here](https://gist.github.com/rodydavis/3b5266da2cc07f6574d425f5ce6e1e31). 🎉
This has made working with asynchronous data a lot eaiser to work with and would love to hear your thoughts about ways to improve it 👀
Also if you are curious about how Angular does asynchronous signals you can check out the [resource signal](https://angular.dev/guide/signals/resource) and the [computedFrom/Async signal](https://justangular.com/blog/building-computed-async-for-signals-in-angular).
+172
View File
@@ -0,0 +1,172 @@
---
name: automate-flutter-app-releases
description: Automate your Flutter app releases to beta or production with this handy shell script that handles version bumping, formatting, cleaning, rebuilding, and deployment via Fastlane.
metadata:
url: https://rodydavis.com/posts/automate-flutter-apps
last_modified: Tue, 03 Feb 2026 20:04:27 GMT
---
# Automate Flutter App Releases
> **TLDR** You can find the script [here](https://gist.github.com/rodydavis/774b36e32d7efa882cca8dd16da6e74c).
```
#!/bin/bash
echo "App Release Automator by @rodydavis"
action="$1"
red=`tput setaf 1`
green=`tput setaf 2`
reset=`tput sgr0`
if [ ${action} = "build" ]; then
echo "${green}Generating built files.. ${reset}"
flutter packages pub run build_runner clean
flutter packages pub run build_runner build --delete-conflicting-outputs
pub global activate pubspec_version
git commit -a -m "Build $(pubver bump patch)"
echo "${green}Building Project...${reset}"
find . -name "*-e" -type f -delete
flutter format .
flutter clean
echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}"
echo "${green}Building APK...${reset}"
flutter build apk
echo "${green}Builing IPA..${reset}"
cd ./ios && pod install && pod repo update && cd ..
flutter build ios
git commit -a -m "Project Rebuilt"
elif [ ${action} = "beta" ]; then
echo "${green}Generating built files..${reset}"
flutter packages pub run build_runner clean
flutter packages pub run build_runner build --delete-conflicting-outputs
pub global activate pubspec_version
git commit -a -m "Beta $(pubver bump patch)"
echo "${green}Building Project...${reset}"
find . -name "*-e" -type f -delete
flutter format .
flutter clean
echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}"
echo "${green}Building APK...${reset}"
flutter build apk
echo "${green}Sending Android to Beta...${reset}"
cd ./android && fastlane beta && cd ..
echo "${green}Builing IPA..${reset}"
flutter build ios
echo "${green}Sending iOS to Beta..${reset}"
cd ./ios && fastlane beta && cd ..
git commit -a -m "Sent to Beta"
elif [ ${action} = "release" ]; then
echo "${green}Generating built files..${reset}"
flutter packages pub run build_runner clean
flutter packages pub run build_runner build --delete-conflicting-outputs
pub global activate pubspec_version
git commit -a -m "Production $(pubver bump minor)"
echo "${green}Building Project...${reset}"
find . -name "*-e" -type f -delete
flutter format .
flutter clean
echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}"
echo "${green}Building APK...${reset}"
flutter build apk
echo "${green}Sending Android to Production...${reset}"
cd ./android && fastlane release && cd ..
echo "${green}Builing IPA..${reset}"
flutter build ios
echo "${green}Sending iOS to Production...${reset}"
cd ./ios && fastlane release && cd ..
git commit -a -m "Sent to Production"
fi
echo "${green}Successfully completed${reset}"
```
Needed:
* Fastlane setup in each directory
* build\_runner as a dependency
* Git Project in VCS
Steps to Run:
1. Download this file and put it at the root level of your flutter project
2. Open the terminal and navigate to your project location
3. Enter this command: `chmod +x release.sh`
Usage
* For beta: `./release.sh beta`
* For production: `./release.sh release`
It will do the following:
* Bump the version numbers if you are using the version in the `pubspec.yaml`
* Release the apps with Fastlane
* Format all Dart Files
* Clean Project
* Rebuild classes
* Add commit message
Make your life easier and automate your builds to beta and production!
## What you need 
* [Fastlane](https://fastlane.tools/) setup in each directory
* [build\_runner](https://pub.dartlang.org/packages/build_runner) as a dependency
* Git Project in VCS
## Initial Setup 
* Download [this file](https://gist.github.com/rodydavis/774b36e32d7efa882cca8dd16da6e74c)
* Put it at the root level of your flutter project
* Open the terminal and navigate to your project location
* Enter this command: chmod +x release.sh
## Usage 
Now you can call this script!
* For beta: `./release.sh beta`
* For production: `./release.sh release`
## Overview 
* Bump the version numbers if you are using the version in the pubspec.yaml
* Release the apps with Fastlane
* Format all Dart Files
* Clean Project
* Rebuild classes
* Add commit messages
* Updates Cocoa Pods
+533
View File
@@ -0,0 +1,533 @@
---
name: dynamic-themes-with-codemirror
description: Learn how to create a Lit web component with CodeMirror, dynamically themed using Material Design's color utilities, for a customizable code editing experience.
metadata:
url: https://rodydavis.com/posts/codemirror-dynamic-theme
last_modified: Tue, 03 Feb 2026 20:04:24 GMT
---
# Dynamic Themes with CodeMirror
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a a code window that uses [CodeMirror](https://codemirror.net/) and apply a dynamic theme with [Material Design](https://material.io/).
> **TLDR** The final source [here](https://github.com/rodydavis/codemirror-dynamic-theme) and an online [demo](https://rodydavis.github.io/codemirror-dynamic-theme/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `codemirror-dynamic-theme` and now open the project in vscode and install the dependencies:
```
cd codemirror-dynamic-theme
npm i lit codemirror @material/material-color-utilities
npm i -D @types/node @types/codemirror
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/codemirror-dynamic-theme/",
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeMirror Dynamic Theme</title>
<script type="module" src="/src/code-window.ts"></script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<code-window> </code-window>
</body>
</html>
```
## Web Component 
Before we update our component we need to rename `my-element.ts` to `code-window.ts`
Open up `code-window.ts` and update it with the following:
```
import { html, css, LitElement, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
applyTheme,
argbFromHex,
hexFromArgb,
themeFromSourceColor,
} from "@material/material-color-utilities";
import CodeMirror from "codemirror";
import codemirrorStyles from "codemirror/lib/codemirror.css";
@customElement("code-window")
export class CodeWindow extends LitElement {
static styles = css`
${unsafeCSS(codemirrorStyles)}
main {
width: 100vw;
height: 100vh;
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
--header-height: 48px;
--input-size: 32px;
}
.toolbar {
height: var(--header-height);
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
display: flex;
align-items: center;
}
.actions > * {
margin-left: 4px;
margin-right: 4px;
}
.toolbar .title {
font-family: sans-serif;
font-size: 18px;
padding-left: 4px;
}
.toolbar .actions {
display: flex;
align-items: center;
}
.toolbar a {
padding: 0;
margin: 0;
padding-left: 8px;
padding-right: 8px;
display: flex;
align-items: center;
cursor: pointer;
}
input[type="color"] {
width: calc(var(--input-size) * 2);
height: var(--input-size);
outline: none;
border: none;
border-radius: 50%;
background-color: var(--md-sys-color-primary-container);
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: var(--input-size);
border: var(--md-sys-color-outline) solid 1px;
}
button {
border: none;
border-radius: 4px;
padding: 8px;
}
.tertiary {
background-color: var(--md-sys-color-tertiary);
color: var(--md-sys-color-on-tertiary);
}
.secondary {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
.spacer {
flex: 1;
}
.editor {
height: calc(100% - var(--header-height));
width: 100%;
}
`;
@property() value = [
`import {html, css, LitElement} from 'lit';`,
`import {customElement, property} from 'lit/decorators.js';`,
``,
`@customElement('simple-greeting')`,
`export class SimpleGreeting extends LitElement {`,
` static styles = css\`p { color: blue }\`;`,
``,
` @property()`,
` name = 'Somebody';`,
``,
` render() {`,
` return html\`<p>Hello, \${this.name}!</p>\`;`,
` }`,
`}`,
].join("\n");
@property() color = "#6750A4";
@property({ type: Boolean }) dark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
render() {
return html`<main>
<header class="toolbar">
<div class="title">${document.title}</div>
<div class="spacer"></div>
<div class="actions">
<button class="secondary" @click=${this.toggleDark.bind(this)}>
${this.dark ? "Light" : "Dark"}
</button>
<button class="tertiary" @click=${this.randomColor.bind(this)}>
Random
</button>
<input
type="color"
.value=${this.color}
@input=${this.onColor.bind(this)}
/>
</div>
</header>
<div class="editor"></div>
</main>`;
}
firstUpdated() {
const root = this.shadowRoot!.querySelector(".editor") as HTMLElement;
const editor = CodeMirror(root, {
value: this.value,
mode: "javascript",
lineNumbers: true,
lineWrapping: true,
indentUnit: 4,
tabSize: 4,
indentWithTabs: true,
autofocus: true,
});
console.debug(editor);
editor.setSize("100%", `100%`);
this.updateTheme();
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
this.dark = e.matches;
this.updateTheme();
});
}
private updateTheme() {
// TODO: Generate Theme
}
private setColor(val: string) {
this.color = val;
this.updateTheme();
}
private onColor(e: Event) {
const target = e.target as HTMLInputElement;
this.setColor(target.value);
}
private randomColor() {
const letters = "0123456789ABCDEF";
let color = "#";
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
this.setColor(color);
}
private toggleDark() {
this.dark = !this.dark;
this.updateTheme();
}
}
declare global {
interface HTMLElementTagNameMap {
"code-window": CodeWindow;
}
}
```
Here we are setting up some of the editor basics to load in the styles needed for the basic layout.
There are also few methods that handle updating of properties on the element such as `toggleDark` and `setColor`. When you run the application you should see the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/zpv7mxra15y855n/cm_1_vrf88lj26h.webp?thumb=)
It doesn't look great yet, but now we can add a CodeMirror theme to import. Create a file `src/theme.ts` and update it with the following:
```
import { css } from "lit";
export const codeMirrorTheme = css`
.CodeMirror {
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
}
.CodeMirror-gutters {
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
border: none;
}
.CodeMirror-guttermarker,
.CodeMirror-guttermarker-subtle,
.CodeMirror-linenumber {
color: var(--md-sys-color-on-background);
}
.CodeMirror-cursor {
border-left: 1px solid var(--md-sys-color-primary);
}
.cm-fat-cursor .CodeMirror-cursor {
background-color: var(--md-sys-color-background);
}
.cm-animate-fat-cursor {
background-color: var(--md-sys-color-background);
}
div.CodeMirror-selected {
background: var(--md-sys-color-surface-variant);
}
.CodeMirror-focused div.CodeMirror-selected {
background: var(--md-sys-color-surface-variant);
}
.CodeMirror-line::selection,
.CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection {
background: var(--md-sys-color-surface-variant);
}
.CodeMirror-line::-moz-selection,
.CodeMirror-line > span::-moz-selection,
.CodeMirror-line > span > span::-moz-selection {
background: var(--md-sys-color-surface-variant);
}
.CodeMirror-activeline-background {
background: var(--md-sys-color-surface);
}
.cm-keyword {
color: var(--md-custom-color-keyword) !important;
}
.cm-operator {
color: var(--md-custom-color-operator) !important;
}
.cm-variable-2 {
color: var(--md-custom-color-variable-2) !important;
}
.cm-variable-3,
.cm-type {
color: var(--md-custom-color-variable-3) !important;
}
.cm-builtin {
color: var(--md-custom-color-builtin) !important;
}
.cm-atom {
color: var(--md-custom-color-atom) !important;
}
.cm-number {
color: var(--md-custom-color-number) !important;
}
.cm-def {
color: var(--md-custom-color-def) !important;
}
.cm-string {
color: var(--md-custom-color-string) !important;
}
.cm-string-2 {
color: var(--md-custom-color-string-2) !important;
}
.cm-comment {
color: var(--md-custom-color-comment) !important;
}
.cm-variable {
color: var(--md-custom-color-variable) !important;
}
.cm-tag {
color: var(--md-custom-color-tag) !important;
}
.cm-meta {
color: var(--md-custom-color-meta) !important;
}
.cm-attribute {
color: var(--md-custom-color-attribute) !important;
}
.cm-property {
color: var(--md-custom-color-property) !important;
}
.cm-qualifier {
color: var(--md-custom-color-qualifier) !important;
}
.cm-variable-3,
.cm-type {
color: var(--md-custom-color-variable-3) !important;
}
.cm-error {
color: var(--md-sys-color-on-error);
background-color: var(--md-sys-color-error);
}
.CodeMirror-matchingbracket {
text-decoration: underline;
color: var(--md-sys-color-on-surface);
}
`;
```
Here we are defining all the styles as [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) so we can easily update them.
Now import the theme and apply the styles in the component:
```
import { codeMirrorTheme } from "./theme";
@customElement("code-window")
export class CodeWindow extends LitElement {
static styles = css`
${unsafeCSS(codemirrorStyles)}
${codeMirrorTheme}
...
```
Now we need to implement the `updateTheme` method in our element:
```
updateTheme() {
const source = this.color;
const dark = this.dark;
const target = this.shadowRoot!.querySelector("main") as HTMLElement;
const properties = [
`--md-custom-color-keyword: #c75779;`,
`--md-custom-color-operator: #008800;`,
`--md-custom-color-variable: #90ccff;`,
`--md-custom-color-variable-2: #dd7700;`,
`--md-custom-color-variable-3: #3333bb;`,
`--md-custom-color-variable-3: #decb6b;`,
`--md-custom-color-builtin: #003388;`,
`--md-custom-color-atom: #bb4646;`,
`--md-custom-color-number: #b4c5ff;`,
`--md-custom-color-def: #82aaff;`,
`--md-custom-color-string: #ffb4a9;`,
`--md-custom-color-string-2: #ffb4a9;`,
`--md-custom-color-comment: #888888;`,
`--md-custom-color-tag: #000080;`,
`--md-custom-color-meta: #a9c7ff;`,
`--md-custom-color-attribute: #008080;`,
`--md-custom-color-property: #336699;`,
`--md-custom-color-qualifier: #690;`,
];
const customColors = properties.map((property) => {
const [key, value] = property.split(":");
const name = key.trim().replace(/^--md-custom-color-/, "");
const color = argbFromHex(value.trim().replace(";", ""));
return {
name,
value: color,
blend: true,
};
});
const theme = themeFromSourceColor(argbFromHex(source), customColors);
applyTheme(theme, { target, dark });
for (const custom of theme.customColors) {
const name = custom.color.name;
const section = dark ? custom.dark : custom.light;
target.style.setProperty(
`--md-custom-color-${name}`,
hexFromArgb(section.color)
);
}
}
```
Here we are using the [Material Color Utilities](https://github.com/material-foundation/material-color-utilities) package and generating a theme from a source color. We can take advantage of Custom Colors to blend the values to the theme.
After the theme is generated we can apply them to the root element and have the custom properties update the editor.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4dms4387o49vrdq/cm_2_i38ka2mknd.webp?thumb=)
Changing the source color can update the theme:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/5j8242y59z068c2/cm_3_h3mcbh0c3s.webp?thumb=)
Changing the brightness can set the colors as well:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/154y3970c459zp1/cm_4_u36e81hjiw.webp?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/ydr425389axp4os/cm_5_2xo33gqwdh.webp?thumb=)
## Conclusion 
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
The source for this example can be found [here](https://github.com/rodydavis/codemirror-dynamic-theme).
If you want to check out dynamic themes for VSCode I created an extension [here](https://github.com/rodydavis/vscode-dynamic-theme).
+234
View File
@@ -0,0 +1,234 @@
---
name: how-to-create-html-web-components-with-dart
description: Discover the power of Web Components and how to build them with both JavaScript and Dart for reusable, framework-agnostic UI elements.
metadata:
url: https://rodydavis.com/posts/dart-html-web-components
last_modified: Tue, 03 Feb 2026 20:04:14 GMT
---
# How to create HTML Web Components with Dart
I am a long time [Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) fan (since helping DevRel [lit.dev](https://lit.dev/) and [Material Web Components](https://github.com/material-components/material-web)) and have also loved writing [Dart](https://dart.dev/) in both [Flutter](https://flutter.dev/) applications and full stack apps.
Despite being [used at so many companies](https://arewebcomponentsathingyet.com/), Web Components have faced a lot of pushback from JavaScript developers that use frameworks to target the web. ☹️
What you may not realize is that the web has a way to create new HTML tags that can be used in **ANY** JS framework or place that returns HTML and you can progressively enchance applications. 🤩
Since they are custom HTML tags, if you swap implementations, you do not need to update where it is used and you can ship components a separate files [instead of one big bundle](https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755).
Dart [used to support Web Components](https://github.com/dart-archive/web-components) at one point and was even used by a precursor to Lit in a product call [Polymer](https://github.com/polymer-dart).
## Creating a Web Component in Javascript
To create a web component in Javascript you just need to extend HTML element and provide callbacks for when the component is mounted.
```
class HelloWorld extends HTMLElement {
static observedAttributes = ["name"];
constructor() {
super();
}
update() {
this.innerHTML = `Hello: ${this.getAttribute('name')}`;
}
connectedCallback() {
console.log("Custom element added to page.");
this. update();
}
disconnectedCallback() {
console.log("Custom element removed from page.");
}
adoptedCallback() {
console.log("Custom element moved to new page.");
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} has changed.`);
if (name === 'name') {
this. update();
}
}
}
customElements.define("hello-world", HelloWorld);
```
We can then use it in HTML like the following:
```
<html>
<body>
<hello-world name="Rody"></hello-world>
<script src="./index.js"></script>
</body>
</html>
```
This works really well, and we don't even need a build step to create them!
## Creating Web Components with Dart
To create them on the Dart side we need to use the [js\_interop package](https://dart.dev/interop/js-interop/usage) and the new [web package](https://pub.dev/packages/web).
We need to create a factory on the dart side that can create these JS classes without actually being able to create a class in the normal way (since JS and Dart classes are different).
There is a great API [`Reflect.construct()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/construct) which allows us to take a normal function and invoke it class a class constructor. JavaScript did not always support native classes and was only added with [ES6](https://www.w3schools.com/js/js_es6.asp).
By using this built in API, we can create the classes with just pure Dart:
```
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:web/web.dart';
class WebComponent<T extends HTMLElement> {
late T element;
final String extendsType = 'HTMLElement';
void connectedCallback() {}
void disconnectedCallback() {}
void adoptedCallback() {}
void attributeChangedCallback(
String name,
String? oldValue,
String? newValue,
) {}
Iterable<String> get observedAttributes => [];
bool get formAssociated => false;
ElementInternals? get internals => element['_internals'] as ElementInternals?;
set internals(ElementInternals? value) {
element['_internals'] = value;
}
R getRoot<R extends JSObject>() {
final hasShadow = element.shadowRoot != null;
return (hasShadow ? element.shadowRoot! : element) as R;
}
static void define(String tag, WebComponent Function() create) {
final obj = _factory(create);
window.customElements.define(tag, obj);
}
}
@JS('Reflect.construct')
external JSAny _reflectConstruct(
JSObject target,
JSAny args,
JSFunction constructor,
);
final _instances = <HTMLElement, WebComponent>{};
JSFunction _factory(WebComponent Function() create) {
final base = create();
final elemProto = globalContext[base.extendsType] as JSObject;
late JSAny obj;
JSAny constructor() {
final args = <String>[].jsify()!;
final self = _reflectConstruct(elemProto, args, obj as JSFunction);
final el = self as HTMLElement;
_instances.putIfAbsent(el, () => create()..element = el);
return self;
}
obj = constructor.toJS;
obj = obj as JSObject;
final observedAttributes = base.observedAttributes;
final formAssociated = base.formAssociated;
obj['prototype'] = elemProto['prototype'];
obj['observedAttributes'] = observedAttributes.toList().jsify()!;
obj['formAssociated'] = formAssociated.jsify()!;
final prototype = obj['prototype'] as JSObject;
prototype['connectedCallback'] = (HTMLElement instance) {
_instances[instance]?.connectedCallback();
}.toJSCaptureThis;
prototype['disconnectedCallback'] = (HTMLElement instance) {
_instances[instance]?.disconnectedCallback();
_instances.remove(instance);
}.toJSCaptureThis;
prototype['adoptedCallback'] = (HTMLElement instance) {
_instances[instance]?.adoptedCallback();
}.toJSCaptureThis;
prototype['attributeChangedCallback'] = (
HTMLElement instance,
String name,
String? oldName,
String? newName,
) {
_instances[instance]?.attributeChangedCallback(name, oldName, newName);
}.toJSCaptureThis;
return obj as JSFunction;
}
```
This may seem like a lot to digest, but that is ok. It simply does some JS magic to upgrade functions to classes and provide the correct callbacks to create the web components.
> If you want a package that does this for you, [html\_web\_components](https://pub.dev/packages/html_web_components) is on pub.dev.
To create a Web Component like we did before, we can just extend the class and define the component.
```
import 'package:html_web_components/html_web_components.dart';
class HelloWorld extends WebComponent {
@override
List<String> observedAttributes = ['name'];
void update() {
element.innerText = "Hello: ${element.getAttribute('name')}!";
}
@override
void connectedCallback() {
super.connectedCallback();
update();
}
@override
void attributeChangedCallback(
String name,
String? oldValue,
String? newValue,
) {
super.attributeChangedCallback(name, oldValue, newValue);
if (observedAttributes.contains(name)) {
update();
}
}
}
void main() {
WebComponent.define('hello-world', HelloWorld.new);
}
```
This should look very similar (that is the goal) and makes it so easy to publish the compoents or build a full web application with it.
## Conclusion
Web Components allow you to upgrade your client side interactivity while having the freedom to use server rendering to create the template files or just use a SPA on the frontend. You can take these components and use them in **ANY** JS frameworks! 🤯
I would highly suggest that you try it out for yourself before you write off Web Components. This is especially true for Flutter developers wanting an alternative to Flutter web (and even use with [Jaspr](https://pub.dev/packages/jaspr)).
You can take advantage of Dart's great ecosystem of packages on [pub.dev](https://pub.dev/) and the ability to compile to WASM and JS. If you use a builder like [peanut](https://pub.dev/packages/peanut) it will even create the script that tries to load WASM and can fallback to JS for you 🔥
If you want to see the code, you can [find it on GitHub](https://github.com/rodydavis/dart-web-components). Reach out if you have any questions or want to show off something cool you built with them!
+122
View File
@@ -0,0 +1,122 @@
---
name: how-to-do-bitwise-operations-in-dart
description: Explore Dart's bitwise operations for both integers and booleans, including AND, OR (inclusive & exclusive), NAND, NOR, and XNOR, with practical code examples.
metadata:
url: https://rodydavis.com/posts/dart/bitwise
last_modified: Tue, 03 Feb 2026 20:04:33 GMT
---
# How to do Bitwise operations in Dart
In Dart it is possible to do [Bitwise Operations](https://en.wikipedia.org/wiki/Bitwise_operation#:~:text=In%20computer%20programming%2C%20a%20bitwise,directly%20supported%20by%20the%20processor.) with **int** and **bool** types.
## AND 
Checks if the left and right side are both true. [Learn more](https://www.ibm.com/docs/en/xl-c-and-cpp-aix/16.1?topic=expressions-bitwise-operator).
```
// int
print(0 & 1); // 0
print(1 & 0); // 0
print(1 & 1); // 1
print(0 & 0); // 0
// bool
print(false & true); // false
print(true & false); // false
print(true & true); // true
print(false & false); // false
```
## OR 
### Inclusive 
Checks if either the left or right side are true. [Learn more](https://www.ibm.com/docs/en/xl-c-and-cpp-aix/16.1?topic=be-logical-operator).
```
// int
print(0 | 1); // 1
print(1 | 0); // 1
print(1 | 1); // 1
print(0 | 0); // 0
// bool
print(false | true); // true
print(true | false); // true
print(true | true); // true
print(false | false); // false
```
### Exclusive 
Checks if both the left or right side are true but not both. [Learn more](https://www.ibm.com/docs/en/xl-c-and-cpp-aix/16.1?topic=expressions-bitwise-exclusive-operator).
```
// int
print(0 ^ 1); // 1
print(1 ^ 0); // 1
print(1 ^ 1); // 0
print(0 ^ 0); // 0
// bool
print(false ^ true); // true
print(true ^ false); // true
print(true ^ true); // false
print(false ^ false); // false
```
## NAND 
Negated AND operation.
```
// int
print(~(0 & 1) & 1); // 1
print(~(1 & 0) & 1); // 1
print(~(1 & 1) & 1); // 0
print(~(0 & 0) & 1); // 1
// bool
print(!(false & true)); // true
print(!(true & false)); // true
print(!(true & true)); // false
print(!(false & false)); // true
```
## NOR 
Negated inclusive OR operation.
```
// int
print(~(0 | 1) & 1); // 0
print(~(1 | 0) & 1); // 0
print(~(1 | 1) & 1); // 0
print(~(0 | 0) & 1); // 1
// bool
print(!(false | true)); // false
print(!(true | false)); // false
print(!(true | true)); // false
print(!(false | false)); // true
```
## XNOR 
Negated exclusive OR operation.
```
// int
print(~(0 ^ 1) & 1); // 0
print(~(1 ^ 0) & 1); // 0
print(~(1 ^ 1) & 1); // 1
print(~(0 ^ 0) & 1); // 1
// bool
print(!(false ^ true)); // false
print(!(true ^ false)); // false
print(!(true ^ true)); // true
print(!(false ^ false)); // true
```
+49
View File
@@ -0,0 +1,49 @@
---
name: various-ways-to-invoke-functions-in-dart
description: Discover the surprising flexibility of calling Dart functions, including mixed positional and named arguments, the `.call` operator, and dynamic invocation with `Function.apply`.
metadata:
url: https://rodydavis.com/posts/dart/function-invoking
last_modified: Tue, 03 Feb 2026 20:04:32 GMT
---
# Various Ways to Invoke Functions in Dart
There are multiple ways to call a [Function](https://dart.dev/language/functions) in Dart.
The examples below will assume the following function:
```
void myFunction(int a, int b, {int? c, int? d}) {
print((a, b, c, d));
}
```
But recently I learned that you can call a functions positional arguments in any order mixed with the named arguments. 🤯
```
myFunction(1, 2, c: 3, d: 4);
myFunction(1, c: 3, d: 4, 2);
myFunction(c: 3, d: 4, 1, 2);
myFunction(c: 3, 1, 2, d: 4);
```
In addition you can use the [`.call`](https://dart.dev/language/callable-objects) operator to invoke the function if you have a reference to it:
```
myFunction.call(1, 2, c: 3, d: 4);
```
You can also use [`Function.apply`](https://api.flutter.dev/flutter/dart-core/Function/apply.html) to dynamically invoke a function with a reference but it should be noted that it will effect js dart complication size and performance:
```
Function.apply(myFunction, [1, 2], {#c: 3, #d: 4});
```
All of these methods print the following:
```
(1, 2, 3, 4)
```
## Demo
@@ -0,0 +1,67 @@
---
name: how-to-print-multiple-objects-to-the-console-with-print-in-dart
description: Learn how to print multiple objects to the console in Dart using Records, offering a similar experience to JavaScript's `console.log()` functionality.
metadata:
url: https://rodydavis.com/posts/dart/print-multiple-objects
last_modified: Tue, 03 Feb 2026 20:04:33 GMT
---
# How to Print Multiple Objects to the Console with print() in Dart
If you are coming from JavaScript you may be used to printing multiple objects to the console with `console.log()`:
```
console.log('a', 1, 'b', 2); // a 1 b 2
```
In Dart we can only print `Object?` to the console with [`print()`](https://api.dart.dev/stable/3.3.1/dart-core/print.html):
```
print(1); // 1
print(null); // null
print({'a': 1, 'b': 2}); // {a: 1, b: 1}
```
But it is totally possible to print multiple objects too, we need to use [Records](https://dart.dev/language/records):
```
final number = 1;
final str = 'Hello World';
print((number, str));
print((DateTime.now(), str));
print((DateTime.now(), count: number, description: str));
print((DateTime.now(), StackTrace.current));
```
Print the following:
```
(1, Hello World)
(2024-03-06 15:48:26.514, Hello World)
(2024-03-06 15:48:26.514, count: 1, description: Hello World)
(2024-03-06 15:48:26.514, Error
at get current [as current] (https://storage.googleapis.com/nnbd_artifacts/3.3.0/dart_sdk.js:139991:30)
at Object.main$0 [as main] (<anonymous>:52:94)
at Object.main$ [as main] (<anonymous>:44:10)
at <anonymous>:89:26
at Object.execCb (https://dartpad.dev/require.js:5:16727)
at e.check (https://dartpad.dev/require.js:5:10499)
at e.<anonymous> (https://dartpad.dev/require.js:5:12915)
at https://dartpad.dev/require.js:5:1542
at https://dartpad.dev/require.js:5:13376
at each (https://dartpad.dev/require.js:5:1020)
at e.emit (https://dartpad.dev/require.js:5:13344)
at e.check (https://dartpad.dev/require.js:5:11058)
at e.enable (https://dartpad.dev/require.js:5:13242)
at e.init (https://dartpad.dev/require.js:5:9605)
at a (https://dartpad.dev/require.js:5:8305)
at Object.completeLoad (https://dartpad.dev/require.js:5:15962)
at HTMLScriptElement.onScriptLoad (https://dartpad.dev/require.js:5:16882))
```
## Demo
+70
View File
@@ -0,0 +1,70 @@
---
name: check-if-an-object-is-truthy-in-dart
description: Learn how to extend Dart's functionality to implement JavaScript-style "truthy" checks for easier conditional logic and value evaluations.
metadata:
url: https://rodydavis.com/posts/dart/truthy
last_modified: Tue, 03 Feb 2026 20:04:34 GMT
---
# Check if an Object is Truthy in Dart
If you are coming from language like JavaScript you may be used to checking if an object is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy).
```
if (true)
if ({})
if ([])
if (42)
if ("0")
if ("false")
if (new Date())
if (-42)
if (12n)
if (3.14)
if (-3.14)
if (Infinity)
if (-Infinity)
```
In Dart you need to explicitly check if an object is not null, true/false or determine if the value is true based on the type.
It is possible however to use Dart extensions to add the truthy capability.
```
extension on Object? {
bool get isTruthy => truthy(this);
}
bool truthy(Object? val) {
if (val == null) return false;
if (val is bool) return val;
if (val is num && val == 0) return false;
if (val is String && (val == 'false' || val == '')) return false;
if (val is Iterable && val.isEmpty) return false;
if (val is Map && val.isEmpty) return false;
return true;
}
```
This will now make it possible for any object to be evaluated as a truthy value in if statements or value assignments.
Prints the following:
```
(null, false)
(, false)
(false, false)
(true, true)
(0, false)
(1, true)
(false, false)
(true, true)
([], false)
([1, 2, 3], true)
({}, false)
({1, 2, 3}, true)
({a: 1, b: 2}, true)
```
## Demo
+136
View File
@@ -0,0 +1,136 @@
---
name: deep-linking-for-flutter-web
description: Learn how to implement proper URL navigation in your Flutter application, including deep linking to specific pages, handling protected routes, and creating custom transitions for a seamless user experience.
metadata:
url: https://rodydavis.com/posts/deep-linking-flutter-web
last_modified: Tue, 03 Feb 2026 20:04:25 GMT
---
# Deep Linking for Flutter Web
In this article I will show you how to have proper URL navigation for your application. Open links to specific pages, protected routes and custom transitions.
> **TLDR** The final source [here](https://github.com/rodydavis/flutter_deep_linking) and an online [demo](https://rodydavis.github.io/flutter_deep_linking/).
## Setup 
Create a new flutter project called “flutter\_deep\_linking”
Open that folder up in VSCode.
Update your “pubspec.yaml” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/r43ga1tjg6p8x6i/deep_1_qbot26kikl.webp?thumb=)
## Step 1 
Create a file at “lib/ui/home/screen.dart” and add the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7r1l1i535220rwy/deep_2_8o7bk1h6ep.webp?thumb=)
Update your “lib/main.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/h42392iez6y3m89/deep_3_u638b3ccuy.webp?thumb=)
Run your application and you should see the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/43sd7k55aq8yce8/deep_4_fk5zfnixom.webp?thumb=)
## Step 2 
Now we need to grab the url the user enters into the address bar.
Create a folder at this location “lib/plugins/navigator”
Create a file inside named: “web.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/430154j9r710g4v/deep_5_r37mgvwx5q.webp?thumb=)
Create a file inside named: “unsupported.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/dczy2vb19nv6oo8/deep_6_xogk4ly3ze.webp?thumb=)
Create a file inside named: “navigator.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/lv0141dn57c869h/deep_7_atfvh3z2tt.webp?thumb=)
Now go back to your “lib/main.dart” file and add the navigator:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/k75ny99q018o2y3/deep_8_4esmqtror6.webp?thumb=)
> Its important to import the navigator as shown as this will have the conditional import for web compiling.
If you run the app now nothing should change.
## Step 3 
Now lets add the proper routing.
Create a new file “lib/ui/router.dart” and add the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/0y1ro5lf7h279w9/deep_9_xae1ebw89x.webp?thumb=)
Also update “lib/main.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/p7821ykg5on9lla/deep_10_ijxwpy9f4h.webp?thumb=)
> Notice how we removed the “home” field for MaterialApp. This is because the router will handle everything. By default we will go home on “/”
## Step 4 
Now lets add multiple screens to put this to the test! Add the following folders and files.
Create a file “lib/ui/account/screen.dart” and add the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/c081tn3331n994h/deep_11_o9tev9myvc.webp?thumb=)
Create a file “lib/ui/settings/screen.dart” and add the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/q117wlgum9kr8gn/deep_12_8gbj5hxdmq.webp?thumb=)
Create a file “lib/ui/about/screen.dart” and add the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/az34574641hrj62/deep_13_aojf2nh3fj.webp?thumb=)
Add the following to “lib/ui/router.dart”:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/8sa4176bo1f1c66/deep_14_9p3q4k5nqt.webp?thumb=)
Now when you navigate to /about, /account and /settings you will go to the new pages!
![](https://rodydavis.com/_/../api/files/pbc_2708086759/o0w52xqyj19cg2w/deep_15_lahb8r2wyw.webp?thumb=)
## Step 5 
Now lets tie into the browser navigation buttons! Update “lib/ui/home/screen.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/eb89014ivx49f9c/deep_16_6ccpd3agpc.webp?thumb=)
Now when you run the application and click on the settings icon it will launch the new screen as expected. But if you click your browsers back button it will go back to the home screen!
![](https://rodydavis.com/_/../api/files/pbc_2708086759/192l3evo16e287e/deep_17_rovfeoa2t1.webp?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4zzn742z7lsg454/deep_18_75rxz9598q.webp?thumb=)
## Step 6 
These urls are great but what if you want to pass data such as an ID that is not known ahead of time? No worries!
Update “lib/ui/account/screen.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/93f2yzeb7qkc089/deep_19_qyhv82wxfe.webp?thumb=)
Lets update our “lib/ui/router.dart” with the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/ar68a9w9l596080/deep_20_i6mmwwv4bg.webp?thumb=)
Now when you run your application and navigate to “/account/40” you will see the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/318co8rwm04s894/deep_21_ytsnzw349i.webp?thumb=)
## Conclusion 
Dynamic routes work great for Flutter web, you just need to know what to tweak! This package uses a forked version of fluro for some fixes I added but once the PRs is merged you can just use the regular package. Let me know what you think below and if there is a better way I am not seeing!
Here is the final code: [https://github.com/rodydavis/flutter\_deep\_linking](https://github.com/rodydavis/flutter_deep_linking)
+89
View File
@@ -0,0 +1,89 @@
---
name: displaying-html-in-flutter
description: Easily display and interact with HTML content in your Flutter app using the `easy_web_view` package, which supports both web and mobile platforms.
metadata:
url: https://rodydavis.com/posts/display-html-in-flutter
last_modified: Tue, 03 Feb 2026 20:04:25 GMT
---
# Displaying HTML in Flutter
Sometimes you have content in HTML that needs to be displayed and interacted with in Flutter.
Online Demo:[https://rodydavis.github.io/easy\_web\_view/#/](https://rodydavis.github.io/easy_web_view/#/)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/2v4742s6b8wbnd1/html_1_yaak9yba8y.webp?thumb=)
For those impatient I created a package for you to get all the following functionally and more here: [https://pub.dev/packages/easy\_web\_view](https://pub.dev/packages/easy_web_view)
## Getting Started 
Create a new flutter project named whatever you want.
> If you plan on showing HTML content on iOS/Android you will need to add the following to your pubspec.yaml
```
dependencies:
webview_flutter: ^0.3.15+1
```
## Web 
Reference: [/lib/src/web.dart](https://github.com/rodydavis/easy_web_view/blob/master/lib/src/web.dart)
To show html on Flutter web we need to use an HTMLElementView. This is a platform view that allows us to display native content.
We first need to register the Element and add all the options we need. Here we are creating an iFrame element and setting the source based on if it is markdown, html or a url.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/lc658z8810cfbdx/html_2_uowumu74gx.webp?thumb=)
To display valid HTML you can set the src field to the following:
```
_src = "data:text/html;charset=utf-8," + Uri.encodeComponent("HTML_CONTENT_HERE");
```
> For the package you can also pass markdown to the src and it will convert it for you.
After you call the setup method it is now time to display your new platform view:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/i3k0c91499iuzcn/html_3_d2x4h481p0.webp?thumb=)
You need to use the same viewType string as you registered for “registerViewFactory” method earlier.
Finally you need to wrap it in a container or sized box with an explicit width and height!
## Mobile 
Reference: [https://github.com/rodydavis/easy\_web\_view/blob/master/lib/src/mobile.dart](https://github.com/rodydavis/easy_web_view/blob/master/lib/src/mobile.dart)
Mobile setup should be easier. Lets add a method for updating the url that we will pass to the web view.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/53dyq4ko469eq77/html_4_can4qsn59m.webp?thumb=)
Create the controller:
```
WebViewController _controller;
```
And when ever the src changes call this method:
```
_controller.loadUrl(_updateUrl(widget.src), headers: widget.headers);
```
Finally lets show the html in the widget tree:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/0n8wq5l5xw7gwa0/html_5_ue0rqxe1sr.webp?thumb=)
## Conclusion 
If you want to see a complete example and advanced use case view the source here: [https://github.com/rodydavis/easy\_web\_view](https://github.com/rodydavis/easy_web_view)
And if you just want to have it all done for you use this package: [https://pub.dev/packages/easy\_web\_view](https://pub.dev/packages/easy_web_view)
Feel free to make PRs if you have anything that could help make it better too (Or if you find bugs).
When you show HTML this way you will find that you can interact, select text and work with it just like you would it it were a regular web page. If you are using the package you can also just pass embedded content or html elements too without needing a full html valid file (YouTube video for example).
+144
View File
@@ -0,0 +1,144 @@
---
name: how-to-export-sqlite-tables-to-create-statements
description: Learn how to export your entire SQLite database schema, including tables and indexes, into runnable CREATE statements at runtime using Flutter and the `sqlite3` package.
metadata:
url: https://rodydavis.com/posts/export-sqlite-dart
last_modified: Tue, 03 Feb 2026 20:04:37 GMT
---
# How to Export SQLite Tables to CREATE Statements
In this article I will show you how to export all the tables and indexes in a [SQLite](https://www.sqlite.org/index.html) database to CREATE statements at runtime.
## Getting started 
Start by creating a new directory and [Flutter](https://flutter.dev/) project:
```
mkdir sqlite_introspect
cd sqlite_introspect
flutter create .
flutter pub add sqlite3 mustache_template
```
This will add the `sqlite3` package which uses FFI to call the native executable and mustache that we will use for templates later.
## Creating the database 
Creating the database can be done either in memory or based on a local file. For this example we will use in memory:
```
final Database db = sqlite3.openInMemory();
```
Don't forget to dispose of the database after use:
```
db.dispose();
```
## Defining the template 
Since we will be using [Mustache](https://mustache.github.io/) we can define the variables that we will pass to the template as JSON.
Create a `TableInfo` class that will store the fields and indexes:
```
class TableInfo {
final String name;
final List<Map<String, dynamic>> fields;
final List<Map<String, dynamic>> indexes;
TableInfo({
required this.name,
required this.fields,
required this.indexes,
});
Map<String, dynamic> toJson() {
return {
'name': name,
'fields': [
for (var i = 0; i < fields.length; i++)
{
'index': i,
'table': name,
'isLast': i == fields.length - 1,
...fields[i],
},
],
'indexes': [
for (var i = 0; i < indexes.length; i++)
{
'index': i,
'table': name,
'isLast': i == indexes.length - 1,
...indexes[i],
},
],
};
}
}
```
Now we can create the Mustache template used to build up the CREATE statements:
```
const template = '''
{{#tables}}
CREATE TABLE {{name}} (
{{#fields}}
{{name}} {{#type}} {{.}}{{/type}}{{#notnull}} NOT NULL{{/notnull}}{{#pk}} PRIMARY KEY{{/pk}}{{#dflt_value}} DEFAULT {{.}}{{/dflt_value}}{{^isLast}},{{/isLast}}
{{/fields}}
);
{{#indexes}}
CREATE {{#unique}} UNIQUE{{/unique}} {{name}}
ON {{table}}({{#values}} {{name}} {{/values}}{{^isLast}},{{/isLast}});
{{/indexes}}
{{/tables}}
''';
```
## Exporting the PRAGMA 
Now we can export the [PRAGMA](https://www.sqlite.org/pragma.html) for the database by exporting the list of tables, querying the column information and indexes about each one.
```
final tables = <TableInfo>[];
// Export table names
final tableNames = db
.select("SELECT name FROM sqlite_master WHERE type='table';")
.map((e) => e['name'] as String);
for (final t in tableNames) {
// Export column information
final info = db.select('PRAGMA table_info($t);');
final tbl = TableInfo(name: t, fields: [], indexes: []);
for (final c in info) {
tbl.fields.add(c);
}
// Export index names
final indexList = db.select('PRAGMA index_list($t);');
for (final index in indexList) {
final name = index['name'] as String;
// Export index information
final infos = db.select('PRAGMA index_info($name);');
final indexValue = {...index, 'values': infos};
tbl.indexes.add(indexValue);
}
tables.add(tbl);
}
```
## Rendering the template 
Now take the tables we just exported and pass them to the mustache template to render:
```
final tml = Template(template);
final args = {"tables": tables.map((e) => e.toJson()).toList()};
final str = tml.renderString(args);
print(str);
```
This will now print out all the tables and indexes as CREATE as valid SQL. 🎉
+205
View File
@@ -0,0 +1,205 @@
---
name: using-fastlane-in-flutter-and-ci
description: Automate Flutter app builds and deployments to both the App Store and Google Play using Fastlane with this step-by-step guide.
metadata:
url: https://rodydavis.com/posts/fastlane-and-flutter
last_modified: Tue, 03 Feb 2026 20:04:17 GMT
---
# Using Fastlane in Flutter and CI
Prerequisites:
* Understand what [Fastlane](https://fastlane.tools/) is and how it works
* Project builds correctly following these [docs](https://flutter.dev/docs/deployment/cd)
* Android app setup in [Google Play Console](https://developer.android.com/distribute/console)
* iOS app setup in [AppStore Connect](https://appstoreconnect.apple.com/)
* [Flutter is installed](https://flutter.dev/docs/get-started/install) and your project is created
### Steps
1. Open your Flutter project
2. Run: cd ios
3. Run: fastlane init and follow the prompts
4. Replace the Fastfile contents with this:
```
#!/bin/bash
echo "App Release Automator by @rodydavis"
action="$1"
red=`tput setaf 1`
green=`tput setaf 2`
reset=`tput sgr0`
if [ ${action} = "build" ]; then
echo "${green}Generating built files.. ${reset}"
flutter packages pub run build_runner clean
flutter packages pub run build_runner build --delete-conflicting-outputs
pub global activate pubspec_version
git commit -a -m "Build $(pubver bump patch)"
echo "${green}Building Project...${reset}"
find . -name "*-e" -type f -delete
flutter format .
flutter clean
echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}"
echo "${green}Building APK...${reset}"
flutter build apk
echo "${green}Builing IPA..${reset}"
cd ./ios && pod install && pod repo update && cd ..
flutter build ios
git commit -a -m "Project Rebuilt"
elif [ ${action} = "beta" ]; then
echo "${green}Generating built files..${reset}"
flutter packages pub run build_runner clean
flutter packages pub run build_runner build --delete-conflicting-outputs
pub global activate pubspec_version
git commit -a -m "Beta $(pubver bump patch)"
echo "${green}Building Project...${reset}"
find . -name "*-e" -type f -delete
flutter format .
flutter clean
echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}"
echo "${green}Building APK...${reset}"
flutter build apk
echo "${green}Sending Android to Beta...${reset}"
cd ./android && fastlane beta && cd ..
echo "${green}Builing IPA..${reset}"
flutter build ios
echo "${green}Sending iOS to Beta..${reset}"
cd ./ios && fastlane beta && cd ..
git commit -a -m "Sent to Beta"
elif [ ${action} = "release" ]; then
echo "${green}Generating built files..${reset}"
flutter packages pub run build_runner clean
flutter packages pub run build_runner build --delete-conflicting-outputs
pub global activate pubspec_version
git commit -a -m "Production $(pubver bump minor)"
echo "${green}Building Project...${reset}"
find . -name "*-e" -type f -delete
flutter format .
flutter clean
echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}"
echo "${green}Building APK...${reset}"
flutter build apk
echo "${green}Sending Android to Production...${reset}"
cd ./android && fastlane release && cd ..
echo "${green}Builing IPA..${reset}"
flutter build ios
echo "${green}Sending iOS to Production...${reset}"
cd ./ios && fastlane release && cd ..
git commit -a -m "Sent to Production"
fi
echo "${green}Successfully completed${reset}"
```
5. Run: cd .. && cd android
6. Run: fastlane init and follow the prompts
7. Replace the Fastfile contents with this:
```
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android)
platform :android do
desc "Prepare and archive app"
lane :prepare do |options|
#bundle_install
Dir.chdir "../.." do
sh("flutter", "packages", "get")
sh("flutter", "clean")
sh("flutter", "build", "appbundle", "--release")
end
end
desc "Push a new beta build to Google Play"
lane :beta do
prepare(release: false)
upload_to_play_store(
track: 'beta',
aab: "../build/app/outputs/bundle/release/app.aab"
)
add_git_tag(
grouping: "fastlane-builds",
prefix: "v",
build_number: android_get_version_code
)
push_to_git_remote
end
desc "Push a new release build to the Google Play"
lane :release do
prepare(release: true)
upload_to_play_store(
track: 'production',
aab: "../build/app/outputs/bundle/release/app.aab"
)
add_git_tag(
grouping: "release",
prefix: "v",
build_number: android_get_version_name
)
push_to_git_remote
end
end
```
8. Run: fastlane add\_plugin versioning\_android and enter your password if needed
9. Run: cd ..
Now you are ready to launch your app to beta!
For ios run: cd ios && fastlane beta
For android run: cd android && fastlane beta
Stay tuned for an article soon where we use these fastlane sub folders for automating the releases on [Github Actions](https://github.com/features/actions) CI
+459
View File
@@ -0,0 +1,459 @@
---
name: lit-and-figma
description: Learn how to create a Figma plugin using Lit web components, including project setup, component creation, WASM integration, and building the final plugin for use in Figma.
metadata:
url: https://rodydavis.com/posts/figma-and-lit
last_modified: Tue, 03 Feb 2026 20:04:20 GMT
---
# Lit and Figma
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a figma plugin.
> **TLDR** You can find the final source [here](https://github.com/rodydavis/figma_lit_example).
## Prerequisites 
* Vscode
* Figma Desktop
* Node
* Typescript
## Getting Started 
We can start off by creating a empty directory and naming it with `snake_case` whatever we want.
```
mkdir figma_lit_example
cd figma_lit_example
```
### Web Setup 
Now we are in the `figma_lit_example` directory and can setup Figma and Lit. Let's start with node.
```
npm init -y
```
This will setup the basics for a node project and install the packages we need. Now lets add some config files. Now open the `package.json` and replace it with the following:
```
{
"name": "figma_lit_example",
"version": "1.0.0",
"description": "Lit Figma Plugin",
"dependencies": {
"lit": "^2.0.0-rc.1"
},
"devDependencies": {
"@figma/plugin-typings": "^1.23.0",
"html-webpack-inline-source-plugin": "^1.0.0-beta.2",
"html-webpack-plugin": "^4.3.0",
"css-loader": "^5.2.4",
"ts-loader": "^8.0.0",
"typescript": "^4.2.4",
"url-loader": "^4.1.1",
"webpack": "^4.44.1",
"webpack-cli": "^4.6.0"
},
"scripts": {
"dev": "npx webpack --mode=development --watch",
"copy": "mkdir -p lit-plugin && cp ./manifest.json ./lit-plugin/manifest.json && cp ./dist/ui.html ./lit-plugin/ui.html && cp ./dist/code.js ./lit-plugin/code.js",
"build": "npx webpack --mode=production && npm run copy",
"zip": "npm run build && zip -r lit-plugin.zip lit-plugin"
},
"browserslist": [
"last 1 Chrome versions"
],
"keywords": [],
"author": "",
"license": "ISC"
}
```
This will add everything we need and add the scripts we need for development and production. Then run the following:
```
npm i
```
This will install everything we need to get started. Now we need to setup some config files.
```
touch tsconfig.json
touch webpack.config.ts
```
This will create 2 files. Now open up `tsconfig.json` and paste the following:
```
{
"compilerOptions": {
"target": "es2017",
"module": "esNext",
"moduleResolution": "node",
"lib": ["es2017", "dom", "dom.iterable"],
"typeRoots": ["./node_modules/@types", "./node_modules/@figma"],
"declaration": true,
"sourceMap": true,
"inlineSources": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": false,
"outDir": "./lib",
"baseUrl": "./packages",
"importHelpers": true,
"plugins": [
{
"name": "ts-lit-plugin",
"rules": {
"no-unknown-tag-name": "error",
"no-unclosed-tag": "error",
"no-unknown-property": "error",
"no-unintended-mixed-binding": "error",
"no-invalid-boolean-binding": "error",
"no-expressionless-property-binding": "error",
"no-noncallable-event-binding": "error",
"no-boolean-in-attribute-binding": "error",
"no-complex-attribute-binding": "error",
"no-nullable-attribute-binding": "error",
"no-incompatible-type-binding": "error",
"no-invalid-directive-binding": "error",
"no-incompatible-property-type": "error",
"no-unknown-property-converter": "error",
"no-invalid-attribute-name": "error",
"no-invalid-tag-name": "error",
"no-unknown-attribute": "off",
"no-unknown-event": "off",
"no-unknown-slot": "off",
"no-invalid-css": "off"
}
}
]
},
"include": ["src/**/*.ts"],
"references": []
}
```
This is a basic typescript config. Now open up `webpack.config.ts` and paste the following:
```
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = (env, argv) => ({
mode: argv.mode === "production" ? "production" : "development",
devtool: argv.mode === "production" ? false : "inline-source-map",
entry: {
ui: "./src/ui.ts",
code: "./src/code.ts",
app: "./src/my-app.ts",
},
module: {
rules: [
{ test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ },
{ test: /\.css$/, use: ["style-loader", { loader: "css-loader" }] },
{ test: /\.(png|jpg|gif|webp|svg)$/, loader: "url-loader" },
],
},
resolve: { extensions: [".ts", ".js"] },
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist"),
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "ui.html"),
filename: "ui.html",
inject: true,
inlineSource: ".(js|css)$",
chunks: ["ui"],
}),
new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin),
],
});
```
Now we need to create the ui for the plugin:
```
touch ui.html
```
Open up `/src/ui.html` and add the following:
```
<my-app></my-app>
```
Now we need a manifest file for the figma plugin:
```
touch manifest.json
```
Open `manifest.json` and add the following:
```
{
"name": "figma_lit_example",
"id": "973668777853442323",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html"
}
```
Now we need to create our web component:
```
mkdir src
cd src
touch my-app.ts
touch code.ts
touch ui.ts
cd ..
```
Open `/src/ui.ts` and paste the following:
```
import "./my-app";
```
Open `/src/my-app.ts` and paste the following:
```
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators.js";
@customElement("my-app")
export class MyApp extends LitElement {
@property() amount = "5";
@query("#count") countInput!: HTMLInputElement;
render() {
return html`
<div>
<h2>Rectangle Creator</h2>
<p>Count: <input id="count" value="${this.amount}" /></p>
<button id="create" @click=${this.create}>Create</button>
<button id="cancel" @click=${this.cancel}>Cancel</button>
</div>
`;
}
create() {
const count = parseInt(this.countInput.value, 10);
this.sendMessage("create-rectangles", { count });
}
cancel() {
this.sendMessage("cancel");
}
private sendMessage(type: string, content: Object = {}) {
const message = { pluginMessage: { type: type, ...content } };
parent.postMessage(message, "*");
}
}
```
Open `code.ts` and paste the following:
```
const options: ShowUIOptions = {
width: 250,
height: 200,
};
figma.showUI(__html__, options);
figma.ui.onmessage = msg => {
switch (msg.type) {
case 'create-rectangles':
const nodes: SceneNode[] = [];
for (let i = 0; i < msg.count; i++) {
const rect = figma.createRectangle();
rect.x = i * 150;
rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0.5, b: 0 } }];
figma.currentPage.appendChild(rect);
nodes.push(rect);
}
figma.currentPage.selection = nodes;
figma.viewport.scrollAndZoomIntoView(nodes);
break;
default:
break;
}
figma.closePlugin();
};
```
## Building the Plugin 
Now that we have all the code in place we can build the plugin and test it in Figma.
```
npm run build
```
#### Step 1 
Download and open the desktop version of Figma.
[https://www.figma.com/downloads/](https://www.figma.com/downloads/)
#### Step 2 
Open the menu and navigate to “Plugins > Manage plugins”
![](https://rodydavis.com/_/../api/files/pbc_2708086759/g21yec9885zhf71/f_1_kfp7k3aclm.webp?thumb=)
#### Step 3 
Click on the plus icon to add a local plugin.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7696z39708249f4/f_2_m4hgctvnry.webp?thumb=)
Click on the box to link to an existing plugin to navigate to the `lit-plugin` folder that was created after the build process in your source code and select `manifest.json`.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/bs328tzc9kk71b7/f_3_ufrd6v1644.webp?thumb=)
#### Step 4 
To run the plugin navigate to “Plugins > Development > figma\_lit\_example” to launch your plugin.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/hpb8fmx3528jb5y/f_4_uoz37dtck3.webp?thumb=)
#### Step 5 
Now your plugin should launch and you can create 5 rectangles on the canvas.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/pqsmqosgf24p9r6/f_5_496mr4f5wp.webp?thumb=)
If everything worked you will have 5 new rectangles on the canvas focused by figma.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/261h7xp06dm41eq/f_6_kp2j5tc6xw.webp?thumb=)
## WASM Support 
If there is a heavy computation that could benefit from running in [WebAssembly](https://webassembly.org/) the following will ensure that it is hardware accelerated when possible.
Let's add [AssemblyScript](https://www.assemblyscript.org/) and some dependencies that will be used for loading the WASM into the figma ui.
```
npm i @assemblyscript/loader
npm i --D assemblyscript js-inline-wasm
npx asinit .
```
Confirm yes to the prompt to have it generate the project files and add the following to the scripts in `package.json`:
```
"asbuild:untouched": "asc assembly/index.ts --target debug",
"asbuild:optimized": "asc assembly/index.ts --target release",
"asbuild": "npm run asbuild:untouched && npm run asbuild:optimized",
"inlinewasm": "inlinewasm build/optimized.wasm --output src/wasm.ts",
```
The code that will be used for the WASM is in `/assembly/index.ts` and it should show the following:
```
// The entry file of your WebAssembly module.
export function add(a: i32, b: i32): i32 {
return a + b;
}
```
Now let's build the wasm module:
```
npm run asbuild
```
For the wasm build to be ignored for git add the following to .gitignore:
```
build
```
This will generate the wasm and wat files in the build directory, but for figma to load them into the ui it needs to be inlined so run the following command to generate the js from the wasm file:
```
npm run inlinewasm
```
This should generate `src/wasm.ts` with the following:
```
const encoded = 'AGFzbQEAAAABBwFgAn9/AX8DAgEABQMBAAAHEAIDYWRkAAAGbWVtb3J5AgAKCQEHACAAIAFqCwAmEHNvdXJjZU1hcHBpbmdVUkwULi9vcHRpbWl6ZWQud2FzbS5tYXA=';
export default new Promise(resolve => {
const decoded = atob(encoded);
const len = decoded.length;
const bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = decoded.charCodeAt(i);
}
resolve(new Response(bytes, { status: 200, headers: { "Content-Type": "application/wasm" } }));
});
```
Now open up the `/src/my-app.ts` and update with the following:
```
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("my-app")
export class MyApp extends LitElement {
@property() amount = "5"; // <-- Pass in a value for the number of rectangles to create
@query("#count") countInput!: HTMLInputElement;
render() {
return html`
<div>
<h2>Rectangle Creator</h2>
<!-- Pass in the amount to the input value -->
<p>Count: <input id="count" value="${this.amount}" /></p>
...
</div>
`;
}
...
}
```
This will let us pass in the amount of boxes to create externally.
Now open `/src/ui.ts` and update it with the following:
```
import "./my-app";
import wasm from "./wasm"; // <-- Our WASM file to load
WebAssembly.instantiateStreaming(wasm as Promise<Response>).then((obj) => {
// @ts-ignore
const value: number = obj.instance.exports.add(2, 4);
console.log("return from wasm", value);
const elem = document.querySelector('my-app')! as HTMLElement;
elem.setAttribute('amount', `${value}`);
});
```
Now when we build the plugin and run it in figma the amount of boxes will be the result of calling into wasm!
## Conclusion 
If you want to learn more about building a plugin in Figma you can read more [here](https://www.figma.com/plugin-docs/intro/) and for Lit you can read the docs [here](https://lit.dev/).
+396
View File
@@ -0,0 +1,396 @@
---
name: creating-your-first-flutter-project
description: Dive into the world of Flutter, Google's UI toolkit, and learn how to build cross-platform apps with ease using this introductory guide and accompanying "Flutter Take 5" video series.
metadata:
url: https://rodydavis.com/posts/first-flutter-project
last_modified: Tue, 03 Feb 2026 20:04:25 GMT
---
# Creating Your First Flutter Project
Flutter is a UI Toolkit from Google allowing you to create expressive and unique experiences unmatched on any platform. You can write your UI once and run it everywhere. Yes everywhere! Web, iOS, Android, Windows, Linux, MacOS, Raspberry PI and much more…
![](https://rodydavis.com/_/../api/files/pbc_2708086759/0g6tcax6u0r8q22/flutter_1_zxbnyzfn8p.webp?thumb=)
If you prefer a video you can follow the YouTube series I am doing called “Flutter Take 5” where I explore topics that you encounter when building a Flutter application. I will also give you tips and tricks as I go through the series.
Or this short:
## What is Flutter 
Flutter recently crossed React Native on Github and now has more than 2 million developers using Flutter to create applications. There are more than 50,000 apps on Google Play alone published with Flutter.
[Learn about Flutter.](https://flutter.dev/)
## Getting Started 
Getting started is very easy once you get the SDK installed. After it is installed creating new applications, plugins and packages is lighting fast. Follow this guide to install Flutter:
[How to install Flutter.](https://flutter.dev/docs/get-started/install)
One nice thing about Flutter is that it is developed in the open as an open source project that anyone can contribute to. If there is something missing you can easily fork the repo and make a PR for the missing functionality.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/39v6u1xoyr67izo/flutter_2_7906zvehq3.gif?thumb=)
## Create the Project 
Now that you have Flutter installed it is time to create your first (Of Many 😉) Flutter project! Open up your terminal and navigate to wherever you want the application folder to be created. Once you “cd” into the directory you can type the following:
```
flutter create my_awesome_project
```
You can replace “my\_awesome\_project” with whatever you want the project to be called. It is important to use snake\_case as it is the valid syntax for project names in dart.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/y2s4ms1j87w2x5d/flutter_3_ex6m9fvb6h.gif?thumb=)
Congratulations you just created your first project!
## Open the Project 
So you may be wondering what we just created so let us dive in to the details. You can open up you project in VSCode if you have it installed by typing the following into terminal:
```
cd my_awesome_project && code .
```
You can open up the folder in your favorite IDE if you prefer. Two important files to notice are the pubspec.yaml and lib/main.dart
Your UI and Logic is located at “lib/main.dart” and you should see the following:
```
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// Try running your application with "flutter run". You'll see the
// application has a blue toolbar. Then, without quitting the app, try
// changing the primarySwatch below to Colors.green and then invoke
// "hot reload" (press "r" in the console where you ran "flutter run",
// or simply save your changes to "hot reload" in a Flutter IDE).
// Notice that the counter didn't reset back to zero; the application
// is not restarted.
primarySwatch: Colors.blue,
// This makes the visual density adapt to the platform that you run
// the app on. For desktop platforms, the controls will be smaller and
// closer together (more dense) than on mobile platforms.
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Invoke "debug painting" (press "p" in the console, choose the
// "Toggle Debug Paint" action from the Flutter Inspector in Android
// Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
// to see the wireframe for each widget.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
```
You can define any dependencies and plugins needed for the application at “pubspec.yaml” and you should see the following:
```
name: example
description: A new Flutter project.
# The following line prevents the package from being accidentally published to
# pub.dev using `pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+1
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.3
dev_dependencies:
flutter_test:
sdk: flutter
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.webp
# - images/a_dot_ham.webp
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
```
## Running the Project 
Running the application is very easy too. While there are buttons in all the IDEs you can also run your project from the command line for quick testing. You can also configure [Flutter for Desktop](https://flutter.dev/desktop) and no need to wait for an emulator to warm up. Open your project and enter the following into terminal:
```
flutter run -d macos
```
Notice the “-d macos” as you can customize what device you want to run on. You should see the following in terminal:
```
Building macOS application...
Syncing files to device macOS... 141ms
Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h Repeat this help message.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).
An Observatory debugger and profiler on macOS is available at: [http://127.0.0.1:58932/f1Mspofty_k=/](http://127.0.0.1:58932/f1Mspofty_k=/)
Application finished.
```
You can also run multiple devices at the same time. You can find more info on the [Flutter Octopus here](https://github.com/flutter/flutter/wiki/Multi-device-debugging-in-VS-Code). If everything went well you should see the following application launch:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/195z98qz6wrk9b3/flutter_4_sh7qzq9ggv.webp?thumb=)
It is a pretty basic application at this point but it is important to show how easy it is to change the state in the application. You can rebuild the UI just by calling “setState()”.
## Testing the Project 
Testing is one of the reasons I love Flutter so much and it is dead simple to run and write tests for the project. If you look at the file “test/widget\_test.dart” you should see the following:
```
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility that Flutter provides. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:example/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
```
You can run these tests very easily. Open your project and type the following into the terminal:
```
flutter test
00:07 +1: All tests passed!
```
Just like that all your tests will run and you can catch any bugs you missed.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7r5n806p1jd1w3u/flutter_5_fr3u6tytdx.gif?thumb=)
You can also generate code coverage for your applications easily by typing the following:
```
flutter test --coverage
```
This will generate a new file at “coverage/lcov.info” and will read the following:
```
SF:lib/main.dart
DA:3,0
DA:4,0
DA:9,1
DA:11,1
DA:13,1
DA:27,1
DA:29,1
DA:35,2
DA:48,1
DA:49,1
DA:55,1
DA:56,2
DA:62,2
DA:66,1
DA:74,1
DA:75,1
DA:78,3
DA:80,1
DA:83,1
DA:99,1
DA:100,1
DA:103,1
DA:104,2
DA:105,3
DA:110,1
DA:111,1
DA:113,1
LF:27
LH:25
end_of_record
```
You can now easily create badges and graphs with the LCOV data. Here is a package that will make that easier:
[test\_coverage | Dart Package](https://pub.dev/packages/test_coverage)
## Conclusion 
Flutter makes it possible to build applications very quickly that do not depend on web or mobile technologies. It can familiar to writing a game as you have to design all your own UI. You can find the final source code here:
[Final source code.](https://github.com/rodydavis/flutter_take_5/tree/master/01_your_first_project)
You can also find the Flutter source code here:
[Flutter source code.](https://github.com/flutter/flutter)
+482
View File
@@ -0,0 +1,482 @@
---
name: lit-and-flutter
description: Learn how to embed a Lit web component directly within your Flutter app to leverage web-based UIs and features while accessing native device APIs for a powerful hybrid development approach.
metadata:
url: https://rodydavis.com/posts/flutter-and-lit
last_modified: Tue, 03 Feb 2026 20:04:19 GMT
---
# Lit and Flutter
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it inline in the Flutter widget tree.
> **TLDR** You can find the final source [here](https://github.com/rodydavis/flutter_hybrid_template).
The reason you would want this integration is so you can take an existing web app, or just a single part of it and embed it in the widget tree.
With it wrapped in Flutter you can call device APIs from event listeners on your web component.
For example you may have an app that handles purchases, and now you can call the in app purchase API or other device specific features not available on the web.
You also get a cross platform app that can be delivered to both Google Play and the App Store.
The web component will receive new code each time you update your site, so you do not have to ship an update each time the web component changes.
## Prerequisites 
* Flutter SDK
* Xcode and Command Line Tools
* Android SDK
* Vscode
* Node
* Typescript
## Getting Started 
We can start off by creating a empty directory and naming it with `snake_case` whatever we want.
```
mkdir flutter_lit_example
cd flutter_lit_example
```
### Web Setup 
Now we are in the `flutter_lit_example` directory and can setup Flutter and Lit. Let's start with node.
```
npm init -y
npm i lit
npm i -D typescript vite @types/node
```
This will setup the basics for a node project and install the packages we need. Now lets add some config files.
```
touch tsconfig.json
touch vite.config.ts
```
This will create 2 files. Now open up `tsconfig.json` and paste the following:
```
{
"compilerOptions": {
"module": "esnext",
"lib": [
"es2017",
"dom",
"dom.iterable"
],
"types": [
"vite/client"
],
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./types",
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts"
],
"exclude": []
}
```
This is a basic typescript config. Now open up `vite.config.ts` and paste the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
// https://vitejs.dev/config/
export default defineConfig({
base: "/flutter_lit_example/", // TODO: Name of your github repo
build: {
outDir: "build/web",
rollupOptions: {
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
input: {
main: resolve(__dirname, "index.html"),
// TODO: Create a new module for each component you want to embed
},
},
},
});
```
Now we need to create our web component:
```
mkdir src
cd src
touch my-app.ts
cd ..
```
Open `my-app.ts` and paste the following:
```
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("my-app")
export class MyApp extends LitElement {
static styles = css`
p {
color: blue;
}
`;
@property()
name = "Somebody";
render() {
return html`<div>
<p>Hello, ${this.name}!</p>
<slot></slot>
</div>`;
}
}
```
We need to create a `index.html` for our web app.
```
touch index.html
```
Open `index.html` and paste the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Example</title>
<script type="module" src="/src/my-app.ts"></script>
<style>
body {
padding: 0;
margin: 0;
}
my-app {
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<my-app></my-app>
</body>
</html>
```
### Flutter Setup 
Now that we have the basics setup for web we can move on to flutter. Let's create the project with the following:
```
flutter create --platforms=ios,android .
flutter packages get
```
Open up `pubspec.yaml` and update it with the following:
```
name: flutter_lit_example
description: A hybrid Flutter app.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_inappwebview: ^5.3.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
```
Make sure to get the packages again:
```
flutter packages get
```
Now we need to create the file that will wrap the web component.
```
cd lib
touch web_component.dart
cd ..
```
Open `web_component.dart` and paste the following:
```
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
class WebComponent extends StatefulWidget {
const WebComponent({
Key key,
@required this.name,
@required this.bundle,
this.attributes = const {},
this.slot = '',
this.events = const [],
}) : super(key: key);
final String name, bundle;
final Map<String, String> attributes;
final String slot;
final List<EventCallback> events;
@override
_WebComponentState createState() => _WebComponentState();
}
class _WebComponentState extends State<WebComponent> {
InAppWebViewController controller;
final Map<String, List<EventCallback>> _events = {};
String get source {
return '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
padding: 0;
margin: 0;
}
${widget.name} {
width: 100%;
height: 100vh;
}
</style>
<script type="module" crossorigin src="${widget.bundle}"></script>
</head>
<body>
<${widget.name} ${widget.attributes.entries.map((e) => '${e.key}="${e.value}"').join(' ')}>
${widget.slot}
</${widget.name}>
<script>
window.addEventListener("flutterInAppWebViewPlatformReady", (event) => {
${widget.events.join('\n')}
});
</script>
</body>
</html>
''';
}
void _setup(InAppWebViewController controller) {
this.controller = controller;
this._setupEvents();
}
void _setupEvents() {
for (final event in _events.keys) {
controller.removeJavaScriptHandler(handlerName: event);
}
for (final event in widget.events) {
_addEvent(event);
}
}
void _addEvent(EventCallback event) {
controller.addJavaScriptHandler(
handlerName: event.query,
callback: event.onPressed,
);
_events[event.event] ??= [];
_events[event.event].add(event);
}
@override
void didUpdateWidget(covariant WebComponent oldWidget) {
if (oldWidget.events != widget.events) {
_setupEvents();
}
if (oldWidget.slot != widget.slot ||
oldWidget.bundle != widget.bundle ||
oldWidget.name != widget.name) {
controller.loadData(data: source);
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return InAppWebView(
initialData: InAppWebViewInitialData(data: source),
onWebViewCreated: _setup,
);
}
}
class EventCallback {
EventCallback({
@required this.onPressed,
@required this.event,
this.query,
});
final String query, event;
final dynamic Function(List<dynamic> args) onPressed;
@override
String toString() => _source;
String get _prefix => query != null && query.isNotEmpty
? 'document.querySelector("$query")'
: 'document.body';
String get _source => [
'$_prefix.addEventListener("$event", (e) => {',
' window.flutter_inappwebview.callHandler("$query", e);',
'}, false);',
].join('\n');
}
```
Open `main.dart` and paste it with th following:
```
import 'package:flutter/material.dart';
import 'web_component.dart';
const WEBSITE_URL = 'https://rodydavis.github.io/flutter_lit_example/';
const BUNDLE_PATH = 'assets/main.js';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final title = 'Flutter Hybrid App';
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: title,
theme: ThemeData(primarySwatch: Colors.blue),
home: MyHomePage(title: title),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Builder(
builder: (context) => WebComponent(
name: 'my-app',
bundle: '$WEBSITE_URL/$BUNDLE_PATH',
attributes: {
'name': widget.title,
},
slot: '<button id="my-button">Talk back!</button>',
events: [
EventCallback(
event: 'click',
query: '#my-button',
onPressed: (_) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Clicked!')));
},
),
],
),
),
);
}
}
```
You will need to update `WEBSITE_URL` to have the url of the website where you will be deploying and `BUNDLE_URL` to the relative path to the js bundle.
This will ensure auto updates with a new version rolls out and the cache it stale. This will also allow for offline support after the first time it is downloaded.
## Running 
Now we can run our application but it requires a few steps to get it all setup.
To test and build our web app locally we will use [vite](https://github.com/vitejs/vite) and render the `index.html`
```
npm i
npm run dev
```
You should see the following:
```
vite v2.2.3 dev server running at:
Local: http://localhost:3000/flutter_lit_example/
Network: http://192.168.1.143:3000/flutter_lit_example/
ready in 311ms.
```
We can open the link `http://localhost:3000/flutter_lit_example/` to see our running web app and hot reload changes from `my-app.ts`.
If you want to learn more about Lit you can read the docs [here](https://lit.dev/).
Once you are happy with how it looks we can move on to Flutter to wrap it in a native app. This will give us access to native code if we wanted to use the in app purchase api or push notifications.
Kill the terminal and run the following:
```
flutter packages get
flutter build ios
flutter build appbundle
flutter run
```
This should select a running device or prompt you to select one. Now that it is running on the device you can see we have two way communication with the Flutter app and the web component.
## Conclusion 
If you want to find the source code you can check it out [here](https://github.com/rodydavis/flutter_hybrid_template) otherwise thanks for reading and let me know if you have any questions!
+131
View File
@@ -0,0 +1,131 @@
---
name: how-to-build-a-flutter-app-on-xcode-cloud
description: Learn how to set up Xcode Cloud to build and deploy your Flutter application to TestFlight and the App Store with this step-by-step guide.
metadata:
url: https://rodydavis.com/posts/flutter-and-xcode-cloud
last_modified: Tue, 03 Feb 2026 20:04:22 GMT
---
# How to build a Flutter app on Xcode Cloud
In this article we are going to go over how to setup [Xcode Cloud](https://developer.apple.com/xcode-cloud/) to build your [Flutter](https://flutter.dev/) application for [TestFlight](https://developer.apple.com/testflight/) and the [AppStore](https://developer.apple.com/app-store/).
## Step 1 
Before we begin Flutter needs to be installed, and you can check by running the following:
```
flutter doctor -v
```
After it is installed we can run the following command to create and open our Flutter project (skip down to step 2 if adding to an existing app).
```
mkdir flutter_ci_example
cd flutter_ci_example
flutter create .
```
If you need more help with creating the first project you can check out my previous blog post [here](https://rodydavis.com/posts/first-flutter-project/).
After the project is created open it in your favorite code editor.
```
code .
```
## Step 2 
The generated files should look like the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/pl153678e69f0qk/x_1_qw8btmibvc.webp?thumb=)
Create a new file at `ios/ci_scripts/ci_post_install.sh` and update it with the following:
```
#!/bin/sh
# Install CocoaPods using Homebrew.
brew install cocoapods
# Install Flutter
brew install --cask flutter
# Run Flutter doctor
flutter doctor
# Get packages
flutter packages get
# Update generated files
flutter pub run build_runner build
# Build ios app
flutter build ios --no-codesign
```
This is a file Xcode Cloud needs to run after the project is downloaded. We need to install [cocoapods](https://cocoapods.org/) for any plugins we are using and Flutter to prebuild our application.
Then run the following command which will make the script executable:
```
chmod +x ios/ci_scripts/ci_post_clone.sh
```
## Step 3 
Open up the iOS project in Xcode by right clicking on the iOS folder and selecting "Open in Xcode".
![](https://rodydavis.com/_/../api/files/pbc_2708086759/u0cveo6hhi22s90/x_2_i34bfwz1u0.webp?thumb=)
You can also open the project by double clicking on the `ios/Runner.xcworkspace` file.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/day67g90vkws5q3/x_3_80qu3qrdlp.webp?thumb=)
Make sure you have the latest version of Xcode Cloud install and that you have [access to the beta](https://developer.apple.com/xcode-cloud/beta/). Create a new workflow by the menu `Product > Xcode Cloud > Create Workflow`:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/qfz8667e0y89hkh/x_4_xsrdzbddjh.webp?thumb=)
Follow the flow to add the project and choose which type of build you want.
Make sure to remove MacOS as a target in the workflow by selecting `Archive - MacOS` and the delete icon on the top right.
If you want to build and release the MacOS app you will need to do that with another script in the macos folder and a workflow in that Xcode workspace.
You can create the file `macos/ci_scripts/ci_post_clone.sh` and update it with the following:
```
#!/bin/sh
# Install CocoaPods using Homebrew.
brew install cocoapods
# Install Flutter
brew install --cask flutter
# Run Flutter doctor
flutter doctor
# Enable macos
flutter config --enable-macos-desktop
# Get packages
flutter packages get
# Update generated files
flutter pub run build_runner build
# Build ios app
flutter build ios --no-codesign
```
If all goes well it will look like the following after a successful build:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/6ubskgc91jv51ha/x_5_zgz4x31cbp.webp?thumb=)
## Conclusion 
Flutter makes it ease to build and deploy to multiple platforms and Xcode Cloud takes care of the signing for Apple platforms.
You can learn more about cd and flutter [here](https://docs.flutter.dev/deployment/cd).
+104
View File
@@ -0,0 +1,104 @@
---
name: flutter-terminal-cheat-sheet
description: This post provides a handy collection of Flutter commands and scripts for web development, package creation, troubleshooting, testing, and more, streamlining your Flutter workflow.
metadata:
url: https://rodydavis.com/posts/flutter-cheat-sheet
last_modified: Tue, 03 Feb 2026 20:04:23 GMT
---
# Flutter Terminal Cheat Sheet
## Run Flutter web with SKIA
```
flutter run -d web --release --dart-define=FLUTTER_WEB_USE_SKIA=true
```
## Run Flutter web with Canvas Kit
```
flutter run -d chrome --release --dart-define=FLUTTER_WEB_USE_EXPERIMENTAL_CANVAS_TEXT=true
```
## Build your Flutter web app to Github Pages to the docs folder
```
flutter build web && rm -rf ./docs && mkdir ./docs && cp -a ./build/web/. ./docs/
```
## Clean rebuild CocoaPods
```
cd ios && pod deintegrate && pod cache clean —all && pod install && cd ..
```
> Sometimes with firebase you need to run: `pod update Firebase`
## Create Dart package with Example
```
flutter create -t plugin . && flutter create -i swift -a kotlin --androidx example
```
## Watch Build Files
```
flutter packages pub run build_runner watch -—delete-conflicting-outputs
```
## Generate Build Files
```
flutter packages pub run build_runner build -—delete-conflicting-outputs
```
## Build Bug Report
```
flutter run —bug-report
```
## Flutter generate test coverage
```
flutter test --coverage && genhtml -o coverage coverage/lcov.info
```
## Rebuild Flutter Cache
```
flutter pub pub cache repair
```
## Clean every flutter project
```
find . -name "pubspec.yaml" -exec $SHELL -c '
echo "Done. Cleaning all projects."
for i in "$@" ; do
DIR=$(dirname "${i}")
echo "Cleaning ${DIR}..."
(cd "$DIR" && flutter clean >/dev/null 2>&1)
done
echo "DONE!"
' {} +
```
## Conditional Export/Import
```
export 'unsupported.dart'
if (dart.library.html) 'web.dart'
if (dart.library.io) 'mobile.dart';
```
## Kill Dart Running
```
killall -9 dart
```
## Flutter scripts 
Add all the scripts to your `pubspec.yaml` with [flutter\_scripts](https://pub.dev/packages/flutter_scripts).
+864
View File
@@ -0,0 +1,864 @@
---
name: how-to-build-a-graph-database-with-flutter
description: Learn how to build and utilize a graph database within your Flutter applications using SQLite and the Drift package to model relationships between data.
metadata:
url: https://rodydavis.com/posts/flutter-graph-database
last_modified: Tue, 03 Feb 2026 20:04:36 GMT
---
# How to build a graph database with Flutter
In this article I will go over how to create and use a graph database with [Flutter](https://flutter.dev/).
**TLDR** The final source [here](https://github.com/rodydavis/flutter_graph_database) and an online [demo](https://rodydavis.github.io/flutter_graph_database/).
## Prerequisites 
Flutter installed and setup (Refer to this [article](https://rodydavis.com/posts/first-flutter-project/) if you need help).
Basic knowledge of [SQLite](https://www.sqlite.org/index.html).
Basic knowledge of Graph Databases (Refer to this [video](https://www.youtube.com/watch?v=GekQqFZm7mA) if you need to learn more).
## Overview 
First of all, why do we need a graph database when other storage options exist?
Why not use key value stores, document stores, or relational databases?
Well, the answer is that it depends on the problem you are trying to solve.
Graph databases are great for modeling relationships between data.
A couple examples:
* A social network app can model the relationships between users and posts
* A game can model the relationships between players and items
* A blog can model the relationships between posts and comments
The possibilities are endless.
Instead of storing data in a table for each collection we store the data as a graph in a nodes and edges table with some additional extensions in SQLite to make it easier.
Here is a [page](https://www.hytradboi.com/2022/simple-graph-sqlite-as-probably-the-only-graph-database-youll-ever-need) that goes in to detail about it and showcases what we are trying to build.
## Getting Started 
First we need to create a new Flutter project.
```
mkdir flutter_graph_database
cd flutter_graph_database
flutter create .
```
After the project is created open it in your favorite code editor.
```
code .
```
## Creating the Database 
We are going to use the [drift](hhttps://pub.dev/packages/drift) package to create the database.
Update the **pubspec.yaml** file with the following:
```
name: flutter_graph_database
description: A new Flutter package project.
version: 0.0.1
publish_to: none
environment:
sdk: ">=2.19.0-238.0.dev <3.0.0"
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
drift: ^2.1.0
sqlite3_flutter_libs: ^0.5.5
http: ^0.13.5
path_provider: ^2.0.0
path: ^1.8.2
sqlite3: ^1.7.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
build_runner: ^2.2.0
drift_dev: ^2.1.0
flutter:
```
### Database Connection 
Next we need to create the database.
Create a new file at **lib/database/connection/unsupported.dart** and update it with the following:
```
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
DatabaseConnection connect(
String dbName, {
bool useWebWorker = false,
bool logStatements = false,
}) {
return DatabaseConnection(NativeDatabase.memory(
logStatements: logStatements,
));
}
```
Create a new file at **lib/database/connection/native.dart** and update it with the following:
```
import 'dart:io';
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
DatabaseConnection connect(
String dbName, {
bool useWebWorker = false,
bool logStatements = false,
}) {
return DatabaseConnection.delayed(Future.sync(() async {
final appDir = await getApplicationDocumentsDirectory();
final dbPath = p.join(appDir.path, dbName);
final receiveDriftIsolate = ReceivePort();
await Isolate.spawn(_entrypointForDriftIsolate,
_IsolateStartRequest(receiveDriftIsolate.sendPort, dbPath));
final driftIsolate = await receiveDriftIsolate.first as DriftIsolate;
return driftIsolate.connect();
}));
}
class _IsolateStartRequest {
final SendPort talkToMain;
final String databasePath;
_IsolateStartRequest(this.talkToMain, this.databasePath);
}
void _entrypointForDriftIsolate(_IsolateStartRequest request) {
final databaseImpl = NativeDatabase(
File(request.databasePath),
logStatements: false,
);
final driftServer = DriftIsolate.inCurrent(
() => DatabaseConnection(databaseImpl),
);
request.talkToMain.send(driftServer);
}
```
Create a new file at **lib/database/connection/web.dart** and update it with the following:
```
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html';
import 'package:drift/drift.dart';
import 'package:drift/remote.dart';
import 'package:drift/web.dart';
import 'package:drift/wasm.dart';
import 'package:http/http.dart' as http;
import 'package:sqlite3/wasm.dart';
DatabaseConnection connect(
String dbName, {
bool useWebWorker = false,
bool logStatements = false,
}) {
if (useWebWorker) {
final worker = SharedWorker('shared_worker.dart.js');
return remote(worker.port!.channel());
} else {
return DatabaseConnection.delayed(Future.sync(() async {
final response = await http.get(Uri.parse('sqlite3.wasm'));
final fs = await IndexedDbFileSystem.open(dbName: '/db/');
final path = '/drift/db/$dbName';
final sqlite3 = await WasmSqlite3.load(
response.bodyBytes,
SqliteEnvironment(fileSystem: fs),
);
final databaseImpl = WasmDatabase(
sqlite3: sqlite3,
path: path,
fileSystem: fs, // <- this is required but not documented
logStatements: logStatements,
);
return DatabaseConnection(databaseImpl);
}));
}
}
```
Create a new file at **lib/database/connection/connection.dart** and update it with the following:
```
export 'unsupported.dart'
if (dart.library.js) 'web.dart'
if (dart.library.ffi) 'native.dart';
```
### Database SQL Files 
#### Schema 
Create a new file at **lib/database/sql/schema.drift** and update it with the following:
```
CREATE TABLE IF NOT EXISTS nodes (
body TEXT,
id TEXT GENERATED ALWAYS AS (json_extract(body, '$.id')) VIRTUAL NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS id_idx ON nodes(id);
CREATE TABLE IF NOT EXISTS edges (
source TEXT,
target TEXT,
properties TEXT,
UNIQUE(source, target, properties) ON CONFLICT REPLACE,
FOREIGN KEY(source) REFERENCES nodes(id),
FOREIGN KEY(target) REFERENCES nodes(id)
);
CREATE INDEX IF NOT EXISTS source_idx ON edges(source);
CREATE INDEX IF NOT EXISTS target_idx ON edges(target);
```
> The ID column is a virtual column that is generated from the body column. This is done so that we can query the database by ID without having to parse the JSON body column.
#### Queries 
Create a new file at **lib/database/sql/queries.drift** and update it with the following:
```
import 'schema.drift';
getAllNodes:
SELECT * FROM nodes;
getAllEdges:
SELECT * FROM edges;
```
#### Delete Edge 
Create a new file at **lib/database/sql/delete-edge.drift** and update it with the following:
```
import 'schema.drift';
deleteEdge:
DELETE FROM edges
WHERE source = ? OR target = ?;
```
#### Delete Node 
Create a new file at **lib/database/sql/delete-node.drift** and update it with the following:
```
import 'schema.drift';
deleteNode:
DELETE FROM nodes
WHERE id = ?;
```
#### Insert Edge 
Create a new file at **lib/database/sql/insert-edge.drift** and update it with the following:
```
import 'schema.drift';
insertEdge(:source as TEXT, :target as TEXT, :body as TEXT):
INSERT INTO edges VALUES(:source, :target, json(:body));
```
#### Search Edges Inbound 
Create a new file at **lib/database/sql/search-edges-inbound.drift** and update it with the following:
```
import 'schema.drift';
searchEdgesInbound:
SELECT * FROM edges
WHERE source = ?;
```
#### Search Edges Outbound 
Create a new file at **lib/database/sql/search-edges-outbound.drift** and update it with the following:
```
import 'schema.drift';
searchEdgesOutbound:
SELECT * FROM edges
WHERE target = ?;
```
#### Search Edges 
Create a new file at **lib/database/sql/search-edges.drift** and update it with the following:
```
import 'schema.drift';
searchEdges:
SELECT * FROM edges WHERE source = ?
UNION
SELECT * FROM edges WHERE target = ?;
```
#### Search Node By ID 
Create a new file at **lib/database/sql/search-node-by-id.drift** and update it with the following:
```
import 'schema.drift';
searchNodeById:
SELECT body FROM nodes
WHERE id = ?;
```
#### Search Node 
Create a new file at **lib/database/sql/search-node.drift** and update it with the following:
```
import 'schema.drift';
-- Create a text index of entries, see https://www.sqlite.org/fts5.html#external_content_tables
CREATE VIRTUAL TABLE node_entries USING fts5 (
body,
content=nodes,
content_rowid=id
);
-- Triggers to keep entries and fts5 index in sync.
CREATE TRIGGER nodes_insert AFTER INSERT ON nodes BEGIN
INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body);
END;
CREATE TRIGGER nodes_delete AFTER DELETE ON nodes BEGIN
INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', old.id, old.body);
END;
CREATE TRIGGER nodes_update AFTER UPDATE ON nodes BEGIN
INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', new.id, new.body);
INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body);
END;
-- Full text search query.
searchNode: SELECT r.** FROM node_entries
INNER JOIN nodes r ON r.id = node_entries.rowid
WHERE node_entries MATCH :query
ORDER BY rank;
```
> Here we are using the [fts5](https://www.sqlite.org/fts5.html) extension to create a full text search index. This is a very powerful feature that allows us to search for nodes by their body text.
#### Traverse Inbound 
Create a new file at **lib/database/sql/traverse-inbound.drift** and update it with the following:
```
import 'schema.drift';
traverseInbound(:source AS TEXT):
WITH RECURSIVE traverse(id) AS (
SELECT :source
UNION
SELECT source FROM edges JOIN traverse ON target = id
) SELECT id FROM traverse;
```
#### Traverse Outbound 
Create a new file at **lib/database/sql/traverse-outbound.drift** and update it with the following:
```
import 'schema.drift';
traverseOutbound(:source AS TEXT):
WITH RECURSIVE traverse(id) AS (
SELECT :source
UNION
SELECT target FROM edges JOIN traverse ON source = id
) SELECT id FROM traverse;
```
#### Traverse Bodies Inbound 
Create a new file at **lib/database/sql/traverse-with-bodies-inbound.drift** and update it with the following:
```
import 'schema.drift';
traverseWithBodiesInbound(:source AS TEXT):
WITH RECURSIVE traverse(x, y, obj) AS (
SELECT :source, '()', '{}'
UNION
SELECT id, '()', body FROM nodes JOIN traverse ON id = x
UNION
SELECT source, '<-', properties FROM edges JOIN traverse ON target = x
) SELECT x, y, obj FROM traverse;
```
#### Traverse Bodies Outbound 
Create a new file at **lib/database/sql/traverse-with-bodies-outbound.drift** and update it with the following:
```
import 'schema.drift';
traverseWithBodiesOutbound(:source AS TEXT):
WITH RECURSIVE traverse(x, y, obj) AS (
SELECT :source, '()', '{}'
UNION
SELECT id, '()', body FROM nodes JOIN traverse ON id = x
UNION
SELECT target, '->', properties FROM edges JOIN traverse ON source = x
) SELECT x, y, obj FROM traverse;
```
#### Traverse Bodies 
Create a new file at **lib/database/sql/traverse-bodies.drift** and update it with the following:
```
import 'schema.drift';
traverseWithBodies(:source AS TEXT):
WITH RECURSIVE traverse(x, y, obj) AS (
SELECT :source, '()', '{}'
UNION
SELECT id, '()', body FROM nodes JOIN traverse ON id = x
UNION
SELECT source, '<-', properties FROM edges JOIN traverse ON target = x
UNION
SELECT target, '->', properties FROM edges JOIN traverse ON source = x
) SELECT x, y, obj FROM traverse;
```
#### Traverse 
Create a new file at **lib/database/sql/traverse.drift** and update it with the following:
```
import 'schema.drift';
traverse(:source AS TEXT):
WITH RECURSIVE traverse(id) AS (
SELECT :source
UNION
SELECT source FROM edges JOIN traverse ON target = id
UNION
SELECT target FROM edges JOIN traverse ON source = id
) SELECT id FROM traverse;
```
#### Update Node 
Create a new file at **lib/database/sql/update-node.drift** and update it with the following:
```
import 'schema.drift';
updateNode:
UPDATE nodes SET body = json(?)
WHERE id = ?;
```
### Database Setup 
Create a new file at **lib/database/database.dart** and update it with the following:
```
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'connection/connection.dart' as impl;
part 'database.g.dart';
@DriftDatabase(include: {
'sql/schema.drift',
'sql/queries.drift',
'sql/delete-edge.drift',
'sql/delete-node.drift',
'sql/insert-edge.drift',
'sql/insert-node.drift',
'sql/search-edges-inbound.drift',
'sql/search-edges-outbound.drift',
'sql/search-edges.drift',
'sql/search-node-by-id.drift',
'sql/search-node.drift',
'sql/traverse-inbound.drift',
'sql/traverse-outbound.drift',
'sql/traverse-with-bodies-inbound.drift',
'sql/traverse-with-bodies-outbound.drift',
'sql/traverse-with-bodies.drift',
'sql/traverse.drift',
'sql/update-node.drift',
})
class GraphDatabase extends _$GraphDatabase {
GraphDatabase({
String dbName = 'graph_db.db',
DatabaseConnection? connection,
bool useWebWorker = false,
bool logStatements = false,
}) : super.connect(
connection ??
impl.connect(
dbName,
useWebWorker: useWebWorker,
logStatements: logStatements,
),
);
@override
int get schemaVersion => 1;
/// Helper method to add graph data from json
Future<void> addGraphData(
Map<String, dynamic> data, {
bool shouldBatch = false,
}) {
return transaction(() async {
try {
final localNodes = data['nodes'] as List<dynamic>;
final localEdges = data['edges'] as List<dynamic>;
// Update nodes
for (final node in localNodes) {
final id = node['id'] as String?;
if (id != null) {
final current = await searchNodeById(id).getSingleOrNull();
final body = jsonEncode(node);
if (current != null) {
await updateNode(id, body);
} else {
await insertNode(body);
}
}
}
// Update edges
for (final edge in localEdges) {
final source = edge['from'] ?? edge['source'] as String?;
final target = edge['to'] ?? edge['target'] as String?;
if (source != null && target != null) {
final body = jsonEncode(edge);
await insertEdge(source, target, body);
}
}
} catch (e) {
debugPrint('Error adding graph data: $e');
}
});
}
Future<void> deleteAll() {
return transaction(() async {
try {
await deleteAllEdges();
await deleteAllNodes();
} catch (e) {
debugPrint('Error clearing graph data: $e');
}
});
}
Future<void> deleteAllEdges() {
return transaction(() async {
final edges = await getAllEdges().get();
for (final edge in edges) {
await deleteEdge(edge.source, edge.target);
}
});
}
Future<void> deleteAllNodes() {
return transaction(() async {
final nodes = await getAllNodes().get();
for (final node in nodes) {
await deleteNode(node.id);
}
});
}
}
```
Create a new file at **build.yaml** and update it with the following:
```
targets:
$default:
sources:
- lib/**
- web/**
- "tool/**"
- pubspec.yaml
- lib/$lib$
- $package$
builders:
drift_dev:
options:
sql:
dialect: sqlite
options:
version: "3.38"
modules:
- json1
- fts5
generate_connect_constructor: true
apply_converters_on_variables: true
generate_values_in_copy_with: true
scoped_dart_components: true
```
Now run the following command to generate the database files:
```
flutter pub run build_runner build --delete-conflicting-outputs
```
## Connecting to the Database 
Add a new dependency to your **pubspec.yaml** file:
```
flutter pub add graphview
```
This will be used for the graph visualization.
Create a new file at **lib/main.dart** and update it with the following:
```
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_graph_database/flutter_graph_database.dart' as db;
import 'package:graphview/GraphView.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Graph Database',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: const Example(),
);
}
}
class Example extends StatefulWidget {
const Example({Key? key}) : super(key: key);
@override
State<Example> createState() => _ExampleState();
}
class _ExampleState extends State<Example> {
final database = db.GraphDatabase();
Graph graph = Graph();
Algorithm builder = FruchtermanReingoldAlgorithm();
final nodes = <String, db.Node>{};
bool loaded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => loadData());
}
@override
void reassemble() {
super.reassemble();
// Needed to reset graph on hot reload
loadData();
}
void setLoadedState(bool value) {
if (mounted) {
setState(() {
loaded = value;
});
}
}
Future<void> addDummyData() async {
// Load example data
try {
// Optionally reset data
await database.deleteAll();
// Add example data to database
await database.addGraphData({
"nodes": [
{"id": '1', "label": 'circle'},
{"id": '2', "label": 'ellipse'},
{"id": '3', "label": 'database'},
{"id": '4', "label": 'box'},
{"id": '5', "label": 'diamond'},
{"id": '6', "label": 'dot'},
{"id": '7', "label": 'square'},
{"id": '8', "label": 'triangle'},
{"id": '9', "label": "star"},
],
"edges": [
{"from": '1', "to": '2'},
{"from": '2', "to": '3'},
{"from": '2', "to": '4'},
{"from": '2', "to": '5'},
{"from": '5', "to": '6'},
{"from": '5', "to": '7'},
{"from": '6', "to": '8'},
{"from": '2', "to": '8'},
{"from": '1', "to": '8'},
{"from": '1', "to": '7'},
{"from": '1', "to": '6'},
{"from": '1', "to": '5'},
{"from": '1', "to": '4'},
{"from": '1', "to": '3'},
{"from": '1', "to": '9'},
{"from": '9', "to": '8'},
{"from": '9', "to": '5'},
{"from": '9', "to": '3'},
]
});
loadData();
} catch (e) {
debugPrint('Error loading example data: $e');
}
}
Future<void> loadData() async {
setLoadedState(false);
final nodeMap = <String, Node>{};
this.nodes.clear();
graph = Graph();
builder = FruchtermanReingoldAlgorithm();
// Load graph data
final nodes = await database.getAllNodes().get();
final edges = await database.getAllEdges().get();
for (final node in nodes) {
final newNode = Node.Id(node.id);
nodeMap[node.id] = newNode;
this.nodes[node.id] = node;
graph.addNode(newNode);
}
for (final edge in edges) {
final source = nodeMap[edge.source];
final target = nodeMap[edge.target];
if (source != null && target != null) {
graph.addEdge(source, target);
}
}
setLoadedState(true);
}
Widget buildNode(Node node) {
final dbNode = nodes[node.key!.value];
final data = jsonDecode(dbNode?.body ?? '{}') as Map<String, dynamic>;
final label = data['label'] ?? '';
return SizedBox(
width: 80,
height: 80,
child: Center(
child: Text(
label,
textAlign: TextAlign.center,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Graph Database'),
actions: [
IconButton(
icon: const Icon(Icons.restore),
onPressed: addDummyData,
),
],
),
body: !loaded
? const Center(child: CircularProgressIndicator())
: nodes.isEmpty
? const Center(child: Text('No Data Loaded'))
: LayoutBuilder(builder: (context, dimens) {
return SizedBox.expand(
child: InteractiveViewer(
constrained: false,
boundaryMargin: EdgeInsets.symmetric(
horizontal: dimens.maxWidth * 0.75,
vertical: dimens.maxHeight * 0.75,
),
minScale: 0.01,
maxScale: 5.6,
child: GraphView(
key: UniqueKey(),
graph: graph,
algorithm: builder,
paint: Paint()
..color = Colors.green
..strokeWidth = 1
..style = PaintingStyle.stroke,
builder: buildNode,
),
),
);
}),
);
}
}
```
When you run the flutter app you should see the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/br82944o71nzm81/graph_flutter_final_yikh8roctq.webp?thumb=)
## Conclusion 
If you want to learn more about building a graph database in Flutter, check out the [source code](https://github.com/rodydavis/flutter_graph_database).
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
---
name: flutter-fastlane-one-click-beta
description: Deploy your Flutter app to the App Store and Google Play with ease using this step-by-step guide covering installation, project setup, Fastlane integration, and automated deployments with Automator.
metadata:
url: https://rodydavis.com/posts/flutter-one-click-release
last_modified: Tue, 03 Feb 2026 20:04:23 GMT
---
# Flutter + Fastlane (One Click Beta)
## 1\. Install Flutter 
[Download Flutter](https://flutter.io/get-started/install/)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7g4od2ae671i5x5/ff_1_u67ip3pbk5.webp?thumb=)
## 2\. Create new Flutter Project 
If you are pretty new to Flutter you can check out [this useful guide](https://flutter.io/get-started/codelab/) on how to create a new project step by step.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/iik1kh354fx05xw/ff_2_9dljl4hgqq.webp?thumb=)
## 3\. Create App in iTunes Connect 
If you are not familiar with iTunes Connect, check out [this article](https://clearbridgemobile.com/how-to-submit-an-app-to-the-app-store/) for getting started and setting up your first app for the App Store.
## 4\. Create App in Google Play 
Setting up an app in the Google Play Console can be tricky, make sure to check out the [official reference](https://support.google.com/googleplay/android-developer/answer/113469?hl=en-GB) and [this guide](https://medium.com/mindorks/upload-your-first-android-app-on-play-store-step-by-step-ee0de9123ac0) if you are having trouble.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/3q8a93oi3ligf6k/ff_3_1z55io2svg.webp?thumb=)
## 5\. Navigate to Project > ios and Setup Fastlane 
[Reference](https://docs.fastlane.tools/getting-started/ios/setup/)
## 6\. Navigate to Project > android and Setup Fastlane 
[Reference](https://docs.fastlane.tools/getting-started/android/setup/)
## 7\. Update Fastlane Fastfiles for iOS and Android and Change accordingly for each platform 
* Make sure to change "YOUR PROJECT PATH" to the path to your project in Finder.
* Only copy the correct platform code for each Fastfile. For example, `default_platform(:ios)` for iOS and \`default\_platform(:android)1st for Android.
```
update_fastlane
default_platform(:ios)
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
increment_build_number(xcodeproj: "Runner.xcodeproj")
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
desc "Push a new release build to the App Store"
lane :release do
increment_build_number(xcodeproj: "Runner.xcodeproj")
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
upload_to_app_store(submit_for_review: true,
automatic_release: true,
skip_screenshots: true,
force: true,
skip_waiting_for_build_processing: true)
end
end
//YOUR PROJECT PATH > android > fastlane > Fastfile
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Submit a new Build to Beta"
lane :beta do
gradle(task: 'clean')
increment_version_code
sh "cd YOUR PROJECT PATH && flutter build apk"
upload_to_play_store(
track: 'beta',
apk: '../build/app/outputs/apk/release/app-release.apk',
skip_upload_screenshots: true,
skip_upload_images: true
)
# crashlytics
end
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: 'clean')
increment_version_code
sh "cd YOUR PROJECT PATH && flutter build apk"
upload_to_play_store(
track: 'production',
apk: '../build/app/outputs/apk/release/app-release.apk',
skip_upload_screenshots: true,
skip_upload_images: true
)
end
end
```
* For Android `increment_version_code` install here.
Sometimes it will fail and you will need to run:
`bundle exec fastlane add_plugin increment_version_code`
* For iOS `increment_build_number` set up Generic Versioning by enabling the agvtool.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/92iv8ah5ixw392g/ff_4_39myyg3bzm.gif?thumb=)
[Source](https://medium.com/xcblog/agvtool-automating-ios-build-and-version-numbers-454cab6f1bbe)
## 8\. Metadata (Optional) 
* For iOS you can have Fastlane download all your apps existing metadata including screenshots from iTunes Connect. In terminal navigate to the project and run.
`fastlane deliver download_metadata && fastlane deliver download_screenshots`
* For Android you can use [Fastlane Supply](https://docs.fastlane.tools/actions/supply/).
## 9\. Open Automator 
Right now everything is working just by the command line. If you navigate to your project in terminal by adding "cd " and dragging in the project folder and hitting Enter, you can type "cd ios && fastlane beta" or "cd android && fastlane beta" and both will run fastlane.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7342x4kyyu21oju/ff_5_1esbyy658n.gif?thumb=)
If you want to be able to submit your app to Google Play and the App Store with one click we will be using [Automator](http://www.applegazette.com/os-x/getting-started-automator-workflows-mac/). Create a new Automator Application. And Search for "Ask for Confirmation" and "Run AppleScript" and drag in.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/b4o4x6v458qa7a5/ff_6_krrntoi3wl.webp?thumb=)
Here is the Script for beta and release. You will need to create a Automator Application for both Beta and Release for each app you want automated. Save it where ever you want and create an Alias to be but on the Desktop.
* Make sure to change **YOUR PROJECT PATH** to the path to your project in Finder
Hint: I have my automator application save in the Github Repo of my project for versioning and easy access for different projects.
```
//Beta
on run {input, parameters}
tell application "Terminal"
activate
do script "cd YOUR PROJECT PATH/android && fastlane beta && cd YOUR PROJECT PATH/ios && fastlane beta"
end tell
tell application "System Events"
try
set visible of application process "Terminal" to false
end try
end tell
end run
//Release
on run {input, parameters}
tell application "Terminal"
activate
do script "cd YOUR PROJECT PATH/android && fastlane deploy && cd YOUR PROJECT PATH/ios && fastlane release"
end tell
tell application "System Events"
try
set visible of application process "Terminal" to false
end try
end tell
end run
```
## 10\. Try It Out! 
Everything should be working now. If you double click on the automator application you should get a confirmation pop up to release the app. The Script will run terminal in the background and you can stay focused on developing awesome flutter applications. If you want to see the progress on fastlane uploading your apps you can click on the terminal icon and the terminal window will reappear. Thanks for reading and please reach out for any questions you have!
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
---
name: how-to-manage-multiple-flutter-versions-with-git-worktrees-and-z
description: Manage multiple Flutter versions efficiently using Git worktrees, eliminating the need for external version managers like FVM.
metadata:
url: https://rodydavis.com/posts/flutter/git-worktree-channels
last_modified: Tue, 03 Feb 2026 20:04:34 GMT
---
# How to Manage Multiple Flutter Versions with Git Worktrees and ZSH
If you have been using [Flutter](https://flutter.dev/) for any length of time then you probably have needed to use multiple flutter versions across multiple projects.
In the past I used to use [FVM](https://fvm.app/) (Flutter Version Management) which is similar to [NVM](https://github.com/nvm-sh/nvm) (Node Version Manager) in the JS world.
I wanted a solution that only relied on [Git](https://git-scm.com/), and started using [worktrees](https://git-scm.com/docs/git-worktree) to manage the Flutter channels.
## Download the SDK 
Check out the flutter repo in a known directory, in this case I will download it to `~/Developer/`:
```
git clone https://github.com/flutter/flutter ~/Developer/flutter
```
## Add Flutter Channels 
Now we can add the branches we want to track:
```
cd ~/Developer/flutter
git checkout origin/dev
git worktree add ../flutter-stable stable
git worktree add ../flutter-beta beta
git worktree add ../flutter-master master
```
We need to checkout the dev channel to allow us to create the worktree for the master branch. This will keep the `flutter` directory separate so we can work on PRs and apply local changes.
After this runs we should have 4 directories: `flutter`, `flutter-master`, `flutter-beta` and `flutter-stable`.
## Add ZSH Alias for each Channel 
Now we need a way to reference each SDK on the fly with an [alias in ZSH](https://github.com/rothgar/mastering-zsh/blob/master/docs/helpers/aliases.md). Add the following to `~/.zshrc`:
```
alias flutter-master='~/Developer/flutter-master/bin/flutter'
alias dart-master='~/Developer/flutter-master/bin/dart'
alias flutter-beta='~/Developer/flutter-beta/bin/flutter'
alias dart-beta='~/Developer/flutter-beta/bin/dart'
alias flutter-stable='~/Developer/flutter-stable/bin/flutter'
alias dart-stable='~/Developer/flutter-stable/bin/dart'
```
## Conclusion 
After reopening the terminal, you can verify it is working by running (or add any channel we added above):
```
flutter-master doctor
dart-master --version
flutter-stable doctor
dart-stable --version
```
You can update any of the channels by navigating to the directory of the worktree for the given channel and pulling changes like any other Git repo.
```
cd ~/Developer/flutter-master
git checkout origin/master
```
Git worktrees are just a way to checkout multiple branches as separate folders instead of needing to stash changes.
+696
View File
@@ -0,0 +1,696 @@
---
name: host-your-flutter-project-as-a-rest-api
description: Learn how to structure a Flutter project to reuse models and business logic across iOS, Android, Web, desktop platforms, and a REST API deployable to Google Cloud Run, enabling a single codebase for both client and server.
metadata:
url: https://rodydavis.com/posts/host-flutter-rest-api
last_modified: Tue, 03 Feb 2026 20:04:22 GMT
---
# Host your Flutter Project as a REST API
After you build your flutter project you may want to reuse the models and business logic from your lib folder. I will show you how to go about setting up the project to have iOS, Android, Web, Windows, MacOS, Linux and a REST API interface with one project. The REST API can also be deploy to Google Cloud Run for Dart everywhere.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4uq151209yp9i27/r_1_gl7e2erkta.gif?thumb=)
> One Codebase for Client and Sever.
This will allow you to expose your Dart models as a REST API and run your business logic from your lib folder while the application runs the models as they are. [Here](https://github.com/rodydavis/shared_dart) is the final project.
## Setting Up 
As with any Flutter project I am going to assume that you already have [Flutter](https://flutter.dev/) installed on your machine and that you can create a project. This is a intermediate level difficulty so read on if you are up to the challenge. You will also need to know the basics of [Docker](https://www.docker.com/).
## Why one project? 
It may not be obvious but when building complex applications you will at some point have a server and an application that calls that server. [Firebase](https://firebase.google.com/) is an excellent option for doing this and I use it in almost all my projects. [Firebase Functions](https://firebase.google.com/products/functions/) are really powerful but you are limited by Javascript or Typescript. What if you could use the same packages that you are using in the Flutter project, or better yet what if they both used the same?
![](https://rodydavis.com/_/../api/files/pbc_2708086759/102isxx047c7165/r_2_t53hqci5ox.gif?thumb=)
When you have a server project and a client project that communicate over a rest api or client sdk like Firebase then you will run into the problem that the server has models of objects stored and the client has models of the objects that are stored. This can lead to a serious mismatch when it changed without you knowing. GraphQL helps a lot with this since you define the model that you recieve. This approach allows your business logic to be always up to date for both the client and server.
## Client Setup 
The first step is to just build your application. The only difference that we will make is keeping the UI and business logic separate. When starting out with Flutter it can be very easy to throw all the logic into the screen and calling setState when the data changes. Even the application when creating a new Flutter project does this. That's why [choosing a state management solution](https://flutter.dev/docs/development/data-and-backend/state-mgmt/options) is so important.
To make things clean and concise we will make 2 folders in our lib folder.
* ui for all Flutter Widgets and Screens
* src for all business logic, classes, models and utility functions
This will leave us with main.dart being only the entry point into our client application.
```
import 'package:flutter/material.dart';
import 'plugins/desktop/desktop.dart';
import 'ui/home/screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: HomeScreen(),
);
}
}
```
Lets Start by making a tab bar for the 2 screens. Create a file in the folder ui/home/screen.dart and add the following:
```
import 'package:flutter/material.dart';
import '../counter/screen.dart';
import '../todo/screen.dart';
class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: <Widget>[
CounterScreen(),
TodosScreen(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (val) {
if (mounted)
setState(() {
_currentIndex = val;
});
},
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: Icon(Icons.add),
title: Text('Counter'),
),
BottomNavigationBarItem(
icon: Icon(Icons.list),
title: Text('Todos'),
),
],
),
);
}
}
```
This is just a basic screen and should look very normal.
### Counter Example
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4pyuti80j9deb0w/r_3_pwj25p3nfr.webp?thumb=)
Now create a file ui/counter/screen.dart and add the following:
```
import 'package:flutter/material.dart';
import 'package:shared_dart/src/models/counter.dart';
class CounterScreen extends StatefulWidget {
@override
_CounterScreenState createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
CounterModel _counterModel = CounterModel();
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counterModel.add();
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// Here we take the value from the MyCounterPage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text('Counter Screen'),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Invoke "debug painting" (press "p" in the console, choose the
// "Toggle Debug Paint" action from the Flutter Inspector in Android
// Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
// to see the wireframe for each widget.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'${_counterModel.count}',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
```
This is the default counter app you get when you create a Flutter application but with one change, it uses `CounterModel` to hold the logic.
Create the counter model at src/models/counter.dart and add the following:
```
class CounterModel {
CounterModel();
int _count = 0;
int get count => _count;
void add() => _count++;
void subtract() => _count--;
void set(int val) => _count = val;
}
```
As you can see it is really easy to expose only what we want to while still having complete flexibility. You could use provider here if you choose, or even bloc and/or streams.
### Todo Example
![](https://rodydavis.com/_/../api/files/pbc_2708086759/eir0lt42913ca5d/r_4_4cia0ajhj0.webp?thumb=)
Lets create a file at ui/todos/screen.dart and add the following:
```
import 'package:flutter/material.dart';
import '../../src/classes/todo.dart';
import '../../src/models/todos.dart';
class TodosScreen extends StatefulWidget {
@override
_TodosScreenState createState() => _TodosScreenState();
}
class _TodosScreenState extends State<TodosScreen> {
final _model = TodosModel();
List<ToDo> _todos;
@override
void initState() {
_model.getList().then((val) {
if (mounted)
setState(() {
_todos = val;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todos Screen'),
),
body: Builder(
builder: (_) {
if (_todos != null) {
return ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final _item = _todos[index];
return ListTile(
title: Text(_item.title),
subtitle: Text(_item.completed ? 'Completed' : 'Pending'),
);
},
);
}
return Center(
child: CircularProgressIndicator(),
);
},
),
);
}
}
```
You will see that we have the logic in TodosModel and uses the class ToDo for toJson and fromJson.
Create a file at the location src/classes/todo.dart and add the following:
```
// To parse this JSON data, do
//
// final toDo = toDoFromJson(jsonString);
import 'dart:convert';
List<ToDo> toDoFromJson(String str) => List<ToDo>.from(json.decode(str).map((x) => ToDo.fromJson(x)));
String toDoToJson(List<ToDo> data) => json.encode(List<dynamic>.from(data.map((x) => x.toJson())));
class ToDo {
int userId;
int id;
String title;
bool completed;
ToDo({
this.userId,
this.id,
this.title,
this.completed,
});
factory ToDo.fromJson(Map<String, dynamic> json) => ToDo(
userId: json["userId"],
id: json["id"],
title: json["title"],
completed: json["completed"],
);
Map<String, dynamic> toJson() => {
"userId": userId,
"id": id,
"title": title,
"completed": completed,
};
}
```
and create the model src/models/todo.dart and add the following:
```
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_dart/src/classes/todo.dart' as t;
class TodosModel {
final kTodosUrl = '[https://jsonplaceholder.typicode.com/todos'](https://jsonplaceholder.typicode.com/todos');
Future<List<t.ToDo>> getList() async {
final _response = await http.get(kTodosUrl);
if (_response != null) {
final _todos = t.toDoFromJson(_response.body);
if (_todos != null) {
return _todos;
}
}
return [];
}
Future<t.ToDo> getItem(int id) async {
final _response = await http.get('$kTodosUrl/$id');
if (_response != null) {
final _todo = t.ToDo.fromJson(json.decode(_response.body));
if (_todo != null) {
return _todo;
}
}
return null;
}
}
```
Here we just get dummy data from a url that emits json and convert them to our classes. This is an example I want to show with networking. There is only one place that fetches the data.
### Run the Project (Web)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/r7724ye890a5030/r_5_cpppxwnavj.webp?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/fah27s6gg8isui1/r_6_7bhqlwxjfs.webp?thumb=)
As you can see when you run your project on chrome you will get the same application that you got on mobile. Even the networking is working in the web. You can call the model and retrieve the list just like you would expect.
## Server Setup
> Now time for the magic..
In the root of the project folder create a file Dockerfile and add the following:
```
# Use Google's official Dart image.
# [https://hub.docker.com/r/google/dart-runtime/](https://hub.docker.com/r/google/dart-runtime/)
FROM google/dart-runtime
```
Create another file at the root called service.yaml and add the following:
```
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: PROJECT_NAME
namespace: default
spec:
template:
spec:
containers:
- image: docker.io/YOUR_DOCKER_NAME/PROJECT_NAME
env:
- name: TARGET
value: "PROJECT_NAME v1"
```
Replace PROJECT\_NAME with your project name, mine is shared-dart for this example.
You will also need to replace YOUR\_DOCKER\_NAME with your docker username so the container can be deployed correctly.
Update your pubspec.yaml with the following:
```
name: shared_dart
description: A new Flutter project.
publish_to: none
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
shelf: ^0.7.3
cupertino_icons: ^0.1.2
http: ^0.12.0+2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
```
The important package here is shelf as it allows us to run a http server with dart.
Create a folder in the root of the project called bin then add a file server.dart and replace it with the following:
```
import 'dart:io';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'src/routing.dart';
void main() {
final handler = const shelf.Pipeline()
.addMiddleware(shelf.logRequests())
.addHandler(RouteUtils.handler);
final port = int.tryParse(Platform.environment['PORT'] ?? '8080');
final address = InternetAddress.anyIPv4;
io.serve(handler, address, port).then((server) {
server.autoCompress = true;
print('Serving at [http://${server.address.host}:${server.port}'](http://${server.address.host}:${server.port}'));
});
}
```
This will tell the container what port to listen for and how to handle the requests.
Create a folder src in the bin folder and add a file routing.dart and replace the contents with the following:
```
import 'dart:async';
import 'package:shelf/shelf.dart' as shelf;
import 'controllers/index.dart';
import 'result.dart';
class RouteUtils {
static FutureOr<shelf.Response> handler(shelf.Request request) {
var component = request.url.pathSegments.first;
var handler = _handlers(request)[component];
if (handler == null) return shelf.Response.notFound(null);
return handler;
}
static Map<String, FutureOr<shelf.Response>> _handlers(
shelf.Request request) {
return {
'info': ServerResponse('Info', body: {
"version": 'v1.0.0',
"status": "ok",
}).ok(),
'counter': CounterController().result(request),
'todos': TodoController().result(request),
};
}
}
```
There is still nothing imported from our main project but you will start to see some similarities. Here we specify controllers for todos and counter url paths.
```
'counter': CounterController().result(request),
'todos': TodoController().result(request),
```
that means any url with the following:[https://mydomain.com/todos](https://mydomain.com/todos) , [https://mydomain.com/todos](https://mydomain.com/todos)/1
will get routed to the TodoController to handle the request.
> This is also the first time I found out about FutureOr. It allows you to return a sync or async function.
And important part about build a REST API is having a consistent response body, so here we can create a wrapper that adds fields we always want to return, like the status of the call, a message and the body.
Create a file at src/result.dart and add the following:
```
import 'dart:convert';
import 'package:shelf/shelf.dart' as shelf;
class ServerResponse {
final String message;
final dynamic body;
final StatusType type;
ServerResponse(
this.message, {
this.type = StatusType.success,
this.body,
});
Map<String, dynamic> toJson() {
return {
"status": type.toString().replaceAll('StatusType.', ''),
"message": message,
"body": body ?? '',
};
}
String toJsonString() {
return json.encode(toJson());
}
shelf.Response ok() {
return shelf.Response.ok(
toJsonString(),
headers: {
'Content-Type': 'application/json',
},
);
}
}
enum StatusType { success, error }
abstract class ResponseImpl {
Future<shelf.Response> result(shelf.Request request);
}
```
This will always return json and the fields that we want to show. You could also include your paging meta data here.
Create a file in at the location src/controllers/counter.dart and add the following:
```
import 'package:shared_dart/src/models/counter.dart';
import 'package:shelf/shelf.dart' as shelf;
import '../result.dart';
class CounterController implements ResponseImpl {
const CounterController();
@override
Future<shelf.Response> result(shelf.Request request) async {
final _model = CounterModel();
final _params = request.url.queryParameters;
if (_params != null) {
final _val = int.tryParse(_params['count'] ?? '0');
_model.set(_val);
} else {
_model.add();
}
return ServerResponse('Info', body: {
"counter": _model.count,
}).ok();
}
}
```
You will see the import to the lib folder of the root project. Since it shares the pubspec.yaml all the packages can be shared. You can import the CounterModel that we created earlier.
Create a file in at the location src/controllers/todos.dart and add the following:
```
import 'package:shared_dart/src/models/todos.dart';
import 'package:shelf/src/request.dart';
import 'package:shelf/src/response.dart';
import '../result.dart';
class TodoController implements ResponseImpl {
@override
Future<Response> result(Request request) async {
final _model = TodosModel();
if (request.url.pathSegments.length > 1) {
final _id = int.tryParse(request.url.pathSegments[1] ?? '1');
final _todo = await _model.getItem(_id);
return ServerResponse('Todo Item', body: _todo).ok();
}
final _todos = await _model.getList();
return ServerResponse(
'List Todos',
body: _todos.map((t) => t.toJson()).toList(),
).ok();
}
}
```
Just like before we are importing the TodosModel model from the lib folder.
For convenience add a file at the location src/controllers/index.dart and add the following:
```
export 'counter.dart';
export 'todo.dart';
```
This will make it easier to import all the controllers.
## Run the Project (Server) 
If you are using [VSCode](https://code.visualstudio.com/) then you will need to update your launch.json with the following:
```
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: [https://go.microsoft.com/fwlink/?linkid=830387](https://go.microsoft.com/fwlink/?linkid=830387)
"version": "0.2.0",
"configurations": [
{
"name": "Client",
"request": "launch",
"type": "dart",
"program": "lib/main.dart"
},
{
"name": "Server",
"request": "launch",
"type": "dart",
"program": "bin/server.dart"
}
]
}
```
Now when you hit run with Server selected you will see the output:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/284wx84cvj1o5ab/r_7_fph074ovtl.webp?thumb=)
You can navigate to this in a browser but you can also work with this in [Postman](https://www.getpostman.com/).
![](https://rodydavis.com/_/../api/files/pbc_2708086759/lhu678e1j962725/r_8_3jwm0aouc0.webp?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/8wpb4q7m67mrk79/r_9_ry6xjpbpbx.webp?thumb=)
Just by adding to the url todos and todos/1 it will return different responses.
For the counter model we can use query parameters too!
![](https://rodydavis.com/_/../api/files/pbc_2708086759/66rky1456ce104z/r_10_ec9e6yhbxc.webp?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/h6k76rdm32ig7bx/r_11_puha19uiln.webp?thumb=)
Just by adding ?count=22 it will update the model with the input.
> Keep in mind this is running your Dart code from you lib folder in your Flutter project without needing the Flutter widgets!
As a side benefit we can also run this project on Desktop. Check out the final project for the desktop folders needed from [Flutter Desktop Embedding](https://github.com/google/flutter-desktop-embedding).
![](https://rodydavis.com/_/../api/files/pbc_2708086759/khoz26gjf79b6ro/r_12_2rv9jnqeo0.webp?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/w65r9llws7e710z/r_13_k7amx8s1d5.webp?thumb=)
## Conclusion 
Now if you wanted to deploy the container to Cloud Run you could with the following command:
gcloud builds submit — tag gcr.io/YOUR\_GOOGLE\_PROJECT\_ID/PROJECT\_NAME .
Replace PROJECT\_NAME with your project name, mine is shared-dart for this example.
You will also need to replace YOUR\_GOOGLE\_PROJECT\_ID with your Google Cloud Project ID. You can create one [here](https://cloud.google.com/cloud-build/docs/quickstart-docker).
Again the final project source code is [here](https://github.com/rodydavis/shared_dart). Let me know your thoughts!
+934
View File
@@ -0,0 +1,934 @@
---
name: building-a-html-element-sandbox-with-lit
description: Learn how to build a Lit web component to create a dynamic HTML element sandbox with live updates, perfect for experimenting with and showcasing web components.
metadata:
url: https://rodydavis.com/posts/html-code-sandbox
last_modified: Tue, 03 Feb 2026 20:04:27 GMT
---
# Building a HTML Element Sandbox with Lit
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a HTML Element sandbox that can be used to update a live component.
> **TLDR** The final source [here](https://github.com/rodydavis/html-element-sandbox) and an online [demo](https://rodydavis.github.io/html-element-sandbox/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `html-element-sandbox` and now open the project in vscode and install the dependencies:
```
cd html-element-sandbox
npm i lit
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/html-element-sandbox/",
build: {
lib: {
entry: "src/html-element-sandbox.ts",
formats: ["es"],
},
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTML Element Sandbox</title>
<script type="module" src="/src/html-element-sandbox.ts"></script>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
html-element-sandbox {
display: block;
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<html-element-sandbox>
<template>
<button
class="button"
knob-text="label"
knob-css-color="fg-color"
knob-css-background-color="bg-color"
knob-css-border-radius="shape"
knob-css-font-size="text-font-size"
knob-css-padding="padding"
knob-css---shadow-color="shadow"
>
My Button
</button>
<style>
.button {
--shadow-color: #000;
--elevation: 3px;
display: block;
width: 100%;
height: 100%;
border: none;
background-color: transparent;
cursor: pointer;
box-shadow: 0 var(--elevation) calc(var(--elevation) * 2) 0 var(--shadow-color);
}
</style>
</template>
<div slot="knobs">
<knob-string id="label" name="Label" value="BUTTON"></knob-string>
<knob-group name="Style" expanded>
<knob-color
id="bg-color"
name="Background Color"
value="#ff0000"
></knob-color>
<knob-color
id="fg-color"
name="Foreground Color"
value="#ffffff"
></knob-color>
<knob-color
id="shadow"
name="Shadow Color"
value="#000000"
></knob-color>
<knob-number
id="text-font-size"
name="Font Size"
value="16"
suffix="px"
></knob-number>
<knob-number
id="shape"
name="Border Radius"
value="100"
suffix="px"
></knob-number>
<knob-number
id="padding"
name="Padding"
value="12"
suffix="px"
></knob-number>
</knob-group>
</div>
</html-element-sandbox>
</body>
</html>
```
Here we are defining the markup we want to use in our sandbox. We are using the `html-element-sandbox` component to create a sandbox for our HTML Element.
```
<html-element-sandbox></html-element-sandbox>
```
Each knob is defined by an `id` and a `name`. The `id` is used to identify the knob in the `template` and the `name` is used to display the knob in the UI.
```
<knob-number
id="shape"
name="Border Radius"
value="30"
suffix="px"
></knob-number>
```
For the element inside the `template` we use `knob-*` attributes to get the values of the knobs and set the attributes, CSS style or text content.
```
<!-- Attributes -->
<div knob-attr-disabled="disabled"></div>
<knob-boolean id="disabled" name="Disable" value="false"></knob-boolean>
<!-- CSS Properties -->
<div knob-css-color="fg-color" knob-css-background-color="bg-color"></div>
<knob-color id="bg-color" name="Background Color" value="#ff0000"></knob-color>
<knob-color id="fg-color" name="Foreground Color" value="#ffffff"></knob-color>
<!-- Text Content -->
<div knob-text="content"></div>
<knob-string id="content" name="Text Content" value="Hello World"></knob-string>
```
A single knob can point to multiple elements:
```
<html-element-sandbox>
<template>
<div id="buttons">
<button
knob-text="label"
knob-css-color="fg-color"
knob-css-background-color="bg-color"
knob-css-border-radius="shape"
knob-css-font-size="text-font-size"
knob-css-padding="padding"
knob-css---shadow-color="shadow"
knob-attr-raised="raised"
knob-attr-contenteditable="contenteditable"
></button>
<mwc-button
knob-attr-label="label"
knob-css---mdc-theme-on-primary="fg-color"
knob-css---mdc-theme-primary="bg-color"
knob-css---mdc-shape-small="shape"
knob-attr-raised="raised"
label="My Button"
></mwc-button>
</div>
<script type="module">
import "https://www.unpkg.com/@material/[email protected]/mwc-button.js?module";
</script>
<style>
button {
--shadow-color: #000;
--elevation: 3px;
display: block;
border: none;
background-color: transparent;
cursor: pointer;
box-shadow: 0 var(--elevation) calc(var(--elevation) * 2) 0 var(--shadow-color);
}
mwc-button {
--mdc-theme-on-primary: #000;
--mdc-theme-primary: #fff;
--mdc-shape-small: none;
}
#buttons {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
}
</style>
</template>
<div slot="knobs">
<knob-string id="label" name="Label" value="BUTTON"></knob-string>
<knob-group name="Style" expanded>
<knob-color
id="bg-color"
name="Background Color"
value="#ff0000"
></knob-color>
<knob-color
id="fg-color"
name="Foreground Color"
value="#ffffff"
></knob-color>
<knob-color id="shadow" name="Shadow Color" value="#000000"></knob-color>
<knob-number
id="text-font-size"
name="Font Size"
value="16"
suffix="px"
></knob-number>
<knob-number
id="shape"
name="Border Radius"
value="30"
suffix="px"
></knob-number>
<knob-number
id="padding"
name="Padding"
value="12"
suffix="px"
></knob-number>
</knob-group>
<knob-group name="Attributes" expanded>
<knob-boolean id="raised" name="Raised" value="false"></knob-boolean>
<knob-list id="contenteditable" name="Content Editable" value="false">
<option value="true">true</option>
<option value="false">false</option>
</knob-list>
</knob-group>
</div>
</html-element-sandbox>
```
A `style` and `script` can be added to load extra content into the sandbox (e.g. a `script` to load a web component).
## Web Component 
Before we update our component we need to rename `my-element.ts` to `html-element-sandbox.ts`
Open up `html-element-sandbox.ts` and update it with the following:
```
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./knobs/boolean";
import "./knobs/string";
import "./knobs/number";
import "./knobs/color";
import "./knobs/list";
import "./knobs/group";
import { KnobValue } from "./knobs/base";
import { BooleanKnob } from "./knobs/boolean";
export const tagName = "html-element-sandbox";
@customElement(tagName)
export class HTMLElementSandbox extends LitElement {
static styles = css`
main {
--knobs-width: 300px;
--code-height: calc(100% * 0.4);
--mobile-height: 350px;
display: grid;
grid-template-areas: "preview" "knobs" "code";
grid-template-columns: 100%;
grid-template-rows: var(--mobile-height) auto auto;
height: 100%;
width: 100%;
}
#preview {
grid-area: preview;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid #272727;
background-color: whitesmoke;
}
@media (min-width: 600px) {
main {
grid-template-areas:
"preview knobs"
"code knobs";
grid-template-columns: calc(100% - var(--knobs-width)) var(
--knobs-width
);
grid-template-rows: calc(100% - var(--code-height)) var(--code-height);
}
#preview {
border-bottom: none;
}
slot[name="knobs"] {
overflow-y: auto;
}
pre {
overflow-y: scroll;
}
}
section {
flex: 1;
}
slot[name="knobs"] {
grid-area: knobs;
display: flex;
flex-direction: column;
border-left: 1px solid #000;
}
slot[name="code"] {
grid-area: code;
}
pre {
margin: 0;
font-family: Monaco, Courier, monospace;
padding: 16px;
background-color: #272727;
color: #c8c8c8;
}
code {
font-size: 0.8rem;
white-space: pre-wrap;
}
`;
@state() code = "";
render() {
return html`<main>
<section id="preview">
<slot></slot>
</section>
<slot name="knobs"> </slot>
<slot name="code">
<pre><code>${this.code}</code></pre>
</slot>
</main>`;
}
firstUpdated() {
this.init();
}
init() {
this.setUpKnobs();
this.code = this.getCode();
// Update the code every time a knob value changes
this.addEventListener("value", () => {
this.code = this.getCode();
});
}
setUpKnobs() {
const root = this.shadowRoot!;
const preview = root.getElementById("preview")!;
const template = this.querySelector("template");
if (template) {
const div = document.createElement("div");
div.appendChild(template.content.cloneNode(true));
// Text Knobs (knob-text)
div.querySelectorAll("[knob-text]").forEach((el) => {
const elemId = el.getAttribute("knob-text") || "";
const knob = this.querySelector(`#${elemId}`);
if (knob && knob instanceof KnobValue) {
knob.addEventListener("value", () => {
const val = knob.value;
el.textContent = val;
});
el.addEventListener("input", (e) => {
const target = e.target as HTMLElement;
knob.value = target.textContent;
});
knob.init();
}
});
div.querySelectorAll("*").forEach((el) => {
const attrs = el.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
const attrName = attr.name;
// CSS Knobs (knob-css-*)
if (attrName.startsWith("knob-css-")) {
const cssKey = attrName.replace("knob-css-", "");
const knob = this.querySelector(`#${attr.value}`);
if (
knob &&
knob instanceof KnobValue &&
el instanceof HTMLElement
) {
knob.addEventListener("value", () => {
const val = knob.value;
if (knob.hasAttribute("suffix")) {
// Add suffix to the value (e.g. px)
el.style.setProperty(
cssKey,
val + knob.getAttribute("suffix")
);
} else {
// No suffix, just set the value
el.style.setProperty(cssKey, val);
}
});
knob.init();
}
}
// Attribute Knobs (knob-attr-*)
if (attrName.startsWith("knob-attr-")) {
const attrKey = attrName.replace("knob-attr-", "");
const knob = this.querySelector(`#${attr.value}`);
if (knob && knob instanceof KnobValue) {
knob.addEventListener("value", () => {
const val = knob.value;
if (knob instanceof BooleanKnob) {
if (val) {
// <div hidden>
el.setAttribute(attrKey, "");
} else {
// <div>
el.removeAttribute(attrKey);
}
} else {
// <div value="foo">
el.setAttribute(attrKey, val);
}
});
knob.init();
}
}
}
});
preview.appendChild(div);
}
}
getCode() {
const root = this.shadowRoot!;
const preview = root.getElementById("preview")!;
if (preview.children.length > 0) {
const child = preview.children[1];
if (child && child.children.length > 0) {
const lines = this.elementToString(child.children[0]);
// Trim empty lines
const linesArray = lines.split("\n");
const filteredLines = linesArray.filter((line) => line.trim() !== "");
return filteredLines.join("\n");
}
}
return "";
}
elementToString(node: Element) {
const sb: string[] = [];
const tag = node.tagName.toLowerCase();
sb.push(`<${tag}`);
const attrs = node.attributes;
// Add attributes
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
if (attr.name.startsWith("knob-")) continue;
// If the attribute is a boolean attribute, add it only if it's true
if (attr.value === "") {
sb.push(` ${attr.name}`);
} else {
sb.push(` ${attr.name}="${attr.value}"`);
}
}
sb.push(">");
if (node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
// If the child is a text node, add the content
if (child instanceof Text) {
sb.push(child.textContent || "");
} else if (child instanceof Element) {
// If the child is an element, recurse
sb.push(this.elementToString(child));
}
}
}
sb.push(`</${tag}>`);
return sb.join("\n");
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: HTMLElementSandbox;
}
}
```
## Knobs 
First let up create a base class that will be used to create all other knobs. Create `src/knobs/base.ts` and update with with the following:
```
import { css, html, LitElement, TemplateResult } from "lit";
import { property } from "lit/decorators.js";
export class Knob extends LitElement {
constructor(name: string) {
super();
this.name = name;
}
@property() name: string;
}
export abstract class KnobValue<T> extends Knob {
constructor(name: string, public val: T) {
super(name);
this._value = val;
this.notify();
}
static styles = css`
.knob {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem;
}
.knob label {
flex: 1;
}
`;
_value: T;
get value(): T {
return this._value;
}
set value(value: T) {
this._value = value;
this.notify();
}
notify() {
const value = this.value;
this.onValue(value);
this.dispatchEvent(
new CustomEvent("value", {
detail: value,
bubbles: true,
composed: true,
})
);
this.requestUpdate();
}
render() {
return html`
<div class="knob">
<label>${this.name}</label>
${this.buildInput()}
</div>
`;
}
onValue(_val: T) {}
init() {
this.notify();
}
resolveValue(val: T) {
return val;
}
abstract buildInput(): TemplateResult;
}
```
### Boolean knob 
Create `src/knobs/boolean.ts` and update with the following:
```
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-boolean";
@customElement(tagName)
export class BooleanKnob extends KnobValue<boolean> {
constructor(name: string, val: boolean) {
super(name, val);
}
static styles = KnobValue.styles;
@property({
type: Boolean,
attribute: "value",
})
_value = false;
buildInput() {
return html`<input
type="checkbox"
.checked=${this.resolveValue(this.value)}
@change=${this.onChange}
/>`;
}
onChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.checked;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: BooleanKnob;
}
}
```
### Number Knob 
Create `src/knobs/number.ts` and update with the following:
```
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-number";
@customElement(tagName)
export class NumberKnob extends KnobValue<number> {
constructor(name: string, val: number) {
super(name, val);
}
static styles = KnobValue.styles;
@property({
type: Number,
attribute: "value",
converter: {
fromAttribute: (val: string) => parseFloat(val),
toAttribute: (val: boolean) => val.toString(),
},
})
_value = 0;
buildInput() {
return html`<input
type="number"
.valueAsNumber=${this.resolveValue(this.value)}
@change=${this.onChange}
/>`;
}
onChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.valueAsNumber;
}
resolveValue(val: number): number {
return val;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: NumberKnob;
}
}
```
### String Knob 
Create `src/knobs/string.ts` and update with the following:
```
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-string";
@customElement(tagName)
export class StringKnob extends KnobValue<string> {
constructor(name: string, val: string) {
super(name, val);
}
static styles = KnobValue.styles;
@property({ type: String, attribute: "value" })
_value = "";
buildInput() {
return html`<input
type="text"
.value=${this.resolveValue(this.value)}
@input=${this.onChange}
/>`;
}
onChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.value;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: StringKnob;
}
}
```
### Color Knob 
Create `src/knobs/color.ts` and update with the following:
```
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { StringKnob } from "./string";
export const tagName = "knob-color";
@customElement(tagName)
export class ColorKnob extends StringKnob {
buildInput() {
return html`<input
type="color"
.value=${this.resolveValue(this.value)}
@input=${this.onChange}
/>`;
}
resolveValue(value: string) {
if (value && value.startsWith("--")) {
const style = getComputedStyle(document.body);
const resolved = style.getPropertyValue(value);
return resolved;
}
return value;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: ColorKnob;
}
}
```
### List Knob 
Create `src/knobs/list.ts` and update with the following:
```
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-list";
@customElement(tagName)
export class ListKnob extends KnobValue<string> {
constructor(name: string, val: string) {
super(name, val);
}
static styles = KnobValue.styles;
@property({
type: String,
attribute: "value",
})
_value = "";
buildInput() {
const options = this.getOptions();
return html`<select @change=${this.onChange}>
${Array.from(options).map(
(option) =>
html`<option
value=${option.value}
.selected=${this.value === option.value}
>
${option.textContent}
</option>`
)}
</select>`;
}
getOptions() {
const options = this.querySelectorAll(
"option"
) as NodeListOf<HTMLOptionElement>;
return Array.from(options);
}
onChange(e: Event) {
const target = e.target as HTMLSelectElement;
this.value = target.value;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: ListKnob;
}
}
```
### Group Knob 
Create `src/knobs/group.ts` and update with the following:
```
import { css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Knob } from "./base";
export const tagName = "knob-group";
@customElement(tagName)
export class GroupKnob extends Knob {
constructor(name: string, knobs: Knob[] = []) {
super(name);
this.knobs = knobs;
}
static styles = css`
details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
details summary {
padding: 0.5rem;
}
`;
knobs: Knob[];
@property({ type: Boolean }) expanded = false;
render() {
return html`<details ?open=${this.expanded}>
<summary>${this.name}</summary>
<div class="collection">
<slot></slot>
${this.knobs.map((knob) => html`${knob}`)}
</div>
</details>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: GroupKnob;
}
}
```
## Conclusion 
If everything worked as expected, you should see the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7n1u53jpjl5104w/knobs_1_dvkg6xds73.webp?thumb=)
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
The source for this example can be found [here](https://github.com/rodydavis/html-element-sandbox).
File diff suppressed because one or more lines are too long
+914
View File
@@ -0,0 +1,914 @@
---
name: 2d-or-3d-force-graph-with-lit
description: Learn how to create interactive 2D and 3D force graphs using Lit, a lightweight web component library, with this step-by-step tutorial.
metadata:
url: https://rodydavis.com/posts/lit-force-graph
last_modified: Tue, 03 Feb 2026 20:04:28 GMT
---
# 2D or 3D Force Graph with Lit
In this article we will cover how to create a 2D/3D force graph using [Lit](https://lit.dev/).
> **TLDR** The final source [here](https://github.com/rodydavis/lit-force-graph) and an online [demo](https://rodydavis.github.io/lit-force-graph/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `lit-force-graph` and now open the project in vscode and install the dependencies:
```
cd lit-force-graph force-graph
npm i lit 3d-force-graph
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/lit-force-graph/",
build: {
lib: {
entry: "src/lit-force-graph.ts",
formats: ["es"],
},
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit Force Graph</title>
<script type="module" src="/src/lit-force-graph.ts"></script>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<lit-force-graph>
<script type="application/json">
{
"name": "Lit Force Graph",
"description": "A force graph built with Lit",
"nodes": [
{
"id": "1",
"name": "Node 1"
},
{
"id": "2",
"name": "Node 2"
},
{
"id": "3",
"name": "Node 3"
},
{
"id": "4",
"name": "Node 4"
}
],
"links": [
{
"source": "1",
"target": "2"
},
{
"source": "1",
"target": "3"
},
{
"source": "2",
"target": "3"
},
{
"source": "2",
"target": "4"
},
{
"source": "3",
"target": "4"
},
{
"source": "4",
"target": "1"
}
]
}
</script>
</lit-force-graph>
</body>
</html>
```
We are passing the graph data as JSON here, but we could also set a src attribute pointed to a remote or local file. It is still possible to set the graph data directly on a component.
## Styles 
Create and open the `public/style.css` file and update it with the following:
```
body {
margin: 0;
padding: 0;
overflow: hidden;
font-size: 12px;
font-family: sans-serif;
position: relative;
width: 100%;
height: 100%;
}
lit-force-graph {
width: 100%;
height: 100vh;
}
:root {
--graph-background-color: #eee;
--graph-foreground-color: #000;
--graph-line-color: rgb(90, 90, 90);
--graph-node-color: rgb(218, 14, 14);
}
@media (prefers-color-scheme: dark) {
:root {
--graph-background-color: #000;
--graph-foreground-color: #fafafa;
--graph-line-color: rgb(214, 214, 214);
--graph-node-color: rgb(228, 8, 8);
}
}
```
## Web Component 
Before we update our component we need to rename `my-element.ts` to `lit-force-graph.ts`
Open up `lit-force-graph.ts` and update it with the following:
```
import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
export const tagName = "lit-force-graph";
@customElement(tagName)
export class LitForceGraph extends LitElement {
static styles = css`
:host {
background-color: var(--graph-background-color, #000011);
color: var(--graph-foreground-color, #ffffff);
width: var(--graph-width, 100%);
height: var(--graph-height, 100vh);
}
#graph {
width: 100%;
height: 100%;
width: var(--graph-width, 100%);
height: var(--graph-height, 100vh);
}
#controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 100 !important;
display: flex;
flex-direction: column;
align-items: flex-end;
}
#controls div {
padding: 5px;
}
#info {
position: absolute;
top: 10px;
left: 10px;
z-index: 100 !important;
display: flex;
flex-direction: column;
align-items: flex-start;
}
#tooltips {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
justify-content: center;
}
.node-tooltip {
background-color: var(--graph-foreground-color, #ffffff);
color: var(--graph-background-color, #000011);
border-radius: 5px;
font-size: 12px;
padding: 5px;
opacity: 0.67;
}
#graph-description {
opacity: 0.67;
}
.scene-tooltip {
color: var(--graph-foreground-color, #ffffff);
background-color: transparent;
display: none;
}
`;
@query("#graph") graph!: HTMLElement;
@property() src = "";
@property() mode = "2D";
render() {
return html` <main
accept="application/json"
@drop="${this.onDrop}"
@dragover="${(e: Event) => e.preventDefault()}"
>
<div id="graph"></div>
<div id="controls">
<div>
<label for="render-mode">Render mode</label>
<select id="render-mode" @change=${this.onChangeMode}>
<!-- TODO: Add render options -->
</select>
</div>
</div>
<div id="info">
<!-- TODO: Add labels for graph -->
</div>
<div id="tooltips">
<!-- TODO: Add tooltip for node -->
</div>
</main>`;
}
override async firstUpdated() {
await this.refresh();
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
prefersDark.addEventListener("change", () => {
this.refresh();
});
}
override attributeChangedCallback(
name: string,
_old: string | null,
value: string | null
): void {
if (name === "src" && value) {
this.refresh();
}
if (name === "data" && value) {
this.setData(JSON.parse(value));
}
if (name === "mode" && value) {
this.mode = value;
if (this.data) {
this.setData({ ...this.data! });
}
}
super.attributeChangedCallback(name, _old, value);
}
/**
* Set the graph data and update the renderer
*
* @param data Graph JSON
*/
setData(data: GraphData) {
this.data = data;
// TODO: Render the graph!
}
private async refresh() {
// Get json from script tag
const children = Array.from(this.children);
const elem = children.find((child) => child.tagName === "SCRIPT");
if (elem) {
// Render from script tag contents
if (elem.textContent) {
const data = JSON.parse(elem.textContent);
if (data) this.setData(data);
// Render from script tag src
} else if (elem.hasAttribute("src")) {
const url = elem.getAttribute("src")!;
const data = await fetch(url).then((res) => res.json());
if (data) this.setData(data);
}
} else if (this.src.length > 0) {
// Render from src attribute
const data = await fetch(this.src).then((res) => res.json());
if (data) this.setData(data);
}
}
private onDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
const reader = new FileReader();
reader.onload = () => {
const json = JSON.parse(reader.result as string);
this.data = json;
this.setData(json);
};
reader.readAsText(file);
}
return false;
}
private onChangeMode(e: Event) {
const mode = (e.target as HTMLSelectElement).value;
this.mode = mode;
if (!this.data) return;
this.setData({ ...this.data! });
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-force-graph": LitForceGraph;
}
}
```
Here we are creating the base component and wiring it up to listen for a drop event of JSON, accept the src attribute or script tag with json in the text contents.
The CSS just sets the tooltip at the bottom of the screen, title to the left and the render selection controls to the top right.
With Lit it makes it easy to support multiple ways to set the data of the component.
### Inline
```
<lit-source-graph>
<script type="application/json">
{
"nodes": [],
"links": []
}
</script>
</lit-source-graph>
```
### Lazy Loading
```
<lit-source-graph></lit-source-graph>
<script>
const elem = document.createElement("lit-source-graph");
elem.src = "./graph-data.json";
// Or remote url
elem.src = "https://example.com/graph-data.json";
// Or data from an object
elem.data = { node: [], links: [] };
</script>
```
## Graph Data
Create and open the file `src/classes/graph.ts` and add the following:
```
export class Graph {
private ids = new Set();
private graph: GraphData = {
nodes: [],
links: [],
};
addNode<T = any>(node: GraphNode<T>) {
if (this.ids.has(node.id)) {
return this.graph.nodes.find((n) => n.id === node.id)!;
}
this.ids.add(node.id);
this.graph.nodes.push(node);
return node;
}
addLink<T = any>(link: GraphLink<T>) {
this.graph.links.push(link);
return link;
}
toJSON() {
return this.graph;
}
}
export interface GraphNode<T = any> {
id: string;
name?: string;
group?: string;
value?: T;
}
export interface GraphLink<T = any> {
source: string;
target: string;
name?: string;
value?: T;
}
export interface GraphData<A = any, B = any> {
name?: string;
description?: string;
nodes: GraphNode<A>[];
links: GraphLink<B>[];
}
```
Here we are creating a utility class that can generate the nodes and links while excluding duplicates and returning the graph data.
Create and open the file `src/classes/context.ts` and add the following:
```
import { GraphData, GraphNode } from "./graph";
export interface RenderContext {
data: GraphData;
element: HTMLElement;
onHover: (node?: GraphNode) => void;
}
export type Renderer = (context: RenderContext) => void;
```
Here is the context type that we will use to create the renderers and pass with the data.
## 2D Renderer 
Create and open the file `src/renderers/mode-2d.ts` and add the following:
```
import ForceGraph from "force-graph";
import { RenderContext } from "../classes/context";
export function render(context: RenderContext) {
const graph = ForceGraph();
const style = getComputedStyle(context.element);
const lineColor = style.getPropertyValue("--graph-line-color").trim();
const bgColor = style.getPropertyValue("--graph-background-color").trim();
const fgColor = style.getPropertyValue("--graph-foreground-color").trim();
const nodeColor = style.getPropertyValue("--graph-node-color").trim();
graph(context.element)
.graphData(context.data)
.width(Number(style.width.slice(0, -2)))
.height(Number(style.height.slice(0, -2)))
.cooldownTicks(100)
.backgroundColor(bgColor)
.linkColor(() => lineColor)
.linkWidth(0.2)
.nodeCanvasObject((node: any, ctx, globalScale) => {
// Draw a circle
ctx.beginPath();
const size = 5 / globalScale;
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
// ctx.fillStyle = nodeColor(node, groupColors);
ctx.fillStyle = nodeColor;
ctx.fill();
ctx.lineWidth = 1 / globalScale;
ctx.strokeStyle = lineColor;
ctx.stroke();
if (globalScale >= 4) {
const label = node.name ?? node.id;
const fontSize = 12 / globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(
(n) => n + fontSize * 0.2
); // some padding
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = fgColor;
// Measure text
ctx.fillText(label, node.x + size * 2 + textWidth / 2, node.y);
node.__bckgDimensions = bckgDimensions;
}
})
.onNodeHover((node: any, prev: any) => {
if (node) {
const graphNode = context.data.nodes.find((n) => n.id === node.id);
context.onHover(graphNode);
}
if (prev) {
context.onHover(undefined);
}
});
}
```
Here we are importing the context and creating the boilerplate for the 2D renderer. When the scale is greater than 4 we draw the node name to add a little more detail.
Notice that on node hover we are calling the onHover callback with the hovered node and we are using custom properties to render the colors.
## 3D Renderer 
Create and open the file `src/renderers/mode-3d.ts` and add the following:
```
import ForceGraph from "3d-force-graph";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = ForceGraph({
controlType: "trackball",
rendererConfig: { antialias: true, alpha: true },
});
const style = getComputedStyle(context.element);
const lineColor = style.getPropertyValue("--graph-line-color").trim();
const bgColor = style.getPropertyValue("--graph-background-color").trim();
const nodeColor = style.getPropertyValue("--graph-node-color").trim();
graph(context.element)
.graphData(context.data)
.width(Number(style.width.slice(0, -2)))
.height(Number(style.height.slice(0, -2)))
.showNavInfo(false)
.linkColor(() => lineColor)
.backgroundColor(bgColor)
.nodeThreeObject((node: any) => {
const color = node.color ?? nodeColor;
node.color = color;
return false as any;
})
.nodeThreeObjectExtend(true)
.onNodeHover((node: any, prev: any) => {
if (node) {
const graphNode = context.data.nodes.find((n) => n.id === node.id);
context.onHover(graphNode);
}
if (prev) {
context.onHover(undefined);
}
})
.cooldownTicks(100);
}
```
We are almost doing the same thing as the 2D renderer but creating it with [Three.js](https://threejs.org/) instead.
## Rendering 
Now open up `src/lit-force-graph.ts` and the imports for the renderers and graph/context classes we created:
```
// ...
import { Renderer } from "./classes/context";
import { GraphData, GraphNode } from "./classes/graph";
import { render as render2D } from "./modes/mode-2d";
import { render as render3D } from "./modes/mode-3d";
// ...
```
Now add the property for the graph data and the renderers in the class:
```
@property({ type: Object }) data?: GraphData;
@state() hovered?: GraphNode;
renderers = new Map<string, Renderer>([
["2D", render2D],
["3D", render3D],
]);
```
Update `setData` to render with the current renderer:
```
setData(data: GraphData) {
this.data = data;
const renderer = this.renderers.get(this.mode);
renderer?.({
element: this.graph,
data,
onHover: (node) => (this.hovered = node),
});
}
```
And finally update the render method to show the graph title and currently hovered node:
```
render() {
return html` <main
accept="application/json"
@drop="${this.onDrop}"
@dragover="${(e: Event) => e.preventDefault()}"
>
<div id="graph"></div>
<div id="controls">
<div>
<label for="render-mode">Render mode</label>
<select id="render-mode" @change=${this.onChangeMode}>
${Array.from(this.renderers.keys()).map((mode) => {
return html` <option value="${mode}">${mode}</option> `;
})}
</select>
</div>
</div>
<div id="info">
<h2 id="graph-name">${this.data?.name}</h2>
<div id="graph-description">${this?.data?.description}</div>
</div>
<div id="tooltips">
${this.hovered
? html` <div class="node-tooltip">
${this.hovered?.name ?? this.hovered?.id}
</div>`
: html``}
</div>
</main>`;
}
```
## Final Code 
If everything was added correctly it should look like this:
```
import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { Renderer } from "./classes/context";
import { GraphData, GraphNode } from "./classes/graph";
import { render as render2D } from "./modes/mode-2d";
import { render as render3D } from "./modes/mode-3d";
export const tagName = "lit-force-graph";
@customElement(tagName)
export class LitForceGraph extends LitElement {
static styles = css`
:host {
background-color: var(--graph-background-color, #000011);
color: var(--graph-foreground-color, #ffffff);
width: var(--graph-width, 100%);
height: var(--graph-height, 100vh);
}
#graph {
width: 100%;
height: 100%;
width: var(--graph-width, 100%);
height: var(--graph-height, 100vh);
}
#controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 100 !important;
display: flex;
flex-direction: column;
align-items: flex-end;
}
#controls div {
padding: 5px;
}
#info {
position: absolute;
top: 10px;
left: 10px;
z-index: 100 !important;
display: flex;
flex-direction: column;
align-items: flex-start;
}
#tooltips {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
justify-content: center;
}
.node-tooltip {
background-color: var(--graph-foreground-color, #ffffff);
color: var(--graph-background-color, #000011);
border-radius: 5px;
font-size: 12px;
padding: 5px;
opacity: 0.67;
}
#graph-description {
opacity: 0.67;
}
.scene-tooltip {
color: var(--graph-foreground-color, #ffffff);
background-color: transparent;
display: none;
}
`;
@query("#graph") graph!: HTMLElement;
@property() src = "";
@property() mode = "2D";
@property({ type: Object }) data?: GraphData;
@state() hovered?: GraphNode;
renderers = new Map<string, Renderer>([
["2D", render2D],
["3D", render3D],
]);
render() {
return html` <main
accept="application/json"
@drop="${this.onDrop}"
@dragover="${(e: Event) => e.preventDefault()}"
>
<div id="graph"></div>
<div id="controls">
<div>
<label for="render-mode">Render mode</label>
<select id="render-mode" @change=${this.onChangeMode}>
${Array.from(this.renderers.keys()).map((mode) => {
return html` <option value="${mode}">${mode}</option> `;
})}
</select>
</div>
</div>
<div id="info">
<h2 id="graph-name">${this.data?.name}</h2>
<div id="graph-description">${this?.data?.description}</div>
</div>
<div id="tooltips">
${this.hovered
? html` <div class="node-tooltip">
${this.hovered?.name ?? this.hovered?.id}
</div>`
: html``}
</div>
</main>`;
}
async firstUpdated() {
await this.refresh();
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
prefersDark.addEventListener("change", () => {
this.refresh();
});
}
/**
* Set the graph data and update the renderer
*
* @param data Graph JSON
*/
setData(data: GraphData) {
this.data = data;
const renderer = this.renderers.get(this.mode);
renderer?.({
element: this.graph,
data,
onHover: (node) => (this.hovered = node),
});
}
private async refresh() {
// Get json from script tag
const children = Array.from(this.children);
const elem = children.find((child) => child.tagName === "SCRIPT");
if (elem) {
// Render from script tag contents
if (elem.textContent) {
const data = JSON.parse(elem.textContent);
if (data) this.setData(data);
// Render from script tag src
} else if (elem.hasAttribute("src")) {
const url = elem.getAttribute("src")!;
const data = await fetch(url).then((res) => res.json());
if (data) this.setData(data);
}
} else if (this.src.length > 0) {
// Render from src attribute
const data = await fetch(this.src).then((res) => res.json());
if (data) this.setData(data);
}
}
private onChangeMode(e: Event) {
const mode = (e.target as HTMLSelectElement).value;
this.mode = mode;
if (!this.data) return;
this.setData({ ...this.data! });
}
private onDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
const reader = new FileReader();
reader.onload = () => {
const json = JSON.parse(reader.result as string);
this.data = json;
this.setData(json);
};
reader.readAsText(file);
}
return false;
}
attributeChangedCallback(
name: string,
_old: string | null,
value: string | null
): void {
if (name === "src" && value) {
this.refresh();
}
if (name === "data" && value) {
this.setData(JSON.parse(value));
}
if (name === "mode" && value) {
this.mode = value;
if (this.data) {
this.setData({ ...this.data! });
}
}
super.attributeChangedCallback(name, _old, value);
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-force-graph": LitForceGraph;
}
}
```
**2D Light:**
![](https://rodydavis.com/_/../api/files/pbc_2708086759/p2au8c800366mlb/graph_light_hsa07drqll.webp?thumb=)
**2D Dark:**
![](https://rodydavis.com/_/../api/files/pbc_2708086759/883uok842mmc7x8/graph_dark_s951yas96p.webp?thumb=)
**3D Light:**
![](https://rodydavis.com/_/../api/files/pbc_2708086759/rd00cd3t0qd7z64/graph_light_3d_1n3rbzomqr.webp?thumb=)
**3D Dark:**
![](https://rodydavis.com/_/../api/files/pbc_2708086759/2o90ed2bymxz254/graph_dark_3d_kckpdzz3lx.webp?thumb=)
## Conclusion 
Now you can render the complex data structures with ease using web components!
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
The source for this example can be found [here](https://github.com/rodydavis/lit-force-graph).
+279
View File
@@ -0,0 +1,279 @@
---
name: json-to-html-table-with-lit
description: Learn how to create a dynamic HTML table from JSON data using a Lit web component, with examples for fetching data from a URL or using inline JSON, and the ability to make the table editable.
metadata:
url: https://rodydavis.com/posts/lit-html-table
last_modified: Tue, 03 Feb 2026 20:04:20 GMT
---
# JSON to HTML Table with Lit
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a HTML [Table](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table) from json url or inline json.
> **TLDR** The final source [here](https://github.com/rodydavis/lit-html-table) and an online [demo](https://rodydavis.github.io/lit-html-table/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `lit-html-table` and now open the project in vscode and install the dependencies:
```
cd lit-html-table
npm i lit
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/lit-html-table/",
build: {
lib: {
entry: "src/lit-html-table.ts",
formats: ["es"],
},
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JSON to Lit HTML Table</title>
<script type="module" src="/src/lit-html-table.ts"></script>
</head>
<body>
<lit-html-table src="https://jsonplaceholder.typicode.com/posts">
<!-- <span slot="title" style="color: red;">Title</span> -->
<!-- <script type="application/json">
[
{
"id": "0",
"name": "First Item"
}
]
</script> -->
</lit-html-table>
</body>
</html>
```
We are passing a src attribute to the web component for this example but we can also add a script tag with the type attribute set to `application/json` with the contents containing the json.
If any table header cell needed to be replaced an element can be provided with the slot name set to the key in the json object.
## Web Component 
Before we update our component we need to rename `my-element.ts` to `lit-html-table.ts`
Open up `lit-html-table.ts` and update it with the following:
```
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
type ObjectData = { [key: string]: any };
@customElement("lit-html-table")
export class LitHtmlTable extends LitElement {
@property() src = "";
data?: ObjectData[];
static styles = css`
tr {
text-align: var(--table-tr-text-align, left);
vertical-align: var(--table-tr-vertical-align, top);
padding: var(--table-tr-padding, 10px);
}
`;
render() {
// Check if data is loaded
if (!this.values) {
return html`<slot name="loading">Loading...</slot>`;
}
// Check if items are not empty
if (this.values.length === 0) {
return html`<slot name="empty">No Items Found!</slot>`;
}
// Convert JSON to HTML Table
return html`
<table>
<thead>
<tr>
${Object.keys(this.values[0]).map((key) => {
const name = key.replace(/\b([a-z])/g, (_, val) =>
val.toUpperCase()
);
return html`<th>
<slot name="${key}">${name}</slot>
</th>`;
})}
</tr>
</thead>
<tbody>
${this.values.map((item) => {
return html`
<tr>
${Object.values(item).map((row) => {
return html`<td>${row}</td>`;
})}
</tr>
`;
})}
</tbody>
</table>
`;
}
async firstUpdated() {
await this.fetchData();
}
// Download the latest json and update it locally
async fetchData() {
let _data: any;
if (this.src.length > 0) {
// If a src attribute is set prefer it over any slots
_data = await fetch(this.src).then((res) => res.json());
} else {
// If no src attribute is set then grab the inline json in the slot
const elem = this.parentElement?.querySelector(
'script[type="application/json"]'
) as HTMLScriptElement;
if (elem) _data = JSON.parse(elem.innerHTML);
}
this.values = this.transform(_data ?? []);
this.requestUpdate();
}
transform(data: any) {
return data;
}
}
```
We have defined a few [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to style the table cell but many more can be added here.
If everything goes well run the command `npm run dev` and the follow should appear:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/y7s7v210eoxwe0u/h_1_dab8xdgmxb.webp?thumb=)
## Editing 
What if we wanted to support editing of any cell? With Lit and Web Components we can progressively enhance the experience without changing the html.
At the top of the class add the following boolean property:
```
@property({ type: Boolean }) editable = false;
```
Now update the `tbody` tag in the render method:
```
<tbody>
${this.values.map((item, index) => {
return html`
<tr>
${Object.entries(item).map((row) => {
return html`<td>
${this.editable
? html`<input
value="${row[1]}"
type="text"
@input=${(e: any) => {
const value = e.target.value;
const key = row[0];
const current = this.values![index];
current[key] = value;
this.values![index] = current;
this.requestUpdate();
this.dispatchEvent(
new CustomEvent("input-cell", {
detail: {
index: index,
data: current,
},
})
);
}}
/>`
: html`${row[1]}`}
</td>`;
})}
</tr>
`;
})}
</tbody>
```
By checking to see if the `editable` and if `true` return an input with an event listener to update the data and dispatch an `input` event.
Add the `editable` attribute to the `index.html`:
```
<lit-html-table editable> ... </lit-html-table>
```
After a reload the table should look like this and any cell can be edited.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/6383404584p0f06/h_2_8s8pheteqq.webp?thumb=)
An event listener can be added just before the closing `body` tag in `index.html` to grab the latest values or cell information:
```
<script>
const elem = document.querySelector("lit-html-table");
elem.addEventListener(
"input-cell",
(e) => {
// Index and data for the individual cell
const { index, data } = e.detail;
// New array of json items
const values = elem.values;
},
false
);
</script>
```
This can be taken farther by checking for the type of the value and returning a color, number or checkbox input.
## Conclusion 
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/). There is also an example on the Lit playground [here](https://lit.dev/playground/#project=W3sibmFtZSI6ImxpdC1odG1sLXRhYmxlLnRzIiwiY29udGVudCI6ImltcG9ydCB7IGh0bWwsIGNzcywgTGl0RWxlbWVudCB9IGZyb20gXCJsaXRcIjtcbmltcG9ydCB7IGN1c3RvbUVsZW1lbnQsIHByb3BlcnR5IH0gZnJvbSBcImxpdC9kZWNvcmF0b3JzLmpzXCI7XG5cbnR5cGUgT2JqZWN0RGF0YSA9IHsgW2tleTogc3RyaW5nXTogYW55IH07XG5cbkBjdXN0b21FbGVtZW50KFwibGl0LWh0bWwtdGFibGVcIilcbmV4cG9ydCBjbGFzcyBMaXRIdG1sVGFibGUgZXh0ZW5kcyBMaXRFbGVtZW50IHtcbiAgQHByb3BlcnR5KCkgc3JjID0gXCJcIjtcblxuICBkYXRhPzogT2JqZWN0RGF0YVtdO1xuXG4gIHN0YXRpYyBzdHlsZXMgPSBjc3NgXG4gICAgdHIge1xuICAgICAgdGV4dC1hbGlnbjogdmFyKC0tdGFibGUtdHItdGV4dC1hbGlnbiwgbGVmdCk7XG4gICAgICB2ZXJ0aWNhbC1hbGlnbjogdmFyKC0tdGFibGUtdHItdmVydGljYWwtYWxpZ24sIHRvcCk7XG4gICAgICBwYWRkaW5nOiB2YXIoLS10YWJsZS10ci1wYWRkaW5nLCAxMHB4KTtcbiAgICB9XG4gIGA7XG5cbiAgcmVuZGVyKCkge1xuICAgIC8vIENoZWNrIGlmIGRhdGEgaXMgbG9hZGVkXG4gICAgaWYgKCF0aGlzLmRhdGEpIHtcbiAgICAgIHJldHVybiBodG1sYDxzbG90IG5hbWU9XCJsb2FkaW5nXCI-TG9hZGluZy4uLjwvc2xvdD5gO1xuICAgIH1cbiAgICAvLyBDaGVjayBpZiBpdGVtcyBhcmUgbm90IGVtcHR5XG4gICAgaWYgKHRoaXMuZGF0YS5sZW5ndGggPT09IDApIHtcbiAgICAgIHJldHVybiBodG1sYDxzbG90IG5hbWU9XCJlbXB0eVwiPk5vIEl0ZW1zIEZvdW5kITwvc2xvdD5gO1xuICAgIH1cbiAgICAvLyBDb252ZXJ0IEpTT04gdG8gSFRNTCBUYWJsZVxuICAgIHJldHVybiBodG1sYFxuICAgICAgPHRhYmxlPlxuICAgICAgICA8dGhlYWQ-XG4gICAgICAgICAgPHRyPlxuICAgICAgICAgICAgJHtPYmplY3Qua2V5cyh0aGlzLmRhdGFbMF0pLm1hcCgoa2V5KSA9PiB7XG4gICAgICAgICAgICAgIGNvbnN0IG5hbWUgPSBrZXkucmVwbGFjZSgvXFxiKFthLXpdKS9nLCAoXywgdmFsKSA9PlxuICAgICAgICAgICAgICAgIHZhbC50b1VwcGVyQ2FzZSgpXG4gICAgICAgICAgICAgICk7XG4gICAgICAgICAgICAgIHJldHVybiBodG1sYDx0aD5cbiAgICAgICAgICAgICAgICA8c2xvdCBuYW1lPVwiJHtrZXl9XCI-JHtuYW1lfTwvc2xvdD5cbiAgICAgICAgICAgICAgPC90aD5gO1xuICAgICAgICAgICAgfSl9XG4gICAgICAgICAgPC90cj5cbiAgICAgICAgPC90aGVhZD5cbiAgICAgICAgPHRib2R5PlxuICAgICAgICAgICR7dGhpcy5kYXRhLm1hcCgoaXRlbSkgPT4ge1xuICAgICAgICAgICAgcmV0dXJuIGh0bWxgXG4gICAgICAgICAgICAgIDx0cj5cbiAgICAgICAgICAgICAgICAke09iamVjdC52YWx1ZXMoaXRlbSkubWFwKCh2YWwpID0-IHtcbiAgICAgICAgICAgICAgICAgIHJldHVybiBodG1sYDx0ZD4ke3ZhbH08L3RkPmA7XG4gICAgICAgICAgICAgICAgfSl9XG4gICAgICAgICAgICAgIDwvdHI-XG4gICAgICAgICAgICBgO1xuICAgICAgICAgIH0pfVxuICAgICAgICA8L3Rib2R5PlxuICAgICAgPC90YWJsZT5cbiAgICBgO1xuICB9XG5cbiAgYXN5bmMgZmlyc3RVcGRhdGVkKCkge1xuICAgIGF3YWl0IHRoaXMuZmV0Y2hEYXRhKCk7XG4gIH1cblxuICBhc3luYyBmZXRjaERhdGEoKSB7XG4gICAgbGV0IF9kYXRhOiBhbnk7XG4gICAgaWYgKHRoaXMuc3JjLmxlbmd0aCA-IDApIHtcbiAgICAgIF9kYXRhID0gYXdhaXQgZmV0Y2godGhpcy5zcmMpLnRoZW4oKHJlcykgPT4gcmVzLmpzb24oKSk7XG4gICAgfSBlbHNlIHtcbiAgICAgIGNvbnN0IGVsZW0gPSB0aGlzLnBhcmVudEVsZW1lbnQ_LnF1ZXJ5U2VsZWN0b3IoXG4gICAgICAgICdzY3JpcHRbdHlwZT1cImFwcGxpY2F0aW9uL2pzb25cIl0nXG4gICAgICApIGFzIEhUTUxTY3JpcHRFbGVtZW50O1xuICAgICAgaWYgKGVsZW0pIF9kYXRhID0gSlNPTi5wYXJzZShlbGVtLmlubmVySFRNTCk7XG4gICAgfVxuICAgIF9kYXRhID8_PSBbXTtcbiAgICB0aGlzLmRhdGEgPSB0aGlzLnRyYW5zZm9ybShfZGF0YSk7XG4gICAgdGhpcy5yZXF1ZXN0VXBkYXRlKCk7XG4gIH1cblxuICB0cmFuc2Zvcm0oZGF0YTogYW55KSB7XG4gICAgcmV0dXJuIGRhdGE7XG4gIH1cbn1cbiJ9LHsibmFtZSI6ImluZGV4Lmh0bWwiLCJjb250ZW50IjoiPCFET0NUWVBFIGh0bWw-XG48aHRtbCBsYW5nPVwiZW5cIj5cblxuPGhlYWQ-XG4gIDxtZXRhIGNoYXJzZXQ9XCJVVEYtOFwiIC8-XG4gIDxsaW5rIHJlbD1cImljb25cIiB0eXBlPVwiaW1hZ2Uvc3ZnK3htbFwiIGhyZWY9XCIvc3JjL2Zhdmljb24uc3ZnXCIgLz5cbiAgPG1ldGEgbmFtZT1cInZpZXdwb3J0XCIgY29udGVudD1cIndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjBcIiAvPlxuICA8dGl0bGU-SlNPTiB0byBMaXQgSFRNTCBUYWJsZTwvdGl0bGU-XG4gIDxzY3JpcHQgdHlwZT1cIm1vZHVsZVwiIHNyYz1cIi4vbGl0LWh0bWwtdGFibGUuanNcIj48L3NjcmlwdD5cbjwvaGVhZD5cblxuPGJvZHk-XG4gIDxsaXQtaHRtbC10YWJsZSBzcmM9XCJodHRwczovL2pzb25wbGFjZWhvbGRlci50eXBpY29kZS5jb20vcG9zdHNcIj5cbiAgICA8IS0tIDxzcGFuIHNsb3Q9XCJ0aXRsZVwiIHN0eWxlPVwiY29sb3I6IHJlZDtcIj5UaXRsZTwvc3Bhbj4gLS0-XG4gICAgPCEtLSA8c2NyaXB0IHR5cGU9XCJhcHBsaWNhdGlvbi9qc29uXCI-XG4gICAgICBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImlkXCI6IFwiMFwiLFxuICAgICAgICAgIFwibmFtZVwiOiBcIkZpcnN0IEl0ZW1cIlxuICAgICAgICB9XG4gICAgICBdXG4gICAgPC9zY3JpcHQ-IC0tPlxuICA8L2xpdC1odG1sLXRhYmxlPlxuXG48L2JvZHk-XG5cbjwvaHRtbD4ifV0).
The source for this example can be found [here](https://github.com/rodydavis/lit-html-table).
+425
View File
@@ -0,0 +1,425 @@
---
name: lit-and-monaco-editor
description: Learn how to create a Lit web component that wraps the Monaco Editor (powering VSCode) to add a fully functional code editor to your web applications.
metadata:
url: https://rodydavis.com/posts/lit-monaco-editor
last_modified: Tue, 03 Feb 2026 20:04:19 GMT
---
# Lit and Monaco Editor
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to wrap the [Monaco Editor](https://microsoft.github.io/monaco-editor/) that powers [VSCode](https://code.visualstudio.com/).
> **TLDR** You can find the final source [here](https://github.com/rodydavis/lit-code-editor) and an online demo [here](https://rodydavis.github.io/lit-code-editor/).
To learn how to build an extension with VSCode and Lit check out the blog post [here](https://rodydavis.com/posts/lit-vscode-extension/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `lit-code-editor` and now open the project in vscode and install the dependencies:
```
cd lit-code-editor
npm i lit monaco-editor
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/lit-code-editor/",
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit Code Editor</title>
<script type="module" src="/src/code-editor.ts"></script>
<style>
body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<code-editor>
<script type="text/javascript">
function x() {
console.log("Hello world! :)");
}
</script>
</code-editor>
</body>
</html>
```
We are setting up the `lit-element` to have a slot which will be the code for the editor to start with. The language can be set with the type or adding an attribute to the `code-editor` component.
## Web Component 
Before we update our component we need to rename `my-element.ts` to `code-editor.ts`
Open up `code-editor.ts` and update it with the following:
```
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, Ref, ref } from "lit/directives/ref.js";
// -- Monaco Editor Imports --
import * as monaco from "monaco-editor";
import styles from "monaco-editor/min/vs/editor/editor.main.css";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
// @ts-ignore
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
@customElement("code-editor")
export class CodeEditor extends LitElement {
private container: Ref<HTMLElement> = createRef();
editor?: monaco.editor.IStandaloneCodeEditor;
@property() theme?: string;
@property() language?: string;
@property() code?: string;
static styles = css`
:host {
--editor-width: 100%;
--editor-height: 100vh;
}
main {
width: var(--editor-width);
height: var(--editor-height);
}
`;
render() {
return html`
<style>
${styles}
</style>
<main ${ref(this.container)}></main>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"code-editor": CodeEditor;
}
}
```
Here we are just setting up some boilerplate to set up the [web workers with vite](https://vitejs.dev/guide/features.html#web-workers) and passing the reference from the container element to the template using the [ref directive](https://lit.dev/docs/templates/directives/#ref).
The styles from monaco editor are also passed as a style element load in the shadow root.
Now let's add some helper methods for accessing the code and language provided:
```
private getFile() {
if (this.children.length > 0) return this.children[0];
return null;
}
private getCode() {
if (this.code) return this.code;
const file = this.getFile();
if (!file) return;
return file.innerHTML.trim();
}
private getLang() {
if (this.language) return this.language;
const file = this.getFile();
if (!file) return;
const type = file.getAttribute("type")!;
return type.split("/").pop()!;
}
private getTheme() {
if (this.theme) return this.theme;
if (this.isDark()) return "vs-dark";
return "vs-light";
}
private isDark() {
return (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
```
These methods are checking the slot for the script tag with the language provided or looking for a property set on `code-editor` and then returning the value.
Now let's attach the editor to the container reference:
```
firstUpdated() {
this.editor = monaco.editor.create(this.container.value!, {
value: this.getCode(),
language: this.getLang(),
theme: this.getTheme(),
automaticLayout: true,
});
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
monaco.editor.setTheme(this.getTheme());
});
}
```
Now the editor should be running and able to be interacted with:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/o79buuo6mcn1lg0/m_1_tsfmich8kz.webp?thumb=)
When the system changes to dark mode it will [switch](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) as well!
![](https://rodydavis.com/_/../api/files/pbc_2708086759/74mr6rs163d0274/m_2_ggpw2nyy0d.webp?thumb=)
To get and set the value from the editor we can add 2 helper methods:
```
setValue(value: string) {
this.editor!.setValue(value);
}
getValue() {
const value = this.editor!.getValue();
return value;
}
```
Everything should work as expected now and the final code should look like the following:
```
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, Ref, ref } from "lit/directives/ref.js";
// -- Monaco Editor Imports --
import * as monaco from "monaco-editor";
import styles from "monaco-editor/min/vs/editor/editor.main.css";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
// @ts-ignore
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
@customElement("code-editor")
export class CodeEditor extends LitElement {
private container: Ref<HTMLElement> = createRef();
editor?: monaco.editor.IStandaloneCodeEditor;
@property() theme?: string;
@property() language?: string;
@property() code?: string;
static styles = css`
:host {
--editor-width: 100%;
--editor-height: 100vh;
}
main {
width: var(--editor-width);
height: var(--editor-height);
}
`;
render() {
return html`
<style>
${styles}
</style>
<main ${ref(this.container)}></main>
`;
}
private getFile() {
if (this.children.length > 0) return this.children[0];
return null;
}
private getCode() {
if (this.code) return this.code;
const file = this.getFile();
if (!file) return;
return file.innerHTML.trim();
}
private getLang() {
if (this.language) return this.language;
const file = this.getFile();
if (!file) return;
const type = file.getAttribute("type")!;
return type.split("/").pop()!;
}
private getTheme() {
if (this.theme) return this.theme;
if (this.isDark()) return "vs-dark";
return "vs-light";
}
private isDark() {
return (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
setValue(value: string) {
this.editor!.setValue(value);
}
getValue() {
const value = this.editor!.getValue();
return value;
}
firstUpdated() {
this.editor = monaco.editor.create(this.container.value!, {
value: this.getCode(),
language: this.getLang(),
theme: this.getTheme(),
automaticLayout: true,
});
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
monaco.editor.setTheme(this.getTheme());
});
}
}
declare global {
interface HTMLElementTagNameMap {
"code-editor": CodeEditor;
}
}
```
## Usage 
To use this component it can have the code provided by slots:
```
<code-editor>
<script type="text/javascript">
function x() {
console.log("Hello world! :)");
}
</script>
</code-editor>
```
Or for properties:
```
<code-editor
code="console.log('Hello World');"
language="javascript"
>
</code-editor>
```
Or both:
```
<code-editor language="typescript">
<script>
function x() {
console.log("Hello world! :)");
}
</script>
</code-editor>
```
The theme can also be manually set:
```
<code-editor theme="vs-light"> </code-editor>
```
## Conclusion 
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
The source for this example can be found [here](https://github.com/rodydavis/lit-code-editor).
+646
View File
@@ -0,0 +1,646 @@
---
name: building-a-rich-text-editor-with-lit
description: Learn how to build a rich text editor using a Lit web component, complete with a toolbar for formatting text, links, and styles.
metadata:
url: https://rodydavis.com/posts/lit-rich-text-editor
last_modified: Tue, 03 Feb 2026 20:04:26 GMT
---
# Building a Rich Text Editor with Lit
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a rich text editor.
> **TLDR** The final source [here](https://github.com/rodydavis/lit-html-editor) and an online [demo](https://rodydavis.github.io/lit-html-editor/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `lit-rich-text-editor` and now open the project in vscode and install the dependencies:
```
cd lit-rich-text-editor
npm i @material/mwc-icon-button
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: '/lit-rich-text-editor/',
build: {
lib: {
entry: "src/lit-rich-text-editor.ts",
formats: ["es"],
},
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
rel="stylesheet"
/>
<title>Lit Rich Text Editor</title>
<script type="module" src="/src/lit-rich-text-editor.ts"></script>
<style>
body {
padding: 0;
margin: 0;
}
lit-rich-text-editor {
--editor-width: 100%;
--editor-height: 100vh;
}
</style>
</head>
<body>
<lit-rich-text-editor>
<template>
<h1>Headline 1</h1>
<p>This is a paragraph.</p>
<p>
<span style="background-color: rgb(255, 0, 0)"
><font color="#ffffff">Styled Text</font></span
>
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
culpa qui officia deserunt mollit anim id est laborum.
</p>
</template>
</lit-rich-text-editor>
</body>
</html>
```
The important things to take away are the styles added to remove the body padding and send size [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to the editor to take up the full viewport.
Inside the `lit-rich-text-editor` tags there is a [`template`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template) passed as a slot to provide html that will not be rendered but can be accessed.
There is also an import for the [Material Icons](https://fonts.google.com/icons) so it can be used in the editor later.
## Editor 
The next thing to create is the editor itself. Open up `src/lit-rich-text-editor.ts` and update it with the following:
```
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "@material/mwc-icon-button";
@customElement("lit-rich-text-editor")
export class LitRichTextEditor extends LitElement {
@state() content: string = "";
@state() root: Element | null = null;
static styles = css`
:host {
--editor-width: 600px;
--editor-height: 600px;
--editor-background: #f1f1f1;
--editor-toolbar-height: 33px;
--editor-toolbar-background: black;
--editor-toolbar-on-background: white;
--editor-toolbar-on-active-background: #a4a4a4;
}
main {
width: var(--editor-width);
height: var(--editor-height);
display: grid;
grid-template-areas:
"toolbar toolbar"
"editor editor";
grid-template-rows: var(--editor-toolbar-height) auto;
grid-template-columns: auto auto;
}
#editor-actions {
grid-area: toolbar;
width: var(--editor-width);
height: var(--editor-toolbar-height);
background-color: var(--editor-toolbar-background);
color: var(--editor-toolbar-on-background);
overscroll-behavior: contain;
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
#editor-actions::-webkit-scrollbar {
display: none;
}
#editor {
width: var(--editor-width);
grid-area: editor;
background-color: var(--editor-background);
}
#toolbar {
width: 1090px;
height: var(--editor-toolbar-height);
}
[contenteditable] {
outline: 0px solid transparent;
}
#toolbar > mwc-icon-button {
color: var(--editor-toolbar-on-background);
--mdc-icon-size: 20px;
--mdc-icon-button-size: 30px;
cursor: pointer;
}
#toolbar > .active {
color: var(--editor-toolbar-on-active-background);
}
select {
margin-top: 5px;
height: calc(var(--editor-toolbar-height) - 10px);
}
input[type="color"] {
height: calc(var(--editor-toolbar-height) - 15px);
-webkit-appearance: none;
border: none;
width: 22px;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
}
`;
render() {
return html`<main>
<input id="bg" type="color" style="display:none" />
<input id="fg" type="color" style="display:none" />
<div id="editor-actions">
<div id="toolbar">
</div>
</div>
<div id="editor">${this.root}</div>
</main> `;
}
async firstUpdated() {
const elem = this.parentElement!.querySelector("lit-rich-text-editor template");
this.content = elem?.innerHTML ?? "";
this.reset();
}
reset() {
const parser = new DOMParser();
const doc = parser.parseFromString(this.content, "text/html");
document.execCommand("defaultParagraphSeparator", false, "br");
document.addEventListener("selectionchange", () => {
this.requestUpdate();
});
const root = doc.querySelector("body");
root!.setAttribute("contenteditable", "true");
this.root = root;
}
}
```
With everything updated run `npm run dev` and the following should appear in the browser:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/8ed1zm27u325371/lit_piano_1_32gx6viodw.webp?thumb=)
Nothing special is happening yet, but the template is being read and passed into the element, parsed and setting the [`contenteditable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable) attribute to `true`.
This is a way to access the slots and use the nodes to hold data that are not used for rendering. Doing it this way allows for a transformation of the HTML source into a format that can be used.
## Toolbar 
At the bottom of the class before the last `}` add the following:
```
renderToolbar(command: (c: string, val: string | undefined) => void) {
// TODO: Selection does not work on Safari iOS
const selection = this.shadowRoot?.getSelection
? this.shadowRoot!.getSelection()
: null;
const tags: string[] = [];
if (selection?.type === "Range") {
// @ts-ignore
let parentNode = selection?.baseNode;
if (parentNode) {
const checkNode = () => {
const parentTagName = parentNode?.tagName?.toLowerCase()?.trim();
if (parentTagName) tags.push(parentTagName);
};
while (parentNode != null) {
checkNode();
parentNode = parentNode?.parentNode;
}
}
}
const commands: {
icon: string;
command: string | (() => void);
active?: boolean;
type?: string;
values?: { value: string; name: string; font?: boolean }[];
command_value?: string;
}[] = [
{
icon: "format_clear",
command: "removeFormat",
},
{
icon: "format_bold",
command: "bold",
active: tags.includes("b"),
},
{
icon: "format_italic",
command: "italic",
active: tags.includes("i"),
},
{
icon: "format_underlined",
command: "underline",
active: tags.includes("u"),
},
{
icon: "format_align_left",
command: "justifyleft",
},
{
icon: "format_align_center",
command: "justifycenter",
},
{
icon: "format_align_right",
command: "justifyright",
},
{
icon: "format_list_numbered",
command: "insertorderedlist",
active: tags.includes("ol"),
},
{
icon: "format_list_bulleted",
command: "insertunorderedlist",
active: tags.includes("ul"),
},
{
icon: "format_quote",
command: "formatblock",
command_value: "blockquote",
},
{
icon: "format_indent_decrease",
command: "outdent",
},
{
icon: "format_indent_increase",
command: "indent",
},
{
icon: "add_link",
command: () => {
const newLink = prompt("Write the URL here", "http://");
if (newLink && newLink != "" && newLink != "http://") {
command("createlink", newLink);
}
},
},
{ icon: "link_off", command: "unlink" },
{
icon: "format_color_text",
command: () => {
const input = this.shadowRoot!.querySelector(
"#fg"
)! as HTMLInputElement;
input.addEventListener("input", (e: any) => {
const val = e.target.value;
command("forecolor", val);
});
input.click();
},
type: "color",
},
{
icon: "border_color",
command: () => {
const input = this.shadowRoot!.querySelector(
"#bg"
)! as HTMLInputElement;
input.addEventListener("input", (e: any) => {
const val = e.target.value;
command("backcolor", val);
});
input.click();
},
type: "color",
},
{
icon: "title",
command: "formatblock",
values: [
{ name: "Normal Text", value: "--" },
{ name: "Heading 1", value: "h1" },
{ name: "Heading 2", value: "h2" },
{ name: "Heading 3", value: "h3" },
{ name: "Heading 4", value: "h4" },
{ name: "Heading 5", value: "h5" },
{ name: "Heading 6", value: "h6" },
{ name: "Paragraph", value: "p" },
{ name: "Pre-Formatted", value: "pre" },
],
},
{
icon: "text_format",
command: "fontname",
values: [
{ name: "Font Name", value: "--" },
...[...checkFonts()].map((f) => ({
name: f,
value: f,
font: true,
})),
],
},
{
icon: "format_size",
command: "fontsize",
values: [
{ name: "Font Size", value: "--" },
{ name: "Very Small", value: "1" },
{ name: "Small", value: "2" },
{ name: "Normal", value: "3" },
{ name: "Medium Large", value: "4" },
{ name: "Large", value: "5" },
{ name: "Very Large", value: "6" },
{ name: "Maximum", value: "7" },
],
},
{
icon: "undo",
command: "undo",
},
{
icon: "redo",
command: "redo",
},
{
icon: "content_cut",
command: "cut",
},
{
icon: "content_copy",
command: "copy",
},
{
icon: "content_paste",
command: "paste",
},
];
return html`
${commands.map((n) => {
return html`
${n.values
? html` <select
id="${n.icon}"
@change=${(e: any) => {
const val = e.target.value;
if (val === "--") {
command("removeFormat", undefined);
} else if (typeof n.command === "string") {
command(n.command, val);
}
}}
>
${n.values.map(
(v) => html` <option value=${v.value}>${v.name}</option>`
)}
</select>`
: html` <mwc-icon-button
icon="${n.icon}"
class="${n.active ? "active" : "inactive"}"
@click=${() => {
if (n.values) {
} else if (typeof n.command === "string") {
command(n.command, n.command_value);
} else {
n.command();
}
}}
></mwc-icon-button>`}
`;
})}
`;
}
```
This takes an array of objects that we can map to `mwc-icon-button` or `select` depending on the passed values. This will also set up the event listeners and execute the command for the given action.
Inside the `<div id="toolbar">` tag add the following:
```
${this.renderToolbar((command, val) => {
document.execCommand(command, false, val);
console.log("command", command, val);
})}
```
This will listen for the callback and fire the command on the document and log it to the console.
And finally at the bottom of the file add the following:
```
export function checkFonts(): string[] {
const fontCheck = new Set(
[
// Windows 10
"Arial",
"Arial Black",
"Bahnschrift",
"Calibri",
"Cambria",
"Cambria Math",
"Candara",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier New",
"Ebrima",
"Franklin Gothic Medium",
"Gabriola",
"Gadugi",
"Georgia",
"HoloLens MDL2 Assets",
"Impact",
"Ink Free",
"Javanese Text",
"Leelawadee UI",
"Lucida Console",
"Lucida Sans Unicode",
"Malgun Gothic",
"Marlett",
"Microsoft Himalaya",
"Microsoft JhengHei",
"Microsoft New Tai Lue",
"Microsoft PhagsPa",
"Microsoft Sans Serif",
"Microsoft Tai Le",
"Microsoft YaHei",
"Microsoft Yi Baiti",
"MingLiU-ExtB",
"Mongolian Baiti",
"MS Gothic",
"MV Boli",
"Myanmar Text",
"Nirmala UI",
"Palatino Linotype",
"Segoe MDL2 Assets",
"Segoe Print",
"Segoe Script",
"Segoe UI",
"Segoe UI Historic",
"Segoe UI Emoji",
"Segoe UI Symbol",
"SimSun",
"Sitka",
"Sylfaen",
"Symbol",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"Webdings",
"Wingdings",
"Yu Gothic",
// macOS
"American Typewriter",
"Andale Mono",
"Arial",
"Arial Black",
"Arial Narrow",
"Arial Rounded MT Bold",
"Arial Unicode MS",
"Avenir",
"Avenir Next",
"Avenir Next Condensed",
"Baskerville",
"Big Caslon",
"Bodoni 72",
"Bodoni 72 Oldstyle",
"Bodoni 72 Smallcaps",
"Bradley Hand",
"Brush Script MT",
"Chalkboard",
"Chalkboard SE",
"Chalkduster",
"Charter",
"Cochin",
"Comic Sans MS",
"Copperplate",
"Courier",
"Courier New",
"Didot",
"DIN Alternate",
"DIN Condensed",
"Futura",
"Geneva",
"Georgia",
"Gill Sans",
"Helvetica",
"Helvetica Neue",
"Herculanum",
"Hoefler Text",
"Impact",
"Lucida Grande",
"Luminari",
"Marker Felt",
"Menlo",
"Microsoft Sans Serif",
"Monaco",
"Noteworthy",
"Optima",
"Palatino",
"Papyrus",
"Phosphate",
"Rockwell",
"Savoye LET",
"SignPainter",
"Skia",
"Snell Roundhand",
"Tahoma",
"Times",
"Times New Roman",
"Trattatello",
"Trebuchet MS",
"Verdana",
"Zapfino",
].sort()
);
const fontAvailable = new Set<string>();
// @ts-ignore
for (const font of fontCheck.values()) {
// @ts-ignore
if (document.fonts.check(`12px "${font}"`)) {
fontAvailable.add(font);
}
}
// @ts-ignore
return fontAvailable.values();
}
```
Following this great suggestion [here](https://stackoverflow.com/a/62755574/7303311) the document checks to see all the avaliable fonts for the browser and given document.
## Running 
If everything went well when the command `npm run dev` is run the following should appear in the viewport:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/yn0c6zrry2y6m4z/lit_text_2_y8vvurvfh1.webp?thumb=)
## Conclusion 
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
The source for this example can be found [here](https://github.com/rodydavis/lit-html-editor).
+344
View File
@@ -0,0 +1,344 @@
---
name: lit-sheet-music
description: Learn how to create a Lit web component that renders MusicXML using OpenSheetMusicDisplay, allowing you to display sheet music dynamically from a source attribute or inline XML.
metadata:
url: https://rodydavis.com/posts/lit-sheet-music
last_modified: Tue, 03 Feb 2026 20:04:18 GMT
---
# Lit Sheet Music
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to render [musicxml](https://www.musicxml.com/) from a src attribute or inline xml using [opensheetmusicdisplay](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay).
![](https://rodydavis.com/_/../api/files/pbc_2708086759/yz849u5k6kg68n5/nice_rbhmdu7o6t.gif?thumb=)
Now any sheet music can be rendered based on the browser width as an svg or canvas (and will resize when the viewport changes).
> **TLDR** The final source [here](https://github.com/rodydavis/lit-sheet-music) and an online [demo](https://rodydavis.github.io/lit-sheet-music/).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `lit-sheet-music` and now open the project in vscode and install the dependencies:
```
cd lit-sheet-music
npm i lit opensheetmusicdisplay
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/lit-sheet-music/",
build: {
lib: {
entry: "src/lit-sheet-music.ts",
formats: ["es"],
},
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
```
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit Sheet Music</title>
<script type="module" src="/src/sheet-music.ts"></script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<sheet-music
src="https://raw.githubusercontent.com/opensheetmusicdisplay/opensheetmusicdisplay/develop/demo/BrahWiMeSample.musicxml"
>
</sheet-music>
</body>
</html>
```
If local [musicxml](https://www.musicxml.com/) is intended to be used update `index.html` with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit Sheet Music</title>
<script type="module" src="/src/sheet-music.ts"></script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<sheet-music>
<script type="text/xml">
<?xml version="1.0" standalone="no"?>
<!DOCTYPE score-partwise PUBLIC
"-//Recordare//DTD MusicXML Partwise//EN"
"http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise>
<part-list>
<score-part id="P1">
<part-name>Voice</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="0" implicit="yes">
<attributes>
<divisions>4</divisions>
<key>
<fifths>-3</fifths>
<mode>major</mode>
</key>
<time>
<beats>2</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
<directive>Langsam, innig.</directive>
</attributes>
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>2</duration>
<type>eighth</type>
<stem>up</stem>
<notations>
<dynamics>
<p/>
</dynamics>
</notations>
<lyric>
<syllabic>single</syllabic>
<text>W&auml;rst</text>
</lyric>
</note>
</measure>
<measure number="1">
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>3</duration>
<type>eighth</type>
<dot/>
<stem>up</stem>
<lyric>
<syllabic>single</syllabic>
<text>du</text>
</lyric>
</note>
<note>
<pitch>
<step>E</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>1</duration>
<type>16th</type>
<stem>up</stem>
<lyric>
<syllabic>single</syllabic>
<text>nicht,</text>
</lyric>
</note>
<note>
<pitch>
<step>E</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>2</duration>
<type>eighth</type>
<stem>up</stem>
<lyric>
<syllabic>begin</syllabic>
<text>heil</text>
</lyric>
</note>
<note>
<pitch>
<step>B</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>1</duration>
<type>16th</type>
<stem>up</stem>
<beam number="1">begin</beam>
<beam number="2">begin</beam>
<notations>
<slur type="start" number="1"/>
</notations>
<lyric>
<syllabic>end</syllabic>
<text>ger</text>
<extend/>
</lyric>
</note>
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<type>16th</type>
<stem>up</stem>
<beam number="1">end</beam>
<beam number="2">end</beam>
<notations>
<slur type="stop" number="1"/>
</notations>
<lyric>
<extend/>
</lyric>
</note>
</measure>
</part>
</score-partwise>
</script>
</sheet-music>
</body>
</html>
```
We are passing a src attribute to the web component for this example but we can also add a script tag with the type attribute set to `text/xml` with the contents containing the json.
## Web Component 
Before we update our component we need to rename `my-element.ts` to `sheet-music.ts`
Open up `sheet-music.ts` and update it with the following:
```
import { html, css, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { IOSMDOptions, OpenSheetMusicDisplay } from "opensheetmusicdisplay";
type BackendType = "svg" | "canvas";
type DrawingType = "compact" | "default";
@customElement("sheet-music")
export class SheetMusic extends LitElement {
_zoom = 1.0;
@property({ type: Boolean }) allowDrop = false;
@property() src = "";
@query("main") canvas!: HTMLElement;
controller?: OpenSheetMusicDisplay;
options: IOSMDOptions = {
autoResize: true,
backend: "canvas" as BackendType,
drawingParameters: "default" as DrawingType,
};
static styles = css`
main {
overflow-x: auto;
}
`;
render() {
return html`<main></main>`;
}
async renderMusic(content: string) {
if (!this.controller) return;
await this.controller.load(content);
this.controller.zoom = this._zoom;
this.controller.render();
this.requestUpdate();
}
private async getMusic(): Promise<string> {
// Check if src attribute is set and prefer it over the slot
if (this.src.length > 0) return fetch(this.src).then((res) => res.text());
// Check if slot children exist and return the xml
const elem = this.parentElement?.querySelector(
'script[type="text/xml"]'
) as HTMLScriptElement;
if (elem) return elem.innerHTML;
// Return nothing if neither is found
return "";
}
async firstUpdated() {
this.controller = new OpenSheetMusicDisplay(this.canvas, this.options);
this.requestUpdate();
// Check for any music and update if found
const music = await this.getMusic();
if (music.length > 0) this.renderMusic(music);
}
}
declare global {
interface HTMLElementTagNameMap {
"sheet-music": SheetMusic;
}
}
```
Run `npm run dev` and the following should appear if all went well:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/2pi4040ed5l9ktm/s_1_4wd2mls47h.webp?thumb=)
## Conclusion 
If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/).
The source for this example can be found [here](https://github.com/rodydavis/lit-sheet-music).
![](https://rodydavis.com/_/../api/files/pbc_2708086759/6yk70v25sl68o50/s_2_mkoqbtpadj.gif?thumb=)
+572
View File
@@ -0,0 +1,572 @@
---
name: lit-and-vscode-extensions
description: Learn how to build a VSCode extension using a Lit web component, covering setup, template creation, component implementation, and extension activation.
metadata:
url: https://rodydavis.com/posts/lit-vscode-extension
last_modified: Tue, 03 Feb 2026 20:04:18 GMT
---
# Lit and VSCode Extensions
In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a VSCode extension.
> **TLDR** You can find the final source [here](https://github.com/rodydavis/lit-vscode-extension).
## Prerequisites 
* Vscode
* Node >= 16
* Typescript
## Getting Started 
We can start off by navigating in terminal to the location of the project and run the following:
```
npm init @vitejs/app --template lit-ts
```
Then enter a project name `lit-vscode-extension` and now open the project in vscode and install the dependencies:
```
cd lit-vscode-extension
npm i -D @types/node
code .
```
Update the `vite.config.ts` with the following:
```
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/lit-vscode-extension/",
build: {
outDir: "build",
rollupOptions: {
external: /^vscode/,
input: {
main: resolve(__dirname, "index.html"),
},
output: {
entryFileNames: "[name].js",
},
},
},
});
```
Open `package.json` and update it with the following:
```
{
"name": "lit-vscode-extension",
"description": "Lit VSCode Extension Example",
"version": "0.0.1",
"publisher": "rodydavis",
"private": true,
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/rodydavis/lit-vscode-extension"
},
"engines": {
"vscode": "^1.47.0"
},
"categories": [
"Other"
],
"activationEvents": [
"onCommand:lit.start",
"onCommand:lit.reset",
"onWebviewPanel:lit"
],
"main": "./build/extension.js",
"contributes": {
"commands": [
{
"command": "lit.start",
"title": "Open Plugin",
"category": "lit"
},
{
"command": "lit.reset",
"title": "Reset",
"category": "lit"
}
]
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"ext": "tsc src/extension.ts --outdir build --skipLibCheck --module commonjs",
"compile": "npm run build && npm run ext",
"serve": "vite preview"
},
"dependencies": {
"lit": "^2.0.0-rc.2"
},
"devDependencies": {
"@types/node": "^15.12.4",
"@types/vscode": "^1.57.0",
"typescript": "^4.2.3",
"vite": "^2.3.5"
}
}
```
After the `package.json` is updated run `npm i`.
## Template 
Open up the `index.html` and update it with the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit Example</title>
<script type="module" src="/src/my-element.ts"></script>
</head>
<body>
<my-element>
<p>This is child content</p>
</my-element>
</body>
</html>
```
## Web Component 
Open up `src/my-element.ts` and update it with the following:
```
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("my-element")
export class MyElement extends LitElement {
static styles = css`
:host {
display: block;
border: solid 1px gray;
padding: 16px;
max-width: 800px;
}
`;
@property() name = "World";
@state() count = 0;
render() {
return html`
<h1>Hello, ${this.name}!</h1>
<button @click=${() => this.modify(1)} part="button">
Click Count: ${this.count}
</button>
<slot></slot>
`;
}
modify(val: number) {
this.count += val;
}
reset() {
this.count = 0;
}
async firstUpdated() {
window.addEventListener(
"message",
(e: any) => {
const message = e.data;
const { command } = message;
if (command === "reset") {
this.reset();
}
},
false
);
}
}
```
Here we are just modifying the example to include a message listener for communicating with the vscode extension, methods for updating the count, and updating the render method.
VSCode communicates with the plugin via post messages because the UI will be loaded in an `iframe`.
Now let's write the extension code in `src/extension.ts`. First start by adding top level declarations that will be referenced multiple times.
```
import * as vscode from "vscode";
const WEB_DIR: string = "build";
const WEB_SCRIPT: string = "main.js";
const TITLE: string = "Lit Example";
const TAG: string = "my-element";
const possible = [
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"abcdefghijklmnopqrstuvwxyz",
"0123456789",
].join("");
function getNonce() {
let text = "";
for (let i = 0; i < 32; i++) {
const char = possible.charAt(Math.floor(Math.random() * possible.length));
text += char;
}
return text;
}
```
We also are creating a `getNonce` method that will be used for making sure the scripts we load are the ones we passed in.
Now create a `Panel` that will contain the ui for our plugin:
```
class Panel {
public static currentPanel: Panel | undefined;
public static readonly viewType = "litExample";
private _disposables: vscode.Disposable[] = [];
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (Panel.currentPanel) {
Panel.currentPanel.panel.reveal(column);
return;
}
const panel = vscode.window.createWebviewPanel(
Panel.viewType,
TITLE,
column || vscode.ViewColumn.One,
getWebviewOptions(extensionUri)
);
Panel.currentPanel = new Panel(panel, extensionUri);
}
public static revive(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
Panel.currentPanel = new Panel(panel, extensionUri);
}
private constructor(
public readonly panel: vscode.WebviewPanel,
public readonly extensionUri: vscode.Uri
) {
this._update();
this.panel.onDidDispose(() => this.dispose(), null, this._disposables);
this.panel.onDidChangeViewState(
(_) => {
if (this.panel.visible) {
this._update();
}
},
null,
this._disposables
);
this.panel.webview.onDidReceiveMessage(
(message) => {
switch (message.command) {
case "alert":
vscode.window.showErrorMessage(message.text);
return;
}
},
null,
this._disposables
);
}
public sendMessage(command: string) {
this.panel.webview.postMessage({ command: command });
}
public dispose() {
Panel.currentPanel = undefined;
this.panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _update() {
const webview = this.panel.webview;
webview.html = this._getHtmlForWebview(webview);
}
private _getHtmlForWebview(webview: vscode.Webview) {
const scriptPathOnDisk = vscode.Uri.joinPath(
this.extensionUri,
WEB_DIR,
WEB_SCRIPT
);
const scriptUri = webview.asWebviewUri(scriptPathOnDisk);
const nonce = getNonce();
const slot = "<p>This is child content</p>";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${TITLE}</title>
</head>
<body class="vscode-light">
<${TAG} nonce="${nonce}" >
${slot}
</${TAG}>
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
</body>
</html>`;
}
}
```
> Notice how we are recreating the html and not using the `index.html`. This allows us to use the component for a deployed website but also the extension with separate app logic code.
Everything else is just boilerplate for managing the panel state and disposing when it is finished.
Now we can add the methods for loading our plugin and listening for the commands:
```
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("lit.start", () => {
Panel.createOrShow(context.extensionUri);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("lit.reset", () => {
if (Panel.currentPanel) {
Panel.currentPanel.sendMessage("reset");
}
})
);
if (vscode.window.registerWebviewPanelSerializer) {
vscode.window.registerWebviewPanelSerializer(Panel.viewType, {
async deserializeWebviewPanel(
webviewPanel: vscode.WebviewPanel,
state: any
) {
console.log(`Received state: ${state}`);
webviewPanel.webview.options = getWebviewOptions(context.extensionUri);
Panel.revive(webviewPanel, context.extensionUri);
},
});
}
}
function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {
return {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, WEB_DIR)],
};
}
```
Here we are loading the extension and passing messages when the `lit.reset` command. We are also returning the `getWebviewOptions` options which `enableScripts` is set to `true` so we can run the injected js.
The final code should look like the follow:
```
import * as vscode from "vscode";
const WEB_DIR: string = "build";
const WEB_SCRIPT: string = "main.js";
const TITLE: string = "Lit Example";
const TAG: string = "my-element";
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("lit.start", () => {
Panel.createOrShow(context.extensionUri);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("lit.reset", () => {
if (Panel.currentPanel) {
Panel.currentPanel.sendMessage("reset");
}
})
);
if (vscode.window.registerWebviewPanelSerializer) {
vscode.window.registerWebviewPanelSerializer(Panel.viewType, {
async deserializeWebviewPanel(
webviewPanel: vscode.WebviewPanel,
state: any
) {
console.log(`Received state: ${state}`);
webviewPanel.webview.options = getWebviewOptions(context.extensionUri);
Panel.revive(webviewPanel, context.extensionUri);
},
});
}
}
function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {
return {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, WEB_DIR)],
};
}
class Panel {
public static currentPanel: Panel | undefined;
public static readonly viewType = "litExample";
private _disposables: vscode.Disposable[] = [];
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (Panel.currentPanel) {
Panel.currentPanel.panel.reveal(column);
return;
}
const panel = vscode.window.createWebviewPanel(
Panel.viewType,
TITLE,
column || vscode.ViewColumn.One,
getWebviewOptions(extensionUri)
);
Panel.currentPanel = new Panel(panel, extensionUri);
}
public static revive(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
Panel.currentPanel = new Panel(panel, extensionUri);
}
private constructor(
public readonly panel: vscode.WebviewPanel,
public readonly extensionUri: vscode.Uri
) {
this._update();
this.panel.onDidDispose(() => this.dispose(), null, this._disposables);
this.panel.onDidChangeViewState(
(_) => {
if (this.panel.visible) {
this._update();
}
},
null,
this._disposables
);
this.panel.webview.onDidReceiveMessage(
(message) => {
switch (message.command) {
case "alert":
vscode.window.showErrorMessage(message.text);
return;
}
},
null,
this._disposables
);
}
public sendMessage(command: string) {
this.panel.webview.postMessage({ command: command });
}
public dispose() {
Panel.currentPanel = undefined;
this.panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _update() {
const webview = this.panel.webview;
webview.html = this._getHtmlForWebview(webview);
}
private _getHtmlForWebview(webview: vscode.Webview) {
const scriptPathOnDisk = vscode.Uri.joinPath(
this.extensionUri,
WEB_DIR,
WEB_SCRIPT
);
const scriptUri = webview.asWebviewUri(scriptPathOnDisk);
const nonce = getNonce();
const slot = "<p>This is child content</p>";
const htmlSource = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${TITLE}</title>
</head>
<body class="vscode-light">
<${TAG} nonce="${nonce}" >
${slot}
</${TAG}>
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
</body>
</html>`;
return htmlSource;
}
}
const possible = [
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"abcdefghijklmnopqrstuvwxyz",
"0123456789",
].join("");
function getNonce() {
let text = "";
for (let i = 0; i < 32; i++) {
const char = possible.charAt(Math.floor(Math.random() * possible.length));
text += char;
}
return text;
}
```
## Running 
Make sure to install the dependencies by running `npm i`.
To build the extension run `npm run compile`.
To open the extension and debug hit `F5`.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/1pm9yw2b9v259jw/v_1_lcs00w2hlu.webp?thumb=)
To run the commands to open the extension run `lit: open plugin` or `lit: reset`:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4e0aa1s6ck946hy/v_2_g5d9uzk1if.webp?thumb=)
To debug the extension when it is open run `Developer: Open Webview Developer Tools` from the command pallet.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/68mp65w90880e6s/v_3_djp3kxusi7.webp?thumb=)
## Conclusion 
If you want to learn more about building a vscode extension you can read more [here](https://code.visualstudio.com/api) and for Lit you can read the docs [here](https://lit.dev/).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,390 @@
---
name: how-to-build-a-native-cross-platform-project-with-flutter
description: Learn how to import `dart:html` and `dart:io` in the same Flutter project to create cross-platform plugins that work seamlessly on mobile and web.
metadata:
url: https://rodydavis.com/posts/native-cross-platform-flutter
last_modified: Tue, 03 Feb 2026 20:04:21 GMT
---
# How to build a native cross platform project with Flutter
Import dart:html and dart:io in the same project!
> **TLDR** The final source [here](https://github.com/rodydavis/flutter_x/tree/finish).
Up to now you have been able to create projects with Flutter that run on iOS/Android, Web and Desktop but only sharing pure dart plugins.
Flutter launched _Flutter for web_ at Google I/O and was a temporary fork that required you to change imports from `import 'package:flutter/material.dart';` to `import 'package:flutter_web/material.dart';`.
As you can image this was really difficult for a code base as you had to create a fork and change the imports. This also meant that you could not import any package that needed on a path or depended on flutter. The time as come and the merge is complete. Now you no longer need to change the imports!
![](https://rodydavis.com/_/../api/files/pbc_2708086759/5lnog837y4qew5d/n_2_kvxta7kwbn.gif?thumb=)
You can use any plugin now, have a debugger, create new flutter projects with the web folder added, web plugins, and so much more..
## Disclaimer 
You will need to be on the latest flutter for this to work.
[Download Flutter](https://flutter.io/get-started/install/)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/7g4od2ae671i5x5/ff_1_u67ip3pbk5.webp?thumb=)
If you are pretty new to Flutter you can check out [this useful guide](https://flutter.io/get-started/codelab/) on how to create a new project step by step.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/iik1kh354fx05xw/ff_2_9dljl4hgqq.webp?thumb=)
Create a new project named **flutter\_x** and it should look like this:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/0twn40c12rj54an/xx_1_mdevnjnwh7.webp?thumb=)
You can also down the starter project [here](https://github.com/rodydavis/flutter_x/tree/starter).
Your code should look like this:
```
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// Try running your application with "flutter run". You'll see the
// application has a blue toolbar. Then, without quitting the app, try
// changing the primarySwatch below to Colors.green and then invoke
// "hot reload" (press "r" in the console where you ran "flutter run",
// or simply save your changes to "hot reload" in a Flutter IDE).
// Notice that the counter didn't reset back to zero; the application
// is not restarted.
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Invoke "debug painting" (press "p" in the console, choose the
// "Toggle Debug Paint" action from the Flutter Inspector in Android
// Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
// to see the wireframe for each widget.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
```
Just to make sure everything is working go ahead and run the project on iOS/Android.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/h0481r5t5y941le/xx_2_srn7elmk0c.webp?thumb=)
You should have the counter application running and working correctly. Now quit and run on Chrome. It should be listed as a device. You can also run from the command line flutter run -d chrome.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/98ad6z4ts63y87h/xx_3_iq2vozt6xi.webp?thumb=)
> You do not get hot reload yet on web so be aware of that.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/9uowmg8lvubl0vq/xx_4_f6ht1u443m.webp?thumb=)
Your project should now look like this.
Open your pubspec.yaml and import the following packages.
```
dependencies:
universal_html:
url_launcher:
```
> You can also remove the comments generated in the pubspec.yaml
Your pubspec.yaml will now read like this:
```
name: flutter_x
description: A new Flutter project.
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
universal_html: ^1.1.0
url_launcher: ^5.1.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
```
By default if you were to check if the device was mobile or web you will get an error at compile time when trying to import a plugin that is not meant for the platform. To get around this we will use dynamic imports.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/49204t2b88i7035/xx_5_a7g3ejp2xp.webp?thumb=)
Create a url\_launcher folder and file url\_launcher.dart, mobile.dart, web.dart, unsupported.dart inside the plugins folder.
In the file url\_launcher.dart add the following:
```
export 'unsupported.dart'
if (dart.library.html) 'web.dart'
if (dart.library.io) 'mobile.dart';
```
This will pick the correct file at runtime and give a fallback if it is not supported.
To protect against edge cases you will need to set up a fallback for the import. In unsupported.dart add the following:
```
class UrlUtils {
UrlUtils._();
static void open(String url, {String name}) {
throw 'Platform Not Supported';
}
}
```
The class UrlUtils and the public methods have to match all three files for this to work correctly. Always set up the unsupported first then copy the file into mobile.dart and web.dart to ensure no typos.
You should now have 3 files with the above code in each class.
In mobile.dart add the following:
```
import 'package:url_launcher/url_launcher.dart';
class UrlUtils {
UrlUtils._();
static void open(String url, {String name}) async {
if (await canLaunch(url)) {
await launch(url);
}
}
}
```
This will open the link in safari view controller or androids default browser respectively.
In web.dart add the following:
```
import 'package:universal_html/prefer_universal/html.dart' as html;
class UrlUtils {
UrlUtils._();
static void open(String url, {String name}) {
html.window.open(url, name);
}
}
```
This will open up a new window in the browser with the specified link.
Add a button to the center of the screen. The ui/home/screen.dart should read the following:
```
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: RaisedButton(
child: Text('Open Flutter.dev'),
onPressed: () {},
)),
);
}
}
```
Update the onPressed to the following:
```
onPressed: () {
try {
UrlUtils.open('[https://flutter.dev'](https://flutter.dev'));
} catch (e) {
print('Error -> $e');
}
},
```
Now when you go to import the UrlUtils it is important to import the correct URI.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/63m11t1je8ih1q4/xx_6_z13qycty3g.webp?thumb=)
Make sure to import `import 'package:flutter_x/plugins/url_launcher/url_launcher.dart';` only.
> You can use the relative import if you wish.
You UI code will now read the following:
```
import 'package:flutter/material.dart';
import '../../plugins/url_launcher/url_launcher.dart';
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: RaisedButton(
child: Text('Open Flutter.dev'),
onPressed: () {
try {
UrlUtils.open('[https://flutter.dev'](https://flutter.dev'));
} catch (e) {
print('Error -> $e');
}
},
)),
);
}
}
```
Your app on the **web** should look like this:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/g94ot165oy1591t/xx_7_gr9gvvqov4.webp?thumb=)
And when you tap the button..
![](https://rodydavis.com/_/../api/files/pbc_2708086759/km1o16n4jb0j1e2/xx_8_kw9mhvznx0.webp?thumb=)
And when you run it on **iOS**/**Android** it should look like this:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/ouwvvq5o301d722/xx_9_af3wexxmw2.webp?thumb=)
And when you tap the button..
![](https://rodydavis.com/_/../api/files/pbc_2708086759/u289r84ndul1v51/xx_10_c184w6lu7g.webp?thumb=)
Congratulations! You made it 🎉
![](https://rodydavis.com/_/../api/files/pbc_2708086759/o6b1408sh0y188l/xx_11_k1m9m1qs1w.gif?thumb=)
Here is the final project located [here](https://github.com/rodydavis/flutter_x/tree/finish).
Please reach out if you have any questions!
@@ -0,0 +1,110 @@
---
name: how-to-do-offline-recommendations-with-sqlite-and-gemini
description: Learn how to enhance your CMS like PocketBase with AI-powered content recommendations using text embeddings, SQLite, and k-nearest neighbor search for efficient and scalable related content suggestions.
metadata:
url: https://rodydavis.com/posts/offline-vector-recommendations
last_modified: Tue, 03 Feb 2026 20:04:15 GMT
---
# How to do Offline Recommendations with SQLite and Gemini
When working with a CMS (like [PocketBase](https://pocketbase.io)) it is common to add some sort of recommendatios for related content. For example you can have a list of blog posts, and show related posts either by random selection or recently viewed.
I first learned about this technique from [Aaron Francis](https://aaronfrancis.com) on his YouTube channel:
## Text Embeddings
[Text embeddings](https://ai.google.dev/gemini-api/docs/embeddings) are a way to convert a chunk of text into a an array of numbers. Having a mathematical representation means we can easily store them in a database and run common functions to calculate the distances between vectors that we have stored.
> You will need an [API Key from AI Studio](https://aistudio.google.com/apikey) to generate the descriptions and embeddings.
In order to create the embedding we need to first generate chunk small enough to fit in the embedding window size. For example we can use an LLM like [Gemini to generate a description](https://ai.google.dev/gemini-api/docs/text-generation?lang=go) for a blog post and then vectorize the description which we can store in the database.
> We only need to generate a new embedding and description when the content changes which limits the billing costs to the frequency of the content changes.
## Storing the Vectors
To store the text embeddings as vectors we can save them in a [SQLite](https://www.sqlite.org) database using a [runtime loadable extension](https://www.sqlite.org/loadext.html) called [sqlite-vec](https://github.com/asg017/sqlite-vec). Here is an example from the readme on how to query the vectors directly in SQLite:
```
.load ./vec0
create virtual table vec_examples using vec0(
sample_embedding float[8]
);
-- vectors can be provided as JSON or in a compact binary format
insert into vec_examples(rowid, sample_embedding)
values
(1, '[-0.200, 0.250, 0.341, -0.211, 0.645, 0.935, -0.316, -0.924]'),
(2, '[0.443, -0.501, 0.355, -0.771, 0.707, -0.708, -0.185, 0.362]'),
(3, '[0.716, -0.927, 0.134, 0.052, -0.669, 0.793, -0.634, -0.162]'),
(4, '[-0.710, 0.330, 0.656, 0.041, -0.990, 0.726, 0.385, -0.958]');
-- KNN style query
select
rowid,
distance
from vec_examples
where sample_embedding match '[0.890, 0.544, 0.825, 0.961, 0.358, 0.0196, 0.521, 0.175]'
order by distance
limit 2;
/*
┌───────┬──────────────────┐
│ rowid │ distance │
├───────┼──────────────────┤
│ 2 │ 2.38687372207642 │
│ 1 │ 2.38978505134583 │
└───────┴──────────────────┘
*/
```
Now we can just take the vectors we created earlier and store them in a table which can update as content changes.
```
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT.
embeddings TEXT
);
CREATE VIRTUAL TABLE vec_posts USING vec0(
id INTEGER PRIMARY KEY,
embedding float[768]
);
-- Sync vectors
INSERT INTO vec_posts(id, embedding) SELECT id, embeddings FROM posts;
```
> We could also setup triggers to keep them up to date but in PocketBase I am using event hooks to keep the virtual table udpated.
## Generate the Recommendation
Now to generate the recommendation offline we just need to use one of the blog posts to use as the input query to then use [k-nearest neighbor search (kNN)](https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html) to get N number of related posts.
```
SELECT
vec_posts.id as id,
vec_posts.embedding as embedding,
posts.title as title,
posts.description as description,
posts.slug as slug
FROM vec_posts
INNER JOIN vec_posts.id = posts.id
WHERE embedding match ?
AND k = 6
ORDER BY distance;
```
We just need to provide the ? argument with the vector of the currently selected blog post, and then after we filter out the current blog post from the list then we have the N closest number of blog posts that are related in a vector database.
## Conclusion
This makes it so no matter how many times a blog posts is visited no network calls are made for the recommendation which enables this to scale really well.
To see this in action you can click around on the various blog posts I have on my site and see the generated descriptions and related posts at the end of each article.
+94
View File
@@ -0,0 +1,94 @@
---
name: how-to-deploy-pocketbase-to-cloud-run
description: Learn how to deploy PocketBase on Google Cloud Run using the new volume mounting feature, enabling scale-to-zero, infinite storage, and easy backups.
metadata:
url: https://rodydavis.com/posts/pocketbase-cloudrun
last_modified: Tue, 03 Feb 2026 20:04:35 GMT
---
# How to Deploy PocketBase to Cloud Run
It is now possible to run [PocketBase](https://pocketbase.io/) on Google [CloudRun](https://cloud.google.com/run?hl=en) because of the recent support for [mounting volumes](https://cloud.google.com/run/docs/configuring/services/cloud-storage-volume-mounts). This is a guide on how to deploy PocketBase on Google Cloud Run.
## Features 
* Scale to zero
* Infinite storage (and file deletion protection, file versions, and multi region)
* `pb_data`/`pb_public`/`pb_hooks` all in the same file system
* Backups can be done either by PocketBase or by protecting the bucket
## Prerequisites 
* Google Cloud project
* Google Cloud Storage bucket
## Getting Started 
Fork [this repository](https://github.com/rodydavis/pocketbase-cloudrun/tree/main) or click "Use this template" to create your own repository.
## Steps 
### Create a new service
![](https://rodydavis.com/_/../api/files/pbc_2708086759/f82j3od8qh31k3e/pb_cloud_run_1_3qclbfac0c.png?thumb=)
#### Google Cloud Build 
* Setup with Cloud Build
* Repository Provider: `GitHub`
* Select Repository: `THIS_REPOSITORY_FORK`
* Branch: `main`
* Build Configuration: `Dockerfile`
#### General Settings 
* Allow unauthenticated invocations
* CPU is only allocated when the service is handling requests
* Maximum number of requests per container is set to `1000`
* Maximum number of containers is set to `1`
* Timeout is set to `3600`
* Ingress is set to internal and `all` traffic
#### Container(s), Volumes, Networking, Security 
##### Volumes 
* Add volume
* Volume type: `Google Storage bucket`
* Volume name: `remote-storage (or any name you want)`
* Bucket: `YOUR_BUCKET_NAME`
* Read-only: `false`
##### Container(s) 
* Startup CPU boost is `enabled`
* Volume mount (s)
* Volume name: `remote-storage`
* Mount path: `/cloud/storage`
#### Add Health Checks 
You can add a health check to your service that uses Pocketbase's health check endpoint `/api/health`.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4vvzz03t70mwkg8/pb_cloud_run_2_7911u5glhr.png?thumb=)
### Deploy and Wait 
Now create the service and wait for the cloud build to finish.
If everything goes well, you should see the service deployed.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/3a6b82polq5n22h/pb_cloud_run_3_y6n1ukkd8u.png?thumb=)
## FAQ 
### What if I have local files that I want to use? 
`pb_data`, `pb_public`, and `pb_hooks` are all directories you might use during development.
You can upload these directories to your Google Cloud Storage bucket you created earlier to the root directory.
### Can I use a custom domain? 
Yes, you can use a custom domain. You can follow the guide on the [official documentation](https://cloud.google.com/run/docs/mapping-custom-domains).
@@ -0,0 +1,524 @@
---
name: how-to-build-a-webrtc-signal-server-with-pocketbase
description: Learn how to build a simple WebRTC video call application using PocketBase as a signaling server, enabling peer-to-peer communication with SQLite on the server and realtime updates via Server Sent Events.
metadata:
url: https://rodydavis.com/posts/pocketbase-webrtc-signal-server-js
last_modified: Tue, 03 Feb 2026 20:04:35 GMT
---
# How to Build a WebRTC Signal Server with PocketBase
## Overview 
If you are new to WebRTC then I suggest checking out this great Fireship video on [WebRTC in 100 seconds](https://youtu.be/WmR9IMUD_CY?si=c6xEDVslDOsIJzyP):
Also if you are looking for a [Firebase](https://firebase.google.com/) example then check out [this repository](https://github.com/fireship-io/webrtc-firebase-demo) which this example is largely based on.
This example is built using [PocketBase](https://pocketbase.io/) as the [signal server](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling) for [WebRTC](https://webrtc.org/) and runs [SQLite](https://www.sqlite.org/index.html) on the server with easy to use realtime SDKs built on top of [Server Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events).
## Setting up the server 
[Download PocketBase](https://pocketbase.io/docs/) and create a new directory that we will use for the project.
```
mkdir webrtc-pocketbase-demo
cd webrtc-pocketbase-demo
```
Copy the PocketBase binary into the directory you just created under a sub directory `.pb`. If you are on MacOS you will need to [allow the executable](https://discussions.apple.com/thread/253681758) to run in settings.
Start the PocketBase server with the following command:
```
.pb/pocketbase serve
```
If all goes well you should see the following:
```
2023/11/04 15:10:56 Server started at http://127.0.0.1:8090
├─ REST API: http://127.0.0.1:8090/api/
└─ Admin UI: http://127.0.0.1:8090/_/
```
Open up the Admin UI url and create a new username and password.
For this example the email and password will be the following:
Key
Value
Email
[\[email protected\]](/cdn-cgi/l/email-protection#dca8b9afa89cb9a4bdb1acb0b9f2bfb3b1)
Password
Test123456789
You should now see the following:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/770w5ti1xz64rxd/web_rtc_1_sxepcsqwya.png?thumb=)
### Creating the collections 
#### ice\_servers 
Create a new collection named `ice_servers` with the following columns:
Column Name
Column Type
url
Plain text
![](https://rodydavis.com/_/../api/files/pbc_2708086759/x4211ub82294ju0/web_rtc_2_18muflf746.png?thumb=)
Add the following API rule to the List/Search and View:
```
@request.auth.id != ''
```
![](https://rodydavis.com/_/../api/files/pbc_2708086759/k18h527ufe4968j/web_rtc_3_zhpjwks4he.png?thumb=)
After the collection is created add 2 records for each of the following values for the url:
```
stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302
```
![](https://rodydavis.com/_/../api/files/pbc_2708086759/zg457nlf42en421/web_rtc_4_u30xyj67qq.png?thumb=)
#### calls 
Create a new collection named `calls` with the following columns:
Column Name
Column Type
Column Settings
user\_id
Relation
Non empty, `users`, Cascade delete is `true`
offer
JSON
 
answer
JSON
 
![](https://rodydavis.com/_/../api/files/pbc_2708086759/snt87v3djk6jsny/web_rtc_5_3x9zuluckq.png?thumb=)
it is also possible to limit the user to one call each by setting the Unique constraint on the `user_id` column.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/463dxsby5b402f1/web_rtc_6_epid0nfuym.png?thumb=)
Add the following API rule to all of the methods:
```
@request.auth.id != ''
```
![](https://rodydavis.com/_/../api/files/pbc_2708086759/ugq1ng867h2pn05/web_rtc_7_6pyvjpk8jx.png?thumb=)
#### offer\_candidates 
Create a new collection named `offer_candidates` with the following columns:
Column Name
Column Type
Column Settings
call\_id
Relation
Non empty, `calls`, Cascade delete is `true`
data
JSON
 
![](https://rodydavis.com/_/../api/files/pbc_2708086759/488t72n0h2t2623/web_rtc_8_c1ecscf02n.png?thumb=)
Add the following API rule to all of the methods:
```
@request.auth.id != ''
```
#### answer\_candidates 
Create a new collection named `answer_candidates` with the following columns:
Column Name
Column Type
Column Settings
call\_id
Relation
Non empty, `calls`, Cascade delete is `true`
data
JSON
 
![](https://rodydavis.com/_/../api/files/pbc_2708086759/0026839i7759o09/web_rtc_9_8urvznju5m.png?thumb=)
Add the following API rule to all of the methods:
```
@request.auth.id != ''
```
![](https://rodydavis.com/_/../api/files/pbc_2708086759/406zf6e649j6m7k/web_rtc_10_rj34gi9mwc.png?thumb=)
#### users 
For demo purposes we will not be including an auth form for the user, but to make the example simple create a new user with the same login info for the admin.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/y0w1qe8tz830220/web_rtc_11_a359eqpxdy.png?thumb=)
![](https://rodydavis.com/_/../api/files/pbc_2708086759/vxze4jkkk5b4o3q/web_rtc_12_m6rwgbmoum.png?thumb=)
## Setting up the client 
Navigate to the directory and run the following commands to get started:
 
```
npm init -y
npm i -D vite
npm i pocketbase
```
Update the `package.json` to be the following:
```
{
"name": "webrtc-pocketbase-demo",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"devDependencies": {
"vite": "^4.5.0"
},
"dependencies": {
"pocketbase": "^0.19.0"
}
}
```
If you are in a Git repository update/create the `.gitignore` to have the following:
```
node_modules
.DS_Store
dist
dist-ssr
*.local
.pb
.env
```
### HTML 
Create `index.html` and add the following:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebRTC Pocketbase Demo</title>
</head>
<body>
<h2>1. Start your Webcam</h2>
<div class="videos">
<span>
<h3>Local Stream</h3>
<video id="webcamVideo" autoplay playsinline></video>
</span>
<span>
<h3>Remote Stream</h3>
<video id="remoteVideo" autoplay playsinline></video>
</span>
</div>
<button id="webcamButton">Start webcam</button>
<h2>2. Create a new Call</h2>
<button id="callButton" disabled>Create Call (offer)</button>``
<h2>3. Join a Call</h2>
<p>Answer the call from a different browser window or device</p>
<input id="callInput" />
<button id="answerButton" disabled>Answer</button>
<h2>4. Hangup</h2>
<button id="hangupButton" disabled>Hangup</button>
<script type="module" src="/main.js"></script>
</body>
</html>
```
### CSS 
Create `style.css` and add the following:
```
body {
--text-color: #2c3e50;
--video-background-color: #2c3e50;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: var(--text-color);
margin: 80px 10px;
}
video {
width: 40vw;
height: 30vw;
margin: 2rem;
background: var(--video-background-color);
}
.videos {
display: flex;
align-items: center;
justify-content: center;
}
```
### JS 
Create `main.js` and add the following:
```
import "./style.css";
import PocketBase from "pocketbase";
const pb = new PocketBase("http://127.0.0.1:8090");
const calls = pb.collection("calls");
const offerCandidates = pb.collection("offer_candidates");
const answerCandidates = pb.collection("answer_candidates");
const webcamButton = document.getElementById("webcamButton");
const webcamVideo = document.getElementById("webcamVideo");
const callButton = document.getElementById("callButton");
const callInput = document.getElementById("callInput");
const answerButton = document.getElementById("answerButton");
const remoteVideo = document.getElementById("remoteVideo");
const hangupButton = document.getElementById("hangupButton");
const auth = await pb
.collection("users")
.authWithPassword(
import.meta.env.VITE_POCKETBASE_USERNAME,
import.meta.env.VITE_POCKETBASE_PASSWORD
);
const userId = auth.record.id;
const iceServers = await pb.collection("ice_servers").getFullList();
const servers = {
iceServers: [{ urls: iceServers.map((e) => e.url) }],
iceCandidatePoolSize: 10,
};
const pc = new RTCPeerConnection(servers);
let localStream = null;
let remoteStream = null;
webcamButton.onclick = async () => {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
remoteStream = new MediaStream();
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
pc.ontrack = (event) => {
const stream = event.streams[0];
stream.getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};
webcamVideo.srcObject = localStream;
remoteVideo.srcObject = remoteStream;
callButton.disabled = false;
answerButton.disabled = false;
webcamButton.disabled = true;
};
callButton.onclick = async () => {
const call = await calls.create({
user_id: userId,
});
const callId = call.id;
callInput.value = callId;
pc.onicecandidate = (event) => {
event.candidate &&
offerCandidates.create({
call_id: callId,
data: event.candidate.toJSON(),
});
};
const offerDescription = await pc.createOffer();
await pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await calls.update(callId, { offer });
calls.subscribe(callId, (e) => {
const data = e.record;
if (!pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
pc.setRemoteDescription(answerDescription);
}
});
answerCandidates.subscribe("*", (e) => {
if (e.action === "create") {
if (e.record?.call_id === callId) {
const data = e.record.data;
const candidate = new RTCIceCandidate(data);
pc.addIceCandidate(candidate);
}
}
});
hangupButton.disabled = false;
};
answerButton.onclick = async () => {
const callId = callInput.value;
const call = await calls.getOne(callId);
pc.onicecandidate = (event) => {
event.candidate &&
answerCandidates.create({
call_id: call.id,
data: event.candidate.toJSON(),
});
};
const offerDescription = call.offer;
const remoteDescription = new RTCSessionDescription(offerDescription);
await pc.setRemoteDescription(remoteDescription);
const answerDescription = await pc.createAnswer();
await pc.setLocalDescription(answerDescription);
const answer = {
type: answerDescription.type,
sdp: answerDescription.sdp,
};
await calls.update(call.id, { answer });
offerCandidates.subscribe("*", async (e) => {
if (e.record?.call_id === call.id) {
if (e.action === "create") {
const data = e.record.data;
const candidate = new RTCIceCandidate(data);
await pc.addIceCandidate(candidate);
} else if (e.action === "delete") {
await offerCandidates.unsubscribe();
window.location.reload();
}
}
});
};
hangupButton.onclick = async () => {
const callId = callInput.value;
pc.close();
await calls.unsubscribe(callId);
await calls.delete(callId);
await answerCandidates.unsubscribe();
window.location.reload();
};
```
## Running the example 
Run the following command to start the client (make sure the server is running in a separate terminal client):
```
npm run dev
```
If successful you should see the following:
```
VITE v4.5.0 ready in 547 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help
```
Open up two browsers with the same url:
![](https://rodydavis.com/_/../api/files/pbc_2708086759/05p16897oei07x6/web_rtc_13_9b6nwkhq3i.png?thumb=)
In the first window click `Start webcam` and then `Create Call (offer)`.
This will ask for camera permission and then generate a new id and add it to the `Join a Call` text field.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/j3436r8199114m1/web_rtc_14_rat2efjyy3.png?thumb=)
Copy the new id and paste it in the second window field and click `Start webcam`.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/4nlh0c1566v17pn/web_rtc_15_438n00ql98.png?thumb=)
Then click `Hangup` when you are done with the call 🎉.
## Conclusion 
You can find the source code [here](https://github.com/rodydavis/webrtc-pocketbase-demo).
@@ -0,0 +1,154 @@
---
name: how-to-send-push-notifications-on-flutter-web-fcm
description: Learn how to implement Firebase Cloud Messaging (FCM) in your Flutter web app with this guide, covering service worker setup, helper methods, and testing to enable push notifications.
metadata:
url: https://rodydavis.com/posts/push-notifications-flutter-web
last_modified: Tue, 03 Feb 2026 20:04:20 GMT
---
# How To Send Push Notifications on Flutter Web (FCM)
If you are using Firebase then you are probably familiar with Firebase Cloud Messaging. The setup on Flutter web is very different than mobile and other plugins you are probably used to.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/my959qff0pmtrag/p_1_sml0ik8i1v.webp?thumb=)
## Setting Up 
Open your web/index.html and look for the following script. If you do not have one you can add it now in the tag.
```
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/flutter_service_worker.js");
});
}
</script>
```
We need to modify it to support the FCM service worker. The important thing we need to do is comment out the flutter\_service\_worker.js so that we will not get 404 errors when registering the FCM service worker.
```
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
// navigator.serviceWorker.register("/flutter_service_worker.js");
navigator.serviceWorker.register("/firebase-messaging-sw.js");
});
}
</script>
```
Now create a new file called firebase-messaging-sw.js in the web folder with the following contents:
```
importScripts("https://www.gstatic.com/firebasejs/7.5.0/firebase-app.js");
importScripts("https://www.gstatic.com/firebasejs/7.5.0/firebase-messaging.js");
firebase.initializeApp({
apiKey: "API_KEY",
authDomain: "AUTH_DOMAIN",
databaseURL: "DATABASE_URL",
projectId: "PROJECT_ID",
storageBucket: "STORAGE_BUCKET",
messagingSenderId: "MESSAGING_SENDER_ID",
appId: "APP_ID",
measurementId: "MEASUREMENT_ID"
});
const messaging = firebase.messaging();
messaging.setBackgroundMessageHandler(function (payload) {
const promiseChain = clients
.matchAll({
type: "window",
includeUncontrolled: true
})
.then(windowClients => {
for (let i = 0; i < windowClients.length; i++) {
const windowClient = windowClients[i];
windowClient.postMessage(payload);
}
})
.then(() => {
return registration.showNotification("New Message");
});
return promiseChain;
});
self.addEventListener('notificationclick', function (event) {
console.log('notification received: ', event)
});
```
Make sure to replace the config keys with your firebase keys.
## Helper Methods 
Create a new dart file wherever you like named firebase\_messaging.dart with the following:
```
import 'dart:async';
import 'package:firebase/firebase.dart' as firebase;
class FBMessaging {
FBMessaging._();
static FBMessaging _instance = FBMessaging._();
static FBMessaging get instance => _instance;
firebase.Messaging _mc;
String _token;
final _controller = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get stream => _controller.stream;
void close() {
_controller?.close();
}
Future<void> init() async {
_mc = firebase.messaging();
_mc.usePublicVapidKey('FCM_SERVER_KEY');
_mc.onMessage.listen((event) {
_controller.add(event?.data);
});
}
Future requestPermission() {
return _mc.requestPermission();
}
Future<String> getToken([bool force = false]) async {
if (force || _token == null) {
await requestPermission();
_token = await _mc.getToken();
}
return _token;
}
}
```
Create a button in the app that will be used to request permissions. While it is possible to request for permission when the app launches this is usually bad practice as the user is unlikely to accept and there is no trust built yet. You can request permissions with the following:
```
final _messaging = FBMessaging.instance;
_messaging.requestPermission().then((_) async {
final _token = await _messaging.getToken();
print('Token: $_token');
});
```
You can listen to messages with the following:
```
final _messaging = FBMessaging.instance;
_messaging.stream.listen((event) {
print('New Message: ${event}');
});
```
## Testing 
Now when you run your application and request permissions you will get a token back. With this token you can open the firebase console and sent a test message to the token.
![](https://rodydavis.com/_/../api/files/pbc_2708086759/yjgc27t37094pwo/p_2_cab21gsn50.webp?thumb=)
## Conclusion 
Now you can send push notifications to Flutter apps! You still need to use conditional imports to support the mobile side as well but stay tuned for an example with that. Let me know your questions and any feedback you may have.
+255
View File
@@ -0,0 +1,255 @@
---
name: signals-and-flutter-hooks
description: Explore state management in Flutter, from the basics of `setState` to advanced techniques using ValueNotifier, Signals, Flutter Hooks, and the new signals_hooks package for a reactive and efficient approach.
metadata:
url: https://rodydavis.com/posts/signals-and-flutter-hooks
last_modified: Tue, 03 Feb 2026 20:04:15 GMT
---
# Signals and Flutter Hooks
When working with data in [Flutter](https://flutter.dev), on of the first things you are exposed to is [setState](https://api.flutter.dev/flutter/widgets/State/setState.html).
## setState
```
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: Counter()));
}
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0;
void increment() {
if (mounted) {
setState(() {
count++;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(child: Text('Count: $count')),
floatingActionButton: FloatingActionButton(
onPressed: increment,
child: const Icon(Icons.add),
),
);
}
}
```
This simply marks the widget as dirty every time you call **setState** but requires you (as the developer) to be mindful and explict about when those updates happen. If you forget to call **setState** when mutating data the widget tree can become stale.
## ValueNotifier
We can impove this by using [ValueNotifier](https://api.flutter.dev/flutter/foundation/ValueNotifier-class.html) instead of storing the value directly. This gives us the ability to read and write a value in a container and use helper widgets like [ValueListenableBuilder](https://api.flutter.dev/flutter/widgets/ValueListenableBuilder-class.html) to update sub parts of the widget tree on value changes.
```
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: Counter()));
}
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
final count = ValueNotifier(0);
void increment() {
count.value++;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(child: ValueListenableBuilder(
valueListenable: count,
builder: (context, value, child) {
return Text('Count: $value');
}
)),
floatingActionButton: FloatingActionButton(
onPressed: increment,
child: const Icon(Icons.add),
),
);
}
}
```
## FlutterSignal
Using the [signals](https://pub.dev/packages/signals) package we can upgrade ValueNotifier to a [signal backed implmentation](https://preactjs.com/guide/v10/signals/) which uses a reactive graph based on a push / pull architecture.
```
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
void main() {
runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: Counter()));
}
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
final count = signal(0);
void increment() {
count.value++;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: ValueListenableBuilder(
valueListenable: count,
builder: (context, value, child) {
return Text('Count: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: increment,
child: const Icon(Icons.add),
),
);
}
}
```
> Signals created after **6.0.0** also implement ValueNotifier so you can easily migrate them without changing any other code.
Instead of ValueListenableBuilder we can use the Watch widget or .watch(context) extension.
```
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
void main() {
runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: Counter()));
}
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
final count = signal(0);
void increment() {
count.value++;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Text('Count: ${count.watch(context)}'),
),
floatingActionButton: FloatingActionButton(
onPressed: increment,
child: const Icon(Icons.add),
),
);
}
}
```
## flutter\_hooks
Using [Flutter Hooks](https://pub.dev/packages/flutter_hooks) we can reduce boilerplate of StatefulWidget by switching to a HookWidget. With **useState** we can define the state directly in the build method and easily share them across widgets.
```
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void main() {
runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: Counter()));
}
class Counter extends HookWidget {
const Counter({super.key});
@override
Widget build(BuildContext context) {
final count = useState(0);
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(child: Text('Count: ${count.value}')),
floatingActionButton: FloatingActionButton(
onPressed: () => count.value++,
child: const Icon(Icons.add),
),
);
}
}
```
> **useState** returns a ValueNotifier that automatically rebuilds the widget on changes
## signals\_hooks
Using a new package [signals\_hooks](https://pub.dev/packages/signals_hooks) we can now define signals in HookWidgets and have the benifits of a reactive graph with shareable lifecycles between widgets.
```
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:signals_hooks/signals_hooks.dart';
void main() {
runApp(const MaterialApp(debugShowCheckedModeBanner: false, home: Counter()));
}
class Counter extends HookWidget {
const Counter({super.key});
@override
Widget build(BuildContext context) {
final count = useSignal(0);
final countStr = useComputed(() => count.value.toString());
useSignalEffect(() {
print('count: $count');
});
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(child: Text('Count: $countStr')),
floatingActionButton: FloatingActionButton(
onPressed: () => count.value++,
child: const Icon(Icons.add),
),
);
}
}
```
@@ -0,0 +1,392 @@
---
name: flutter-infinite-canvas
description: Learn how to build an infinite, multi-touch canvas in Flutter using InteractiveViewer and CustomMultiChildLayout for a flexible and interactive user experience.
metadata:
url: https://rodydavis.com/posts/snippets/flutter-infinite-canvas
last_modified: Tue, 03 Feb 2026 20:04:31 GMT
---
# Flutter Infinite Canvas
## Overview 
The following is an example of how to build an infinite canvas with [InteractiveViewer](https://api.flutter.dev/flutter/widgets/InteractiveViewer-class.html) and [CustomMultiChildLayout](https://api.flutter.dev/flutter/widgets/CustomMultiChildLayout-class.html).
Blog post: [Create a multi touch canvas in Flutter](https://rodydavis.com/posts/flutter-multi-touch-canvas)
```
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' hide Colors;
void main() {
final controller = WidgetCanvasController([
WidgetCanvasChild(
key: UniqueKey(),
offset: Offset.zero,
size: const Size(400, 800),
child: Scaffold(
appBar: AppBar(
title: const Text('Scaffold 1'),
),
body: const Placeholder(),
),
),
WidgetCanvasChild(
key: UniqueKey(),
offset: const Offset(200, 200),
size: const Size(400, 800),
child: Scaffold(
appBar: AppBar(
title: const Text('Scaffold 2'),
),
body: const Placeholder(),
),
),
]);
runApp(MaterialApp(home: WidgetCanvas(controller: controller)));
}
class WidgetCanvas extends StatefulWidget {
const WidgetCanvas({super.key, required this.controller});
final WidgetCanvasController controller;
@override
State<WidgetCanvas> createState() => WidgetCanvasState();
}
class WidgetCanvasState extends State<WidgetCanvas> {
@override
void initState() {
super.initState();
controller.addListener(onUpdate);
}
@override
void dispose() {
controller.removeListener(onUpdate);
super.dispose();
}
void onUpdate() {
if (mounted) setState(() {});
}
static const Size _gridSize = Size.square(50);
WidgetCanvasController get controller => widget.controller;
Rect axisAlignedBoundingBox(Quad quad) {
double xMin = quad.point0.x;
double xMax = quad.point0.x;
double yMin = quad.point0.y;
double yMax = quad.point0.y;
for (final Vector3 point in <Vector3>[
quad.point1,
quad.point2,
quad.point3,
]) {
if (point.x < xMin) {
xMin = point.x;
} else if (point.x > xMax) {
xMax = point.x;
}
if (point.y < yMin) {
yMin = point.y;
} else if (point.y > yMax) {
yMax = point.y;
}
}
return Rect.fromLTRB(xMin, yMin, xMax, yMax);
}
@override
Widget build(BuildContext context) {
const inset = 2.0;
return Listener(
onPointerDown: (details) {
controller.mouseDown = true;
controller.checkSelection(details.localPosition);
},
onPointerUp: (details) {
controller.mouseDown = false;
},
onPointerCancel: (details) {
controller.mouseDown = false;
},
onPointerMove: (details) {},
child: LayoutBuilder(
builder: (context, constraints) => InteractiveViewer.builder(
transformationController: controller.transform,
panEnabled: controller.canvasMoveEnabled,
scaleEnabled: controller.canvasMoveEnabled,
onInteractionStart: (details) {
controller.mousePosition = details.focalPoint;
},
onInteractionUpdate: (details) {
if (!controller.mouseDown) {
controller.scale = details.scale;
} else {
controller.moveSelection(details.focalPoint);
}
controller.mousePosition = details.focalPoint;
},
onInteractionEnd: (details) {},
minScale: 0.4,
maxScale: 4,
boundaryMargin: const EdgeInsets.all(double.infinity),
builder: (context, viewport) {
return SizedBox(
width: 1,
height: 1,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: GridBackgroundBuilder(
cellWidth: _gridSize.width,
cellHeight: _gridSize.height,
viewport: axisAlignedBoundingBox(viewport),
),
),
Positioned.fill(
child: CustomMultiChildLayout(
delegate: WidgetCanvasDelegate(controller),
children: controller.children.map((e) {
return LayoutId(
id: e,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Material(
elevation: 4,
child: SizedBox.fromSize(
size: e.size,
child: e.child,
),
),
),
if (controller.isSelected(e.key!))
Positioned.fill(
top: -inset,
left: -inset,
right: -inset,
bottom: -inset,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.blue,
width: 1,
),
),
),
),
],
));
}).toList(),
),
),
],
),
);
},
),
),
);
}
}
class GridBackgroundBuilder extends StatelessWidget {
const GridBackgroundBuilder({
super.key,
required this.cellWidth,
required this.cellHeight,
required this.viewport,
});
final double cellWidth;
final double cellHeight;
final Rect viewport;
@override
Widget build(BuildContext context) {
final int firstRow = (viewport.top / cellHeight).floor();
final int lastRow = (viewport.bottom / cellHeight).ceil();
final int firstCol = (viewport.left / cellWidth).floor();
final int lastCol = (viewport.right / cellWidth).ceil();
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
for (int row = firstRow; row < lastRow; row++)
for (int col = firstCol; col < lastCol; col++)
Positioned(
left: col * cellWidth,
top: row * cellHeight,
child: Container(
height: cellHeight,
width: cellWidth,
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.withOpacity(0.1),
width: 1,
),
),
),
),
],
);
}
}
class WidgetCanvasDelegate extends MultiChildLayoutDelegate {
WidgetCanvasDelegate(this.controller);
final WidgetCanvasController controller;
List<WidgetCanvasChild> get children => controller.children;
Size backgroundSize = const Size(100000, 100000);
late Offset backgroundOffset = Offset(
-backgroundSize.width / 2,
-backgroundSize.height / 2,
);
@override
void performLayout(Size size) {
// Then draw the screens.
for (final widget in children) {
layoutChild(widget, BoxConstraints.tight(widget.size));
positionChild(widget, widget.offset);
}
}
@override
bool shouldRelayout(WidgetCanvasDelegate oldDelegate) => true;
}
class WidgetCanvasChild extends StatelessWidget {
const WidgetCanvasChild({
required Key key,
required this.size,
required this.offset,
required this.child,
}) : super(key: key);
final Size size;
final Offset offset;
final Widget child;
Rect get rect => offset & size;
WidgetCanvasChild copyWith({
Size? size,
Offset? offset,
Widget? child,
}) {
return WidgetCanvasChild(
key: key!,
size: size ?? this.size,
offset: offset ?? this.offset,
child: child ?? this.child,
);
}
@override
Widget build(BuildContext context) {
return child;
}
}
class WidgetCanvasController extends ChangeNotifier {
WidgetCanvasController(this.children);
final List<WidgetCanvasChild> children;
final Set<Key> _selected = {};
late final transform = TransformationController();
Matrix4 get matrix => transform.value;
double scale = 1;
Offset mousePosition = Offset.zero;
bool _mouseDown = false;
bool get mouseDown => _mouseDown;
set mouseDown(bool value) {
_mouseDown = value;
notifyListeners();
}
bool isSelected(Key key) => _selected.contains(key);
bool get hasSelection => _selected.isNotEmpty;
bool get canvasMoveEnabled => !mouseDown;
Offset toLocal(Offset global) {
return transform.toScene(global);
}
void checkSelection(Offset localPosition) {
final offset = toLocal(localPosition);
final selection = <Key>[];
for (final child in children) {
final rect = child.rect;
if (rect.contains(offset)) {
selection.add(child.key!);
}
}
if (selection.isNotEmpty) {
setSelection({selection.last});
} else {
deselectAll();
}
}
void moveSelection(Offset position) {
final delta = toLocal(position) - toLocal(mousePosition);
for (final key in _selected) {
final index = children.indexWhere((e) => e.key == key);
if (index == -1) continue;
final current = children[index];
children[index] = current.copyWith(
offset: current.offset + delta,
);
}
mousePosition = position;
notifyListeners();
}
void select(Key key) {
_selected.add(key);
notifyListeners();
}
void setSelection(Set<Key> keys) {
_selected.clear();
_selected.addAll(keys);
notifyListeners();
}
void deselect(Key key) {
_selected.remove(key);
notifyListeners();
}
void deselectAll() {
_selected.clear();
notifyListeners();
}
void add(WidgetCanvasChild child) {
children.add(child);
notifyListeners();
}
void remove(Key key) {
children.removeWhere((e) => e.key == key);
notifyListeners();
}
}
```
## Demo
@@ -0,0 +1,309 @@
---
name: flutter-input-output-preview
description: Build responsive Flutter apps with a reusable `TwoPane` widget and an `InputOutputPreview` component for side-by-side code and preview display on both mobile and desktop.
metadata:
url: https://rodydavis.com/posts/snippets/flutter-input-output-preview
last_modified: Tue, 03 Feb 2026 20:04:31 GMT
---
# Flutter Input Output Preview
First we need a two pane widget to properly render on mobile and desktop:
```
import 'package:flutter/material.dart';
class TwoPane extends StatefulWidget {
const TwoPane({
super.key,
required this.primary,
required this.secondary,
required this.title,
this.actions = const [],
this.loading = false,
});
final (String, WidgetBuilder) primary, secondary;
final List<Widget> actions;
final String title;
final bool loading;
@override
State<TwoPane> createState() => _TwoPaneState();
}
class _TwoPaneState extends State<TwoPane> {
bool darkMode = false;
void toggleDarkMode() {
if (mounted) {
setState(() {
darkMode = !darkMode;
});
}
}
ThemeData theme(Color color, Brightness brightness) {
return ThemeData(
brightness: brightness,
colorScheme: ColorScheme.fromSeed(
seedColor: color,
brightness: brightness,
),
useMaterial3: true,
);
}
@override
void didUpdateWidget(covariant TwoPane oldWidget) {
if (oldWidget.loading != widget.loading ||
oldWidget.title != widget.title ||
oldWidget.actions != widget.actions) {
if (mounted) setState(() {});
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return Theme(
data: theme(Colors.purple, darkMode ? Brightness.dark : Brightness.light),
child: Builder(builder: (context) {
final (primaryTitle, primaryBuilder) = widget.primary;
final (secondaryTitle, secondaryBuilder) = widget.secondary;
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
centerTitle: false,
actions: [
IconButton(
tooltip: 'Toggle dark mode',
onPressed: toggleDarkMode,
icon: Icon(darkMode ? Icons.light_mode : Icons.dark_mode),
),
...widget.actions,
],
),
body: LayoutBuilder(
builder: (context, dimens) {
if (dimens.maxWidth > 800 && dimens.maxHeight > 600) {
return Column(
children: [
if (widget.loading) const LinearProgressIndicator(),
Expanded(
child: Row(
children: [
Flexible(
flex: 1,
child: primaryBuilder(context),
),
Flexible(
flex: 1,
child: secondaryBuilder(context),
),
],
),
),
],
);
}
return DefaultTabController(
length: 2,
child: Column(
children: [
SizedBox(
height: kToolbarHeight,
width: double.infinity,
child: TabBar(
tabs: [
Tab(text: primaryTitle),
Tab(text: secondaryTitle),
],
),
),
if (widget.loading) const LinearProgressIndicator(),
Expanded(
child: TabBarView(
children: [
primaryBuilder(context),
secondaryBuilder(context),
],
),
),
],
),
);
},
));
}),
);
}
}
```
Then we can pass some text fields for one pane to render an output:
```
import 'package:flutter/material.dart';
import 'two_pane.dart';
class InputOutputPreview extends StatefulWidget {
const InputOutputPreview({
super.key,
required this.title,
required this.input,
required this.output,
required this.preview,
required this.placeholder,
this.actions = const [],
this.codeTitle = 'Code',
this.previewTitle = 'Preview',
this.loading = false,
this.lazy = false,
this.previewSize = const Size(300, 700),
});
final (
String,
ValueChanged<(TextEditingController, TextEditingController)>
) input, output;
final Widget? preview;
final Widget placeholder;
final String title;
final List<Widget> actions;
final String codeTitle, previewTitle;
final Size? previewSize;
final bool loading;
final bool lazy;
@override
State<InputOutputPreview> createState() => _InputOutputPreviewState();
}
class _InputOutputPreviewState extends State<InputOutputPreview> {
final input = TextEditingController();
final output = TextEditingController();
String? lastInput;
String? lastOutput;
@override
void initState() {
super.initState();
if (!widget.lazy) input.addListener(onInput);
output.addListener(onOutput);
}
@override
void dispose() {
super.dispose();
if (!widget.lazy) input.removeListener(onInput);
output.removeListener(onOutput);
input.dispose();
output.dispose();
}
void onInput() {
final (_, update) = widget.input;
final str = input.text;
if (lastInput == str) return;
update((input, output));
lastInput = str;
}
void onOutput() {
final (_, update) = widget.output;
final str = output.text;
if (lastOutput == str) return;
update((output, input));
lastOutput = str;
}
@override
Widget build(BuildContext context) {
final (inputTitle, _) = widget.input;
final (outputTitle, _) = widget.output;
return TwoPane(
title: widget.title,
actions: widget.actions,
loading: widget.loading,
primary: (
widget.codeTitle,
(context) => SizedBox(
height: double.infinity,
child: Column(
children: [
Flexible(
child: Padding(
padding: const EdgeInsets.all(8),
child: Card(
child: ListTile(
title: Text(inputTitle),
subtitle: TextField(
maxLines: null,
controller: input,
expands: true,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
suffix: widget.lazy
? IconButton(
onPressed: onInput,
icon: const Icon(Icons.save),
tooltip: 'Submit',
)
: null,
),
),
),
),
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.all(8),
child: Card(
child: ListTile(
title: Text(outputTitle),
subtitle: TextField(
maxLines: null,
controller: output,
expands: true,
decoration: const InputDecoration(
isCollapsed: true,
border: InputBorder.none,
),
),
),
),
),
),
],
),
),
),
secondary: (
widget.previewTitle,
(context) => Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: Builder(builder: (context) {
if (widget.previewSize == null) {
return widget.preview ?? widget.placeholder;
}
return Center(
child: Material(
elevation: 8,
child: SizedBox.fromSize(
size: widget.previewSize,
child: widget.preview ?? widget.placeholder,
),
),
);
}),
)
),
);
}
}
```
@@ -0,0 +1,131 @@
---
name: flutter-markdown-view-with-material-3
description: Learn how to customize the Flutter Markdown widget using Material 3 text and color styles for a visually appealing and consistent design.
metadata:
url: https://rodydavis.com/posts/snippets/flutter-markdown-view-material-3
last_modified: Tue, 03 Feb 2026 20:04:30 GMT
---
# Flutter Markdown View with Material 3
## Overview 
How to style the [Flutter markdown](https://pub.dev/packages/flutter_markdown) widget with [Material 3](https://m3.material.io/) text and color styles:
```
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:url_launcher/url_launcher.dart';
class MarkdownView extends StatelessWidget {
const MarkdownView({
Key? key,
required this.markdown,
this.textScaleFactor = 1,
}) : super(key: key);
final String markdown;
final double textScaleFactor;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Markdown(
data: markdown,
selectable: true,
softLineBreak: true,
onTapLink: (text, link, _) {
final url = link ?? '/';
if (url.startsWith('http')) {
launchUrl(Uri.parse(url));
} else {
context.push(url);
}
},
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
[md.EmojiSyntax(), ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes],
),
styleSheet: MarkdownStyleSheet(
textScaleFactor: textScaleFactor,
p: textTheme.bodyLarge!.copyWith(
fontSize: 16,
color: colors.onSurface.withOpacity(0.72),
),
a: TextStyle(
decoration: TextDecoration.underline,
color: colors.onSurface.withOpacity(0.72),
),
h1: textTheme.displaySmall!.copyWith(
fontSize: 25,
color: colors.onSurface,
),
h2: textTheme.headlineLarge!.copyWith(
fontSize: 20,
color: colors.onSurface,
),
h3: textTheme.headlineMedium!.copyWith(
fontSize: 18,
color: colors.onSurface,
),
h4: textTheme.headlineSmall!.copyWith(
fontSize: 16,
color: colors.onSurface,
),
h5: textTheme.titleLarge!.copyWith(
fontSize: 16,
color: colors.onSurface,
),
h6: textTheme.titleMedium!.copyWith(
fontSize: 16,
color: colors.onSurface,
),
listBullet: textTheme.bodyLarge!.copyWith(
color: colors.onSurface,
),
em: const TextStyle(fontStyle: FontStyle.italic),
strong: const TextStyle(fontWeight: FontWeight.bold),
blockquote: TextStyle(
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w500,
color: colors.onSurfaceVariant,
),
blockquoteDecoration: BoxDecoration(
color: colors.surfaceVariant,
borderRadius: BorderRadius.circular(4),
),
code: const TextStyle(fontFamily: 'monospace'),
tableHead:
const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
tableBody:
const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
blockSpacing: 8,
listIndent: 32,
blockquotePadding: const EdgeInsets.all(8),
h1Padding: const EdgeInsets.symmetric(vertical: 8),
h2Padding: const EdgeInsets.symmetric(vertical: 8),
h3Padding: const EdgeInsets.symmetric(vertical: 8),
h4Padding: const EdgeInsets.symmetric(vertical: 8),
h5Padding: const EdgeInsets.symmetric(vertical: 8),
h6Padding: const EdgeInsets.symmetric(vertical: 8),
codeblockPadding: const EdgeInsets.all(8),
codeblockDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: colors.surfaceVariant,
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
color: colors.outline.withOpacity(0.4),
width: 1,
),
),
)),
);
}
}
```
@@ -0,0 +1,127 @@
---
name: flutter-master-detail-view
description: Learn how to implement a responsive Master-Detail interface in Flutter that adapts to different screen sizes, leveraging multi-column layouts on larger screens and pushing to detail screens on mobile.
metadata:
url: https://rodydavis.com/posts/snippets/flutter-master-detail-view
last_modified: Tue, 03 Feb 2026 20:04:30 GMT
---
# Flutter Master-detail view
When building mobile, desktop and web applications with Flutter often times you are faced with what to do with lists and the content when selected. Depending on the data you may have a list that renders another list before resolving to a detail view. On tablet or desktop this can be achieved with multi-column layouts.
> On mobile you will still need to push to the details screen since the space is constrained.
How to build a [Master-detail](https://en.wikipedia.org/wiki/Master%E2%80%93detail_interface) view with Flutter:
```
import 'package:flutter/material.dart';
class MasterDetail<T> extends StatefulWidget {
const MasterDetail({
Key? key,
required this.listBuilder,
required this.detailBuilder,
required this.onPush,
this.emptyBuilder,
}) : super(key: key);
final Widget Function(BuildContext, ValueChanged<T?>, T?) listBuilder;
final Widget Function(BuildContext, T, bool) detailBuilder;
final void Function(BuildContext, T) onPush;
final WidgetBuilder? emptyBuilder;
@override
State<MasterDetail<T>> createState() => _MasterDetailState<T>();
}
class _MasterDetailState<T> extends State<MasterDetail<T>> {
final selected = ValueNotifier<T?>(null);
double? detailsWidth;
@override
Widget build(BuildContext context) {
return Scaffold(
primary: false,
body: LayoutBuilder(
builder: (context, dimens) {
const double minWidth = 350;
final maxWidth = dimens.maxWidth - minWidth;
if (detailsWidth != null) {
if (detailsWidth! > maxWidth) {
detailsWidth = maxWidth;
}
if (detailsWidth! < minWidth) {
detailsWidth = minWidth;
}
}
return ValueListenableBuilder<T?>(
valueListenable: selected,
builder: (context, item, child) {
final canShowDetails = dimens.maxWidth > 800;
final showDetails = item != null && canShowDetails;
return Row(
children: [
Expanded(
child: widget.listBuilder(context, (item) {
if (canShowDetails) {
selected.value = item;
} else {
selected.value = null;
if (item != null) widget.onPush(context, item);
}
}, selected.value),
),
if (canShowDetails)
MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
if (mounted) {
setState(() {
double w = detailsWidth ?? maxWidth;
w -= details.delta.dx;
// Check for min width
if (w < minWidth) {
w = minWidth;
}
// Check for max width
if (w > maxWidth) {
w = maxWidth;
}
detailsWidth = w;
});
}
},
child: const SizedBox(
width: 5,
height: double.infinity,
child: VerticalDivider(),
),
),
),
if (canShowDetails)
SizedBox(
width: detailsWidth ?? maxWidth,
height: double.infinity,
child: showDetails
? widget.detailBuilder(context, item, false)
: widget.emptyBuilder?.call(context) ??
const Center(
child: Text('Select a item to view details'),
),
),
],
);
},
);
},
),
);
}
}
```
This widget will size itself after layout and try to size the list as small as possible with the details filling up the rest. This is important for later when we nest multiple of these to create progressively adapting layouts.
@@ -0,0 +1,34 @@
---
name: flutter-native-http-client
description: This blog post explores how to optimize HTTP client selection in Flutter applications based on the platform, using Cronet on Android and Cupertino's native client on iOS for improved performance and caching.
metadata:
url: https://rodydavis.com/posts/snippets/flutter-native-http-client
last_modified: Tue, 03 Feb 2026 20:04:29 GMT
---
# Flutter Native HTTP Client
```
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:platform_info/platform_info.dart';
void main() async {
var clientFactory = Client.new;
final device = DeviceInfoPlugin();
if (platform.isAndroid) {
final engine = CronetEngine.build(
cacheMode: CacheMode.memory,
userAgent: (await device.androidInfo).model,
);
clientFactory = () => CronetClient.fromCronetEngine(engine);
} else if (platform.isCupertino) {
clientFactory = CupertinoClient.defaultSessionConfiguration.call;
}
runWithClient(() => runApp(const MyApp()),clientFactory);
}
```
@@ -0,0 +1,158 @@
---
name: flutter-stream-widget
description: Learn how to build dynamic Flutter UIs by directly using streams within your widget's build method, enabling reactive screen updates and more efficient data handling.
metadata:
url: https://rodydavis.com/posts/snippets/flutter-stream-widget
last_modified: Tue, 03 Feb 2026 20:04:29 GMT
---
# Flutter Stream Widget
Work with streams directly in the build method of a [Flutter](https://flutter.dev/) widget:
```
import 'dart:async';
import 'package:flutter/widgets.dart';
abstract class StreamWidget extends StatefulWidget {
const StreamWidget({Key? key}) : super(key: key);
Stream<Widget> build(BuildContext context);
void initState() {}
void dispose() {}
void reassemble() {}
Widget? buildEmpty(BuildContext context) => null;
Widget? buildError(BuildContext context, Object? error) => null;
@override
State<StreamWidget> createState() => _StreamWidgetState();
}
class _StreamWidgetState extends State<StreamWidget> {
@override
void initState() {
widget.initState.call();
super.initState();
}
@override
void dispose() {
widget.dispose.call();
super.dispose();
}
@override
void reassemble() {
widget.reassemble.call();
super.reassemble();
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: widget.build(context),
builder: (context, snapshot) {
if (snapshot.hasError) {
final result = widget.buildError(context, snapshot.error);
if (result != null) return result;
}
if (snapshot.hasData) {
return snapshot.data!;
} else {
final result = widget.buildEmpty(context);
if (result != null) return result;
}
return const SizedBox.shrink();
},
);
}
}
```
This could also be applied to Future widgets, but for reactive screens, streams are closer to what is actually happening.
## Riverpod Example
```
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'generated.g.dart';
@riverpod
class GeneratedWidget extends _$GeneratedWidget {
@override
Widget build(BuildContext context) {
return const Text('Generated widget!');
}
}
@riverpod
class StreamWidget extends _$StreamWidget {
@override
Stream<Widget> build(BuildContext context) async* {
final controller = StreamController<int>();
final timer = Timer.periodic(const Duration(seconds: 1), (timer) {
controller.add(timer.tick);
});
yield* controller.stream.map((event) => Text('Stream widget: $event'));
timer.cancel();
await controller.close();
}
}
@riverpod
class FutureWidget extends _$FutureWidget {
@override
Future<Widget> build(BuildContext context) async {
await Future.delayed(const Duration(seconds: 3));
return const Text('Future completed!');
}
}
class Example extends StatelessWidget {
const Example({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
Consumer(builder: (context, ref, child) {
final generated = ref.watch(generatedWidgetProvider(context));
return generated;
}),
Consumer(builder: (context, ref, child) {
final stream = ref.watch(streamWidgetProvider(context));
return stream.when(
data: (data) => data,
error: (error, stack) => Text(error.toString()),
loading: () => const CircularProgressIndicator(),
);
}),
Consumer(builder: (context, ref, child) {
final future = ref.watch(futureWidgetProvider(context));
return future.when(
data: (data) => data,
error: (error, stack) => Text(error.toString()),
loading: () => const CircularProgressIndicator(),
);
}),
],
),
),
);
}
}
```
@@ -0,0 +1,135 @@
---
name: lightweight-flutter-animations
description: Learn how to create a streamlined animation widget in Flutter that eliminates the need for `setState` by leveraging an abstract class and `SingleTickerProviderStateMixin` for efficient UI updates.
metadata:
url: https://rodydavis.com/posts/snippets/lightweight-flutter-animations
last_modified: Tue, 03 Feb 2026 20:04:28 GMT
---
# Lightweight Flutter Animations
## Overview 
First we need to create the abstract class:
```
abstract class AnimationWidget<T extends StatefulWidget> extends State<T>
with SingleTickerProviderStateMixin {
Duration elapsed = Duration.zero;
Duration delta = Duration.zero;
late final Ticker ticker;
BoxConstraints constraints = const BoxConstraints.tightFor();
@override
void initState() {
super.initState();
ticker = createTicker((elapsed) {
delta = elapsed - this.elapsed;
this.elapsed = elapsed;
update(elapsed);
if (mounted) setState(() {});
});
ticker.start();
WidgetsBinding.instance.addPostFrameCallback(start);
}
@override
void dispose() {
ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, dimens) {
constraints = dimens;
return paint(context, dimens);
});
}
void start(Duration time) {}
void update(Duration time);
Widget paint(BuildContext context, BoxConstraints constraints);
}
```
This will let us replace `State` with `AnimationWidget` and not need to call `setState` to rebuild the ui.
## Example 
For the example we need an inline canvas painter:
```
class InlinePainter extends CustomPainter {
InlinePainter({
required this.draw,
super.repaint,
});
final void Function(Canvas canvas, Size size) draw;
@override
void paint(Canvas canvas, Size size) {
draw(canvas, size);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
```
And the example using the new widget class:
```
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class SimpleExample extends StatefulWidget {
const SimpleExample({Key? key}) : super(key: key);
@override
State<SimpleExample> createState() => _SimpleExampleState();
}
class _SimpleExampleState extends AnimationWidget<SimpleExample> {
var x = 0.0;
var y = 0.0;
var z = 0.0;
@override
void update(Duration time) {
final t = delta.inMilliseconds / 1000;
x += t;
y += t;
z += t;
}
@override
Widget paint(BuildContext context, BoxConstraints constraints) {
return Material(
child: Center(
child: Container(
width: 100,
height: 100,
transform: Matrix4.identity()
..rotateX(x)
..rotateY(y)
..rotateZ(z),
child: const Text(
'Hello World',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
```
## Demo
@@ -0,0 +1,29 @@
---
name: material-3-to-material-2-theme-adapter
description: Learn how to seamlessly integrate Material Design 3's styling into your Material Design 2 components using CSS variable overrides.
metadata:
url: https://rodydavis.com/posts/snippets/m3-to-m2-css-adapter
last_modified: Tue, 03 Feb 2026 20:04:28 GMT
---
# Material 3 to Material 2 Theme Adapter
## Overview 
How to style Material 2 components with Material 3 in CSS:
```
:root {
--mdc-theme-primary: var(--md-sys-color-primary);
--mdc-theme-on-primary: var(--md-sys--coloron-primary);
--mdc-theme-background: var(--md-sys--colorbackground);
--mdc-theme-on-background: var(--md-sys--coloron-background);
--mdc-theme-on-surface-variant: var(--md-sys--coloron-surface-variant);
--mdc-theme-surface-variant: var(--md-sys--colorsurface-variant);
--mdc-theme-on-surface: var(--md-sys--coloron-surface);
--mdc-theme-surface: var(--md-sys--colorsurface);
--mdc-theme-text-primary-on-background: var(--md-sys--coloron-surface-variant);
--mdc-theme-outline: var(--md-sys-color-outline);
}
```
@@ -0,0 +1,83 @@
---
name: color-utilities-in-javascript
description: Explore helpful color utility functions, like RGB to HSL, HEX to RGB, and HSL to HEX, generated with the assistance of GitHub Copilot.
metadata:
url: https://rodydavis.com/posts/snippets/typescript-color-utilities
last_modified: Tue, 03 Feb 2026 20:04:31 GMT
---
# Color Utilities in JavaScript
Color utilities generated by [GitHub Copilot](https://github.com/features/copilot).
### Convert an RGB color to HSL
```
function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
var max = Math.max(r, g, b);
var min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if (max == min) {
h = s = 0;
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return [h, s, l];
}
```
### Convert HEX to RGB
```
function hexToRgb(hex) {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
```
### Convert HSL to HEX
```
function hslToHex(h, s, l) {
var r, g, b;
if (s == 0) {
r = g = b = l;
} else {
var hue2rgb = function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return "#" + (1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1);
}
```
@@ -0,0 +1,18 @@
---
name: ios-or-macos-lock-screen-nasa-image-of-the-day
description: Automate your daily dose of cosmic beauty by setting your lock or home screen to NASA's image of the day using Shortcuts and their public API.
metadata:
url: https://rodydavis.com/posts/snippets/workflow-nasa-image-of-day
last_modified: Tue, 03 Feb 2026 20:04:29 GMT
---
# iOS or MacOS Lock Screen NASA Image of the Day
## Overview 
How to set the lock screen or home screen to the [NASA](https://www.nasa.gov/) image of the day using the [public API](https://data.nasa.gov/Space-Science/Astronomy-Picture-of-the-Day-API/ez2w-t8ua) and [shortcuts](https://apps.apple.com/us/app/shortcuts/id915249334).
![](https://rodydavis.com/_/../api/files/pbc_2708086759/po57y204r684716/shortcuts_nasa_overview_18xdpcg42u.webp?thumb=)
[iCloud Link](https://www.icloud.com/shortcuts/f3cc2b5108c54ffeaf4299fe5ed702b3) to download.
+130
View File
@@ -0,0 +1,130 @@
---
name: how-to-do-full-text-search-with-sqlite
description: Learn how to supercharge your SQLite databases with full-text search capabilities using the built-in fts5 extension, enabling efficient and powerful querying with the `MATCH` keyword.
metadata:
url: https://rodydavis.com/posts/sqlite/fts5
last_modified: Tue, 03 Feb 2026 20:04:36 GMT
---
# How to do Full Text Search with SQLite
SQLite has a powerful way to add new functionality via [loadable extensions](https://www.sqlite.org/loadext.html). The first-party ones include [fts5](https://www.sqlite.org/fts5.html), [json1](https://www.sqlite.org/json1.html) and a couple others.
When building applications it is common to add searching features based on data coming from tables and you may already have queries for fuzzy searching with **LIKE**. You may be excited to hear that SQLite can easily add fully query capabilities over a dataset all with just a simple **MATCH** keyword. 👀
## Creating your first search index 
Full text search in SQLite requires storing the index in **VIRTUAL** tables, which allow for optimized storage of the index based on the queries we will execute against it.
You can create the virtual table for the index making sure to include the **USING** directive for the fts5 target.
```
CREATE VIRTUAL TABLE posts_fts USING fts5 (
title,
description,
content,
content=posts,
content_rowid=id
);
```
> Text IDs are also supported instead of just INTEGERS.
This is a standard callout. You can customize its content and even the icon.
### Contentless tables 
You can also create a contentless table that will not be based on any existing tables:
```
CREATE VIRTUAL TABLE example_fts USING fts5 (
name,
description,
content=''
);
```
## Keeping the index up to date 
By having the source content be stored in another table we need to make sure to keep both tables in sync and avoid updating the index in a hot path when trying to make a query.
By default when you create table it will be empty, even if the source table is populated. You do have various options for populating the index.
### Update by query 
If you use a contentless table or want to pull in data from a view you can update by query.
```
INSERT INTO posts_fts (id, title, description, content)
SELECT id, title, description, content FROM posts;
```
### Rebuild command 
Using the rebuild command it will update the index based on the content table specified.
```
INSERT INTO posts_fts(posts_fts) VALUES('rebuild');
```
### Triggers 
We can use SQLite triggers to automatically keep the records updated:
```
CREATE TRIGGER posts_insert AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(id, title, description, content)
VALUES (new.id, new.title, new.description, new.content);
END;
CREATE TRIGGER posts_delete AFTER DELETE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, id, title, description, content)
VALUES ('delete', old.id, old.title, old.description, old.content);
END;
CREATE TRIGGER posts_update AFTER UPDATE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, id, title, description, content)
VALUES ('delete', old.id, old.title, old.description, old.content);
INSERT INTO posts_fts(id, title, description, content)
VALUES (new.id, new.title, new.description, new.content);
END;
```
This will always ensure the two tables are in sync for any CRUD actions on the source table.
## Searching the index 
### Query syntax 
Here is the supported query syntax:
```
<phrase> := string [*]
<phrase> := <phrase> + <phrase>
<neargroup> := NEAR ( <phrase> <phrase> ... [, N] )
<query> := [ [-] <colspec> :] [^] <phrase>
<query> := [ [-] <colspec> :] <neargroup>
<query> := [ [-] <colspec> :] ( <query> )
<query> := <query> AND <query>
<query> := <query> OR <query>
<query> := <query> NOT <query>
<colspec> := colname
<colspec> := { colname1 colname2 ... }
```
To preform an actual query on the index we will need to use the **MATCH** keyword and order by the rank.
```
SELECT posts.* FROM posts_fts
INNER JOIN posts ON posts.id = posts_fts.rowid
WHERE posts_fts MATCH :query
ORDER BY rank;
```
## Demo
## Reference 
* [https://www.sqlite.org/fts5.html](https://www.sqlite.org/fts5.html)
* [https://docs.datasette.io/en/latest/full\_text\_search.html](https://docs.datasette.io/en/latest/full_text_search.html)
+152
View File
@@ -0,0 +1,152 @@
---
name: using-sqlite-as-a-key-value-store
description: Learn how to use SQLite as a simple and efficient key/value store for your applications, offering benefits like single-file data containment, attachment capabilities, and easy integration with tools like Drift.
metadata:
url: https://rodydavis.com/posts/sqlite/key-value
last_modified: Tue, 03 Feb 2026 20:04:37 GMT
---
# Using SQLite as a Key Value Store
[SQLite](https://www.sqlite.org/) is a very capable edge database that can store various shapes of data.
Key/Value databases are popular in applications for storing settings, and other non-relational data.
By using SQLite to store the key/values you can contain all the data for a user in a single file and can [attach it to other databases](https://www.sqlite.org/lang_attach.html) or sync it to a server.
## Create the table
To store key/value type data we need to first create our table.
```
CREATE TABLE key_value (
key TEXT NOT NULL PRIMARY KEY,
value,
UNIQUE(key)
);
```
key
value
user\_id
1
foo
bar
active
1
guest
0
SQLite has [optional column types](https://www.sqlite.org/datatype3.html) and can be very useful for dynamic values.
## Save a value
To save a value for a given key we can run the following:
```
INSERT OR REPLACE
INTO key_value (key, value)
VALUES (:key, :value)
RETURNING *;
```
key
value
user\_id
1
Since the key is [UNIQUE](https://www.sqlitetutorial.net/sqlite-unique-constraint/) we do not have to worry about conflicts as it will overwrite the value as intended.
## Read a value
To read a value we can pass in a key to our query:
```
SELECT value FROM key_value
WHERE key = :key;
```
value
1
This will only return a single value column with a max of 1 rows.
## Delete a value
To delete a value or key we can run the following:
```
DELETE FROM key_value
WHERE key = :key;
```
## Search for key or value
We can also search for a specific key or value (if it is a string) with the following:
```
SELECT key, value
FROM key_value
WHERE key LIKE :query
OR value LIKE :query;
```
key
value
bar
1
foo
bar
## Drift Support
If you are using [Drift](https://drift.simonbinder.eu/) in dart, create a new file `key_value.drift` and add the following:
```
CREATE TABLE key_value (
"key" TEXT NOT NULL PRIMARY KEY,
value TEXT,
UNIQUE("key")
);
setItem:
INSERT OR REPLACE
INTO key_value ("key", value)
VALUES (:key, :value)
RETURNING *;
getItem:
SELECT value FROM key_value
WHERE "key" = :key;
deleteItem:
DELETE FROM key_value
WHERE "key" = :key;
searchItem:
SELECT "key", value
FROM key_value
WHERE "key" LIKE :query
OR value LIKE :query;
```
## Demo
+185
View File
@@ -0,0 +1,185 @@
---
name: how-to-store-sqlite-as-nosql-store
description: Discover how to leverage SQLite's JSON support to build a NoSQL-like document store, complete with TTL-based expiration, within this powerful embedded database.
metadata:
url: https://rodydavis.com/posts/sqlite/no-sql
last_modified: Tue, 03 Feb 2026 20:04:37 GMT
---
# How to store SQLite as NoSQL Store
[SQLite](https://www.sqlite.org/) is a very capable edge database that can store various shapes of data.
[NoSQL databases](https://www.mongodb.com/nosql-explained#:~:text=Some%20say%20the%20term%20%E2%80%9CNoSQL,format%20other%20than%20relational%20tables.) are very popular due to the schema-less nature of storing of the data but it is totally possible to store these documents in SQLite.
SQLite actually has great [JSON support](https://www.sqlite.org/json1.html) and even supports [JSONB](https://sqlite.org/draft/jsonb.html).
## Create the table 
To store JSON documents we need to create a table to store the values as strings.
```
CREATE TABLE documents (
path TEXT NOT NULL PRIMARY KEY,
data TEXT,
ttl INTEGER,
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
UNIQUE(path)
);
```
path
data
ttl
created
updated
/posts/1
{"id":1}
NULL
0
0
/posts/2
{"id":2}
NULL
0
0
/users/1
{"id":1}
NULL
0
0
The basic idea is to store a JSON object and an unique path.
There is an optional [TTL](https://www.cloudflare.com/learning/cdn/glossary/time-to-live-ttl/#:~:text=What%20is%20time%2Dto%2Dlive%20\(TTL\)%20in%20networking,CDN%20caching%20and%20DNS%20caching.) to automatically delete rows when they reach the stale date.
## Save a document 
To save a document we can encode our JSON as a string or binary and save in in the table with a unique path.
```
INSERT OR REPLACE
INTO documents (path, data, ttl, created, updated)
VALUES (:path, :data, :ttl, :created, :updated)
RETURNING *;
```
You can also use JSON functions to save the Object to a valid JSON string.
```
INSERT OR REPLACE
INTO documents (path, data, ttl, created, updated)
VALUES ("/posts/1", json('{"id" 1}'), NULL, 0, 0)
RETURNING *;
```
path
data
ttl
created
updated
/posts/1
{"id":1}
NULL
0
0
## Reading a document 
To read a document we just need the path. If a TTL is set we can [calculate if the current date](https://www.sqlite.org/lang_datefunc.html) is greater than the offset and not return the document.
```
SELECT * FROM documents
WHERE path = :path
AND (
(ttl IS NOT NULL AND ttl + updated < unixepoch())
OR
ttl IS NULL
);
```
path
data
ttl
created
updated
/posts/1
{"id":1}
NULL
0
0
## Get documents for a collection 
We can query all the docs for a given collection using some built-in functions and a path prefix:
```
SELECT *
FROM documents
WHERE (
path LIKE :prefix
AND
(LENGTH(path) - LENGTH(REPLACE(path, '/', ''))) = (LENGTH(:prefix) - LENGTH(REPLACE(:prefix, '/', '')))
)
AND (
(ttl IS NOT NULL AND ttl + updated < unixepoch())
OR
ttl IS NULL
)
ORDER BY created;
```
It is expected to search for a :prefix with the `/%` at the end:
`"/my/path/%" // search for /my/path`
## Deleting expired documents 
Using the TTL field we can delete all expired documents:
```
DELETE FROM documents
WHERE ttl IS NOT NULL
AND ttl + updated < unixepoch();
```
## Demo
+43
View File
@@ -0,0 +1,43 @@
---
name: sqlite-on-the-ui-thread
description: Unlock the surprising speed of SQLite in Flutter for building responsive UIs, showcasing its ability to handle large datasets with synchronous queries and optimized configurations.
metadata:
url: https://rodydavis.com/posts/sqlite/ui-thread
last_modified: Tue, 03 Feb 2026 20:04:37 GMT
---
# SQLite on the UI Thread
[SQLite](https://www.sqlite.org/) is a lot faster than you may realize. In [Flutter](https://flutter.dev/) for example there is [drift](https://pub.dev/packages/drift), [sqlite\_async](https://pub.dev/packages/sqlite_async) and [sqflite](https://pub.dev/packages/sqflite) which allow for async access of data. But with [sqlite3](https://pub.dev/packages/sqlite3) you can query with sync functions! 🤯
Here is a list view where there are 10000 items and each item is retrieved with a select statement 👀
![](https://rodydavis.com/_/../api/files/pbc_2708086759/q0fkqwm4y69yob8/demo_tobl81yuqz.gif?thumb=)
Source: [https://gist.github.com/rodydavis/4a6dca4a2e1afc530ac93e94a76a594a](https://gist.github.com/rodydavis/4a6dca4a2e1afc530ac93e94a76a594a)
SQLite, when used effectively, can be a powerful asset for UI-driven applications. Operating within the same process and thread as the UI, it offers a seamless integration that can significantly improve component building.
Async/await does not mean you will be building the most performant applications, and in some cases will [incur a performance penalty](https://madelinemiller.dev/blog/javascript-promise-overhead/).
Even with extensive datasets, SQLite demonstrates remarkable efficiency. Its ability to handle millions of rows without compromising speed is a testament to its robust architecture. Contrary to the misconception of being solely a background-thread database, SQLite functions as a process-level library, akin to any other C-based library.
By strategically employing indexes and queries, developers can achieve nanosecond response times and mitigate N+1 query issues. The judicious use of views, indexes, and virtual tables is paramount in optimizing performance.
Complex join operations and the ability to retrieve only essential data for display further underscore SQLite's versatility. For example, when presenting a list view or cards, SQLite can efficiently fetch the required 30 items without undue overhead.
SQLite's flexibility extends beyond single-database scenarios. The [ATTACH](https://www.sqlite.org/lang_attach.html) feature enables the management of multiple databases within a single application. Additionally, the concept of isolates or workers allows for parallel processing, further enhancing performance and responsiveness.
From simple [key-value](https://rodydavis.com/sqlite/key-value) stores to intricate data modeling, SQLite's capabilities are vast. By applying appropriate [PRAGMAs](https://www.sqlite.org/pragma.html), such as WAL mode, developers can tailor SQLite's behavior to meet specific application requirements.
[Example PRAGMA](https://www.reddit.com/r/rails/comments/16cbiz9/the_6_pragmas_you_need_to_know_to_tune_your/):
```
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA journal_size_limit = 67108864;
PRAGMA mmap_size = 134217728;
PRAGMA cache_size = 2000;
PRAGMA busy_timeout = 5000;
```
@@ -0,0 +1,563 @@
---
name: file-based-routing-for-static-sites
description: Learn how to create a multi-page static site with file-based routing using TypeScript, allowing for quick updates and easy content management.
metadata:
url: https://rodydavis.com/posts/static-site-file-based-routing
last_modified: Tue, 03 Feb 2026 20:04:23 GMT
---
# File Based Routing for Static Sites
In this article I will go over how to use file based routing to output as a static site multi page application.
> **TLDR** The final source [here](https://github.com/rodydavis/static-site-file-based-routing) and an online [demo](https://rodydavis.github.io/static-site-file-based-routing/).
## Step 1 
Create a new folder called “static-site-file-based-routing” and open it up in VSCode.
```
mkdir static-site-file-based-routing
cd static-site-file-based-routing
code .
```
## Step 2 
Create a `tsconfig.json` and replace it with the following:
```
{
"compilerOptions": {
"incremental": true,
"target": "es5",
"module": "es2020",
"outDir": "dist",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"typeRoots": [
"node_modules/@types",
"src/@types"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
```
## Step 3 
Create a `package.json` and update it with the following:
```
{
"name": "static-site-file-based-routing",
"version": "1.0.0",
"description": "File based routing for HTML MPA",
"type": "module",
"scripts": {
"start": "wds --node-resolve --root-dir build --base-path /static-site-file-based-routing --open --watch",
"postinstall": "npm run tsc",
"build": "node dist/main.js --inputDir ./example --outputDir ./build",
"dev": "node dist/main.js --inputDir ./example --outputDir ./build -w",
"tsc": "tsc",
"tsc:watch": "tsc -w"
},
"devDependencies": {
"@pkgjs/parseargs": "^0.10.0",
"@types/markdown-it": "^12.2.3",
"@types/node": "^18.7.23",
"@web/dev-server": "^0.1.34",
"chokidar": "^3.5.3",
"highlight.js": "^11.6.0",
"html-format": "^1.0.2",
"markdown-it": "^13.0.1",
"parse5": "^7.1.1",
"typescript": "^4.8.4"
}
}
```
Then run `npm install` to install all the dependencies.
> I am using `@web/dev-server` to serve the site locally. You can use any server you want.
These dependencies are used for various file transformations such as markdown to HTML, HTML formatting, and file watching.
## Step 4 
Create a `src` folder and add 4 files:
### `src/build.ts` 
```
import * as fs from "fs";
import { compileDir, compileTarget } from "./compile.js";
import * as path from "path";
import chokidar from "chokidar";
import { publicDirectory } from "./static.js";
interface Options {
inputDir?: string;
outputDir?: string;
publicDir?: string;
watch?: boolean;
clean?: boolean;
}
export default async function build(options: Options) {
const inputDir = options.inputDir || "www";
const outputDir = options.outputDir || "build";
const publicDir = options.publicDir || "public";
const watch = options.watch || false;
const clean = options.clean || false;
if (!fs.existsSync(inputDir)) {
throw new Error(`Input directory ${inputDir} does not exist`);
}
if (clean) {
if (fs.existsSync(outputDir)) {
fs.rmdirSync(outputDir, { recursive: true });
}
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
if (watch) {
console.log("Watching for changes...");
chokidar.watch(inputDir).on("all", async (event, inputFile) => {
console.log(event, inputFile);
if (fs.existsSync(inputFile)) {
const relativePath = path.relative(inputDir, inputFile);
const outputFile = `${outputDir}/${relativePath}`;
const stat = fs.statSync(inputFile);
if (stat.isDirectory()) {
await compileDir(inputFile, outputFile);
} else if (stat.isFile()) {
const filename = path.basename(inputFile);
if (filename === "layout.html") {
// Rebuild all related directories
const dir = path.dirname(inputFile);
const inDir = path.relative(inputDir, dir);
const outDir = `${outputDir}/${inDir}`;
await compileDir(dir, outDir);
} else {
await compileTarget(inputFile, outputFile);
}
}
}
});
} else {
await compileDir(inputDir, outputDir);
}
if (publicDir.split(',').length > 1) {
for (const dir of publicDir.split(',')) {
publicDirectory(dir, outputDir, watch);
}
} else {
publicDirectory(publicDir, outputDir, watch);
}
}
```
### `src/compile.ts` 
```
import * as fs from "fs";
import * as path from "path";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import * as parse5 from "parse5";
import type { Document } from "parse5/dist/tree-adapters/default.js";
import format from 'html-format';
function compile(file: string) {
const raw = fs.readFileSync(file, "utf-8");
const ext = path.extname(file);
switch (ext) {
case ".md":
case ".markdown":
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return (
'<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true })
.value +
"</code></pre>"
);
} catch (__) {
console.error(__);
}
}
return "";
},
});
return parse5.parse(md.render(raw));
case ".html":
return parse5.parse(raw);
default:
break;
}
return raw;
}
function createHtml(options?: { head?: string; body?: string; }) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
${options?.head ?? ""}
</head>
<body>
${options?.body ?? "<slot></slot>"}
</body>
</html>
`;
}
export async function compileFile(file: string, target: string) {
// Use regex to check if ends with index.*
const isIndex = /index\.([a-z]+)$/i.test(file);
if (!isIndex && !fs.statSync(file).isDirectory()) {
// Skipping for nested layouts
return;
}
const parent = path.dirname(target);
// Render up the directory tree until we hit the root directory
const files: string[] = [file];
let filePath = file;
while (filePath !== parent) {
filePath = path.dirname(filePath);
// Check for root level layout
const layout = path.join(filePath, "layout.html");
if (fs.existsSync(layout)) {
files.unshift(layout);
}
if (filePath === ".") break;
}
// Check for root level index markdown or html
const layout = path.join(parent, "layout.html");
if (fs.existsSync(layout)) {
if (
fs.existsSync(path.join(parent, "index.html")) ||
fs.existsSync(path.join(parent, "index.md")) ||
fs.existsSync(path.join(parent, "index.markdown"))
) {
files.unshift(layout);
}
}
let output = createHtml();
for (const item of files) {
const doc = compile(item);
if (typeof doc !== 'string') {
const content = parse5.serialize(doc);
output = mergeDocuments(output, content);
}
}
// Replace extension
const ext = path.extname(file);
const newFile = target.replace(ext, '.html');
// Check if parent directory exists
const parentDir = path.dirname(newFile);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
fs.writeFileSync(newFile, output);
console.log(`--> ${newFile}`);
}
function mergeDocuments(current: string, source: string) {
let raw = current;
// Merge body
const html = extractDoc(parse5.parse(source));
// Check for <slot></slot>
const hasSlot = raw.includes("<slot></slot>");
if (hasSlot) {
raw = raw.replace("<slot></slot>", parse5.serialize(html.body));
} else {
// Append to body
const endBodyIdx = raw.lastIndexOf("</body>");
const start = raw.slice(0, endBodyIdx);
const end = raw.slice(endBodyIdx);
const body = parse5.serialize(html.body);
raw = start + body + end;
}
// Merge head
const endHeadIdx = raw.lastIndexOf("</head>");
const start = raw.slice(0, endHeadIdx);
const end = raw.slice(endHeadIdx);
const head = parse5.serialize(html.head);
raw = start + head + end;
// Format
raw = format(raw);
// Remove duplicate title tags
const lastTitle = raw.lastIndexOf("<title>");
const lastTitleEnd = raw.lastIndexOf("</title>");
const title = raw.slice(lastTitle, lastTitleEnd + 8);
raw = raw.replace(/<title>.*<\/title>/, "");
raw = raw.replace("</head>", title + "</head>");
return raw;
}
function extractDoc(doc: Document) {
const html = (doc.childNodes[1] ?? doc.childNodes[0]) as unknown as Document;
const head = html.childNodes.find(
(node) => node.nodeName === "head"
) as unknown as Document;
const body = html.childNodes.find(
(node) => node.nodeName === "body"
) as unknown as Document;
return { head, body };
}
export async function compileDir(inputDir: string, outputDir: string) {
const files = fs.readdirSync(inputDir);
for (const file of files) {
const inputFile = `${inputDir}/${file}`;
const outputFile = `${outputDir}/${file}`;
await compileTarget(inputFile, outputFile);
}
}
export async function compileTarget(input: string, output: string) {
const stat = fs.statSync(input);
if (stat.isDirectory()) {
if (!fs.existsSync(output)) {
fs.mkdirSync(output, { recursive: true });
}
await compileDir(input, output);
} else if (stat.isFile()) {
const ext = path.extname(input);
if (['.html', '.md', '.markdown'].includes(ext)) {
await compileFile(input, output);
} else {
const current = fs.readFileSync(input);
const parentDir = path.dirname(output);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
if (fs.existsSync(output)) {
// Check if content is the same
const previous = fs.readFileSync(output);
if (Buffer.compare(current, previous) !== 0) {
fs.writeFileSync(output, current);
}
} else {
// Copy the file
fs.copyFileSync(input, output);
}
}
}
}
```
### `src/static.ts` 
```
import * as fs from "fs";
import * as path from "path";
import chokidar from "chokidar";
export function publicDirectory(publicDir: string, outputDir: string, watch: boolean) {
if (watch) {
if (fs.existsSync(publicDir)) {
chokidar.watch(publicDir).on("all", (event, inputFile) => {
console.log(event, inputFile);
if (fs.existsSync(inputFile)) {
const relativePath = path.relative(publicDir, inputFile);
const outputFile = `${outputDir}/${relativePath}`;
const stat = fs.statSync(inputFile);
if (stat.isDirectory()) {
copyStaticFiles(inputFile, outputFile);
} else if (stat.isFile()) {
fs.copyFileSync(inputFile, outputFile);
}
}
});
}
} else {
// Copy static files
if (fs.existsSync(publicDir)) {
copyStaticFiles(publicDir, outputDir);
}
}
}
function copyStaticFiles(inDir: string, outDir: string) {
const files = fs.readdirSync(inDir);
for (const file of files) {
const inputFile = `${inDir}/${file}`;
const outputFile = `${outDir}/${file}`;
const stat = fs.statSync(inputFile);
if (stat.isDirectory()) {
if (!fs.existsSync(outputFile)) {
fs.mkdirSync(outputFile, { recursive: true });
}
copyStaticFiles(inputFile, outputFile);
} else if (stat.isFile()) {
fs.copyFileSync(inputFile, outputFile);
}
}
}
```
### `src/main.ts` 
```
#!/usr/bin/env node
// @ts-ignore
import { parseArgs } from "@pkgjs/parseargs";
import build from "./build.js";
export async function main() {
const {
values: { inputDir, outputDir, watch },
} = parseArgs({
options: {
inputDir: {
type: "string",
short: "i",
},
outputDir: {
type: "string",
short: "o",
},
watch: {
type: "boolean",
short: "w",
},
},
allowPositional: true,
});
if (inputDir === undefined || outputDir === undefined) {
console.log("Usage: build -i <inputDir> -o <outputDir> [-w]");
return;
}
await build({
inputDir,
outputDir,
watch,
});
}
main();
```
## Step 5 
Now that the project is setup we can start the typescript compiler in watch mode.
```
npm run ts:watch
```
## Step 6 
Now create a folder that will contain the source files for the website.
### `example/index.md` 
```
# Hello World
This is a test
```
### `example/style.css` 
```
body {
background-color: #000;
color: #fff;
}
```
### `example/layout.html` 
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<slot></slot>
</body>
</html>
```
## Step 7 
With the source files created we can now run the build script.
For a one time build run:
```
npm run build
```
For a watch mode run:
```
npm run dev
```
Now start the http server:
```
npm run start
```
## Conclusion 
As you make changes it will only update affected files and be very fast to update.
> Note that this does not bundle the javascript and will be up to you if you are using `node_modules` in any files (for the example in the repo I show how to use **UNPKG**).
If you want to find the source code you can check it out [here](https://github.com/rodydavis/static-site-file-based-routing) otherwise thanks for reading and let me know if you have any questions!