<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: Datasette Lite</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/series/datasette-lite.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2022-09-16T02:55:03+00:00</updated><author><name>Simon Willison</name></author><entry><title>Weeknotes: Datasette Lite, s3-credentials, shot-scraper, datasette-edit-templates and more</title><link href="https://simonwillison.net/2022/Sep/16/weeknotes/#atom-series" rel="alternate"/><published>2022-09-16T02:55:03+00:00</published><updated>2022-09-16T02:55:03+00:00</updated><id>https://simonwillison.net/2022/Sep/16/weeknotes/#atom-series</id><summary type="html">
    &lt;p&gt;Despite &lt;a href="https://simonwillison.net/2022/Sep/12/prompt-injection/"&gt;distractions from AI&lt;/a&gt; I managed to make progress on a bunch of different projects this week, including new releases of &lt;a href="https://s3-credentials.readthedocs.io/"&gt;s3-credentials&lt;/a&gt; and &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt;, a new &lt;a href="https://datasette.io/plugins/datasette-edit-templates"&gt;datasette-edit-templates&lt;/a&gt; plugin and a small but neat improvement to &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Better GitHub support for Datasette Lite&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt; is &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;Datasette running in WebAssembly&lt;/a&gt;. Originally intended as a cool tech demo it's quickly becoming a key component of the wider Datasette ecosystem - just this week I saw that mySociety are using it to help people explore their &lt;a href="https://mysociety.github.io/wdtk_authorities_list/datasets/whatdotheyknow_authorities_dataset/latest"&gt;WhatDoTheyKnow Authorities Dataset&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One of the neat things about Datasette Lite is that you can feed it URLs to CSV files, SQLite database files and even SQL initialization scripts and it will fetch them into your browser and serve them up inside Datasette. I wrote more about this capability in &lt;a href="https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/"&gt;Joining CSV files in your browser using Datasette Lite&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There's just one catch: because those URLs are fetched by JavaScript running in your browser, they need to be served from a host that sets the &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; header (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS"&gt;see MDN&lt;/a&gt;). This is not an easy thing to explain to people!&lt;/p&gt;
&lt;p&gt;The good news here is that GitHub makes every public file (and every Gist) hosted on GitHub available as static hosting with that magic header.&lt;/p&gt;
&lt;p&gt;The bad news is that you have to know how to construct that URL! GitHub's "raw" links redirect to that URL, but JavaScript &lt;code&gt;fetch()&lt;/code&gt; calls can't follow redirects if they don't have that header - and GitHub's redirects do not.&lt;/p&gt;
&lt;p&gt;So you need to know that if you want to load the SQLite database file from this page on GitHub:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/lerocha/chinook-database/blob/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite"&gt;https://github.com/lerocha/chinook-database/blob/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You first need to rewrite that URL to the following, which is served with the correct CORS header:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite"&gt;https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Asking human's to do that by hand isn't reasonable. So I added some code!&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;githubUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;^&lt;/span&gt;https:&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;github.com&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;.&lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;.&lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;blob&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;.&lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-cce"&gt;\?&lt;/span&gt;raw=true&lt;span class="pl-kos"&gt;)&lt;/span&gt;?&lt;span class="pl-cce"&gt;$&lt;/span&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;fixUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;matches&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;githubUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;exec&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;matches&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s"&gt;`https://raw.githubusercontent.com/&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;matches&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;/&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;matches&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;2&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;/&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;matches&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;3&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Fun aside: GitHub Copilot auto-completed that &lt;code&gt;return&lt;/code&gt; statement for me, correctly guessing the URL string I needed based on the regular expression I had defined several lines earlier.&lt;/p&gt;
&lt;p&gt;Now any time you feed Datasette Lite a URL, if it's a GitHub page it will automatically rewrite it to the CORS-enabled equivalent on the &lt;code&gt;raw.githubusercontent.com&lt;/code&gt; domain.&lt;/p&gt;
&lt;p&gt;Some examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://lite.datasette.io/?url=https://github.com/lerocha/chinook-database/blob/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite"&gt;https://lite.datasette.io/?url=https://github.com/lerocha/chinook-database/blob/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite&lt;/a&gt; - that Chinook SQLite database example (from &lt;a href="https://github.com/lerocha/chinook-database/blob/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite"&gt;here&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://lite.datasette.io/?csv=https://github.com/simonw/covid-19-datasette/blob/6294ade30843bfd76f2d82641a8df76d8885effa/us_census_state_populations_2019.csv"&gt;https://lite.datasette.io/?csv=https://github.com/simonw/covid-19-datasette/blob/6294ade30843bfd76f2d82641a8df76d8885effa/us_census_state_populations_2019.csv&lt;/a&gt; - US censes populations by state, from my &lt;a href="https://github.com/simonw/covid-19-datasette"&gt;simonw/covid-19-datasette&lt;/a&gt; repo&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;datasette-edit-templates&lt;/h4&gt;
&lt;p&gt;I started working on this plugin a couple of years ago but didn't get it working. This week I finally &lt;a href="https://github.com/simonw/datasette-edit-templates/issues/1"&gt;closed the initial issue&lt;/a&gt; and shipped a &lt;a href="https://datasette.io/plugins/datasette-edit-templates"&gt;first alpha release&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It's pretty fun. On first launch it creates a &lt;code&gt;_templates_&lt;/code&gt; table in your database. Then it allows the &lt;code&gt;root&lt;/code&gt; user (run &lt;code&gt;datasette data.db --root&lt;/code&gt; and click the link to sign in as root) to edit Datasette's default set of Jinja templates, writing their changes to that new table.&lt;/p&gt;
&lt;p&gt;Datasette uses those templates straight away. It turns the whole of Datasette into an interface for editing itself.&lt;/p&gt;
&lt;p&gt;Here's an animated demo showing the plugin in action:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-edit-templates.gif" alt="Animated screenshot. The Datasette app menu now has a Edit templates item, which goes to a page listing all of the templates. If you edit the _footer.html template to add an exclamation mark on the next page the Datasette footer shows that change." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The implementation is currently a bit gnarly, but I've filed &lt;a href="https://github.com/simonw/datasette/issues/1809"&gt;an issue&lt;/a&gt; in Datasette core to help clear some of it up.&lt;/p&gt;
&lt;h4&gt;s3-credentials get-objects and put-objects&lt;/h4&gt;
&lt;p&gt;I built &lt;a href="https://s3-credentials.readthedocs.org/"&gt;s3-credentials&lt;/a&gt; to solve my number one frustration with AWS S3: the surprising level of complexity involved in issuing IAM credentials that could only access a specific S3 bucket. I introduced it in &lt;a href="https://simonwillison.net/2021/Nov/3/s3-credentials/"&gt;s3-credentials: a tool for creating credentials for S3 buckets&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Once you've created credentials, you need to be able to do stuff with them. I find the default AWS CLI tools relatively unintuitive, so &lt;code&gt;s3-credentials&lt;/code&gt; has continued to grow &lt;a href="https://s3-credentials.readthedocs.io/en/stable/other-commands.html"&gt;other commands&lt;/a&gt; as and when I feel the need for them.&lt;/p&gt;
&lt;p&gt;The latest version, &lt;a href="https://github.com/simonw/s3-credentials/releases/tag/0.14"&gt;0.14&lt;/a&gt;, adds two more: &lt;a href="https://s3-credentials.readthedocs.io/en/stable/other-commands.html#get-objects"&gt;get-objects&lt;/a&gt; and &lt;a href="https://s3-credentials.readthedocs.io/en/stable/other-commands.html#put-objects"&gt;put-objects&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These let you do things like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s3-credentials get-objects my-bucket -p "*.txt" -p "static/*.css"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This downloads every key in &lt;code&gt;my-bucket&lt;/code&gt; with a name that matches either of those patterns.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s3-credentials put-objects my-bucket one.txt ../other-directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This uploads &lt;code&gt;one.txt&lt;/code&gt; and the whole &lt;code&gt;other-directory&lt;/code&gt; folder with all of its contents.&lt;/p&gt;
&lt;p&gt;As with most of my projects, the GitHub issues threads for each of these include a blow-by-blow account of how I finalized their design - &lt;a href="https://github.com/simonw/s3-credentials/issues/68"&gt;#68&lt;/a&gt; for &lt;code&gt;put-objects&lt;/code&gt; and &lt;a href="https://github.com/simonw/s3-credentials/issues/78"&gt;#78&lt;/a&gt; for &lt;code&gt;get-objects&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;shot-scraper --log-requests&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; is my tool for automating screenshots, &lt;a href="https://simonwillison.net/2022/Mar/10/shot-scraper/"&gt;built on top of Playwright&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Its latest feature was inspired by Datasette Lite.&lt;/p&gt;
&lt;p&gt;I have an ongoing ambition to get Datasette Lite to work &lt;a href="https://github.com/simonw/datasette-lite/issues/26"&gt;entirely offline&lt;/a&gt;, using Service Workers.&lt;/p&gt;
&lt;p&gt;The first step is to get it to work &lt;a href="https://github.com/simonw/datasette-lite/issues/40"&gt;without loading external resources&lt;/a&gt; - it currently hits PyPI and a separate CDN multiple times to download wheels every time you load the application.&lt;/p&gt;
&lt;p&gt;To do that, I need a reliable list of all of the assets that it's fetching.&lt;/p&gt;
&lt;p&gt;Wouldn't it be handy If I could run a command and get a list of those resources?&lt;/p&gt;
&lt;p&gt;The following command now does exactly that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper https://lite.datasette.io/ \
  --wait-for 'document.querySelector("h2")' \
  --log-requests requests.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here' the &lt;code&gt;--wait-for&lt;/code&gt; is needed to ensure &lt;code&gt;shot-scraper&lt;/code&gt; doesn't terminate until the application has fully loaded - detected by waiting for a &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; element to be added to the page.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--log-requests&lt;/code&gt; bit is a &lt;a href="https://shot-scraper.datasette.io/en/stable/screenshots.html#logging-all-requests"&gt;new feature&lt;/a&gt; in &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/0.15"&gt;shot-scraper 0.15&lt;/a&gt;: it logs out a newline-delimited JSON file with details of all of the resources fetched during the run. That file starts like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{"method": "GET", "url": "https://lite.datasette.io/", "size": 10516, "timing": {...}}
{"method": "GET", "url": "https://plausible.io/js/script.manual.js", "size": 1005, "timing": {...}}
{"method": "GET", "url": "https://latest.datasette.io/-/static/app.css?cead5a", "size": 16230, "timing": {...}}
{"method": "GET", "url": "https://lite.datasette.io/webworker.js", "size": 4875, "timing": {...}}
{"method": "GET", "url": "https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js", "size": null, "timing": {...}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is already pretty useful... but wouldn't it be more useful if I could explore that data in Datasette?&lt;/p&gt;
&lt;p&gt;That's what this recipe does:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper https://lite.datasette.io/ \
  --wait-for 'document.querySelector("h2")' \
  --log-requests - | \
  sqlite-utils insert /tmp/datasette-lite.db log - --flatten --nl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's piping the newline-delimited JSON to &lt;code&gt;sqlite-utils insert&lt;/code&gt; which then inserts it, using the &lt;code&gt;--flatten&lt;/code&gt; option to turn that nested &lt;code&gt;timing&lt;/code&gt; object into a flat set of columns.&lt;/p&gt;
&lt;p&gt;I decided to share it by turning it into a SQL dump and publishing that to &lt;a href=""&gt;this Gist&lt;/a&gt;. I did that using the &lt;code&gt;sqlite-utils memory&lt;/code&gt; command to convert it to a SQL dump like so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper https://lite.datasette.io/ \
  --wait-for 'document.querySelector("h2")' \
  --log-requests - | \
  sqlite-utils memory stdin:nl --flatten --dump &amp;gt; dump.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;stdin:nl&lt;/code&gt; means "read from standard input and treat that as newline-delimited JSON". Then I run a &lt;code&gt;select *&lt;/code&gt; command and use &lt;code&gt;--dump&lt;/code&gt; to output that to &lt;code&gt;dump.sql&lt;/code&gt;, which I pasted into a new Gist.&lt;/p&gt;
&lt;p&gt;So now I can &lt;a href="https://lite.datasette.io/?sql=https://gist.githubusercontent.com/simonw/7f41a43ba0f177238ed7bdd95078a0d4/raw/4fc0f80decce4e1ea1e925cdc2bf3f05d73034ed/datasette-lite.sql#/data/stdin"&gt;open the result in Datasette Lite&lt;/a&gt;!&lt;/p&gt;
&lt;h4&gt;Datasette on Sandstorm&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://sandstorm.io/"&gt;Sandstorm&lt;/a&gt; is "an open source
platform for self-hosting web apps". You can think of it as an easy to use UI over a Docker-like container platform - once you've installed it on a server you can use it to manage and install applications that have been bundled for it.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/ocdtrekkie"&gt;Jacob Weisz&lt;/a&gt; has been doing exactly that for Datasette. The result is &lt;a href="https://apps.sandstorm.io/app/uawacvvx9f9ncex1sqj8njwpujf8s9fkmg7wmp55hg6xetrd45w0"&gt;Datasette in the Sandstorm App Market&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-sandstorm.jpg" alt="The listing for Datasette on the Sandstorm App Market, with a prominent DEMO button" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;You can see how it works in the &lt;a href="https://github.com/ocdtrekkie/datasette-sandstorm"&gt;ocdtrekkie/datasette-sandstorm&lt;/a&gt; repo. I helped out by building a small &lt;a href="https://github.com/simonw/datasette-sandstorm-support"&gt;datasette-sandstorm-support&lt;/a&gt; plugin to show how permissions and authentication can work against Sandstorm's &lt;a href="https://docs.sandstorm.io/en/latest/developing/auth/"&gt;custom HTTP headers&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/s3-credentials"&gt;s3-credentials&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/s3-credentials/releases/tag/0.14"&gt;0.14&lt;/a&gt; - (&lt;a href="https://github.com/simonw/s3-credentials/releases"&gt;15 releases total&lt;/a&gt;) - 2022-09-15&lt;br /&gt;A tool for creating credentials for accessing S3 buckets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/0.16"&gt;0.16&lt;/a&gt; - (&lt;a href="https://github.com/simonw/shot-scraper/releases"&gt;21 releases total&lt;/a&gt;) - 2022-09-15&lt;br /&gt;A command-line utility for taking automated screenshots of websites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-edit-templates"&gt;datasette-edit-templates&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-edit-templates/releases/tag/0.1a0"&gt;0.1a0&lt;/a&gt; - 2022-09-14&lt;br /&gt;Plugin allowing Datasette templates to be edited within Datasette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-sandstorm-support"&gt;datasette-sandstorm-support&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-sandstorm-support/releases/tag/0.1"&gt;0.1&lt;/a&gt; - 2022-09-14&lt;br /&gt;Authentication and permissions for Datasette on Sandstorm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-upload-dbs"&gt;datasette-upload-dbs&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-upload-dbs/releases/tag/0.1.2"&gt;0.1.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-upload-dbs/releases"&gt;3 releases total&lt;/a&gt;) - 2022-09-09&lt;br /&gt;Upload SQLite database files to Datasette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-upload-csvs"&gt;datasette-upload-csvs&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-upload-csvs/releases/tag/0.8.2"&gt;0.8.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-upload-csvs/releases"&gt;13 releases total&lt;/a&gt;) - 2022-09-08&lt;br /&gt;Datasette plugin for uploading CSV files and converting them to database tables&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/docker/pytest-docker"&gt;Run pytest against a specific Python version using Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/github/clone-and-push-gist"&gt;Clone, edit and push files that live in a Gist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/macos/external-display-laptop"&gt;Driving an external display from a Mac laptop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/macos/ifuse-iphone"&gt;Browse files (including SQLite databases) on your iPhone with ifuse&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/python/pypy-macos"&gt;Running PyPy on macOS using Homebrew&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3-credentials"&gt;s3-credentials&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-copilot"&gt;github-copilot&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="datasette"/><category term="weeknotes"/><category term="s3-credentials"/><category term="shot-scraper"/><category term="datasette-lite"/><category term="github-copilot"/></entry><entry><title>Analyzing ScotRail audio announcements with Datasette - from prototype to production</title><link href="https://simonwillison.net/2022/Aug/21/scotrail/#atom-series" rel="alternate"/><published>2022-08-21T02:04:59+00:00</published><updated>2022-08-21T02:04:59+00:00</updated><id>https://simonwillison.net/2022/Aug/21/scotrail/#atom-series</id><summary type="html">
    &lt;p&gt;Scottish train operator ScotRail released a two-hour long MP3 file containing all of the components of its automated station announcements. Messing around with them is proving to be a huge amount of fun.&lt;/p&gt;
&lt;p&gt;I now have a Datasette instance running at &lt;a href="https://scotrail.datasette.io/"&gt;scotrail.datasette.io&lt;/a&gt; with a table of 2,440 MP3 files of announcement snippets. You can search them, filter them and play them there - either individual clips or as a sequence of clips attached together.&lt;/p&gt;
&lt;p&gt;A few examples of things you can do with it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Here are all of &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Reason"&gt;421 reasons&lt;/a&gt; that a train might be delayed or cancelled.&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://scotrail.datasette.io/scotrail/announcements?_search=bomb"&gt;search for bomb&lt;/a&gt; turns up four audio clips about a "wartime bomb near the railway". Searching for &lt;a href="https://scotrail.datasette.io/scotrail/announcements?_search=theft"&gt;theft&lt;/a&gt; is fun too.&lt;/li&gt;
&lt;li&gt;Here's every clip in &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Special+train"&gt;the Special Train category&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Here are &lt;a href="https://scotrail.datasette.io/scotrail?sql=select+*+from+announcements+order+by+random()+limit+100"&gt;100 random clips&lt;/a&gt; - hit "Play 100 MP3s on this page" to listen to them all in sequence.&lt;/li&gt;
&lt;li&gt;This page generates &lt;a href="https://scotrail.datasette.io/scotrail/random_apology"&gt;a random apology&lt;/a&gt; by gluing together six different clips, mad libs style.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/scotrail-apology.jpg" alt="random_apology query returning 7 rows: I am sorry to announce that the South West Trains service to Wilmslow has been cancelled due to Overcrowding because an earlier train had fewer coaches than normal" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The rest of this post is a detailed account of how I built this Datasette instance, from early prototypes on &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt; to a full, customized instance of Datasette running on Vercel and with build and deploy automation powered by GitHub Actions.&lt;/p&gt;
&lt;h4&gt;Origins of the data&lt;/h4&gt;
&lt;p&gt;Yesterday, Daily Record journalist Jon Brady &lt;a href="https://twitter.com/jonbradyphoto/status/1560630522002321408"&gt;tweeted this&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Bizarre FOI find of the day: Scotrail has openly published a two-hour long sound file containing every single element of its automated station announcements &lt;a href="https://files.scotrail.co.uk/ScotRail_Station_Announcements_June2022.mp3"&gt;https://files.scotrail.co.uk/ScotRail_Station_Announcements_June2022.mp3&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An hour and a half later, developer Matt Eason &lt;a href="https://twitter.com/MattEason/status/1560653413783744512"&gt;replied&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If anyone needs this split into 2,440 individual mp3s - because why wouldn't you - I've put them here: &lt;a href="https://drive.google.com/drive/folders/172W6sXnvlr7UcNLipO8BTw417_KRz9c5?usp=sharing"&gt;https://drive.google.com/drive/folders/172W6sXnvlr7UcNLipO8BTw417_KRz9c5?usp=sharing&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And if anyone wants to help transcribe all the files, here's a shared sheet, which has a good chance of descending into chaos:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/edit#gid=2073317291"&gt;https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/edit#gid=2073317291&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(Matt later &lt;a href="https://github.com/matteason/scotrail-announcements-june-2022/blob/main/README.md"&gt;shared notes&lt;/a&gt; about how he used &lt;a href="https://www.nch.com.au/wavepad/index.html"&gt;WavePad&lt;/a&gt; to do this.)&lt;/p&gt;
&lt;p&gt;It took two hours for a collective of anonymous volunteers to dive through all of the files and transcribe every single one of them.&lt;/p&gt;
&lt;p&gt;I first heard about this all earlier today, when Matt shared his project &lt;a href="https://www.matteason.co.uk/scotbeats/"&gt;Ambient Scotrail Beats&lt;/a&gt; - an absolutely superb project that overlays random snippets of ScotRail announcement on top of ambient "lofi hip hop radio" - or any YouTube clip you want to enhance in that way.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/scotrail-ambient-beats.jpg" alt="Ambient ScotRail Beats - a UI that lets you select a track, or paste in a YouTube URL, pick an announcement volume and delay and  then click Play to listen to the result" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I love how Matt credits Voiceover artist &lt;a href="https://www.alisonmckay.com/"&gt;Alison McKay&lt;/a&gt;, who originally recorded the announcements for ScotRail, in his work.&lt;/p&gt;
&lt;p&gt;I couldn't resist throwing the data into &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; to see what kind of things I could do with it.&lt;/p&gt;
&lt;h4&gt;Starting out with Datasette Lite&lt;/h4&gt;
&lt;p&gt;My first step to explore the data was to try it out in &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt;, my build of Datasette that &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;runs in WebAssembly entirely in the browser&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The great thing about Datasette Lite is that you don't need to deploy anything anywhere in order to use it for a new project.&lt;/p&gt;
&lt;p&gt;I exported a CSV file from &lt;a href="https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/edit#gid=2073317291"&gt;the Google Sheet&lt;/a&gt;, pasted it into &lt;a href="https://gist.github.com/simonw/8a2494a3402450716f4c8129d280b133"&gt;this GitHub Gist&lt;/a&gt;, grabbed the URL to the &lt;a href="https://gist.githubusercontent.com/simonw/8a2494a3402450716f4c8129d280b133/raw/76f4f030a64318082496c51ffa6e572f3d8d00bd/ScotRail%2520announcements%2520June%25202022%2520index%2520-%2520Index.csv"&gt;raw CSV file&lt;/a&gt; and pasted that into Datasette Lite.&lt;/p&gt;
&lt;p&gt;I ran it through GitHub Gists because Datasette Lite needs data to have open CORS headers in order to load it, and GitHub Gists (and files on GitHub itself) have these headers.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update:&lt;/strong&gt; This turns out to be unneccessary: Google Sheets serves these headers too, so given a URL to &lt;a href="https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/export?format=csv"&gt;a Google Sheets CSV export&lt;/a&gt; Datasette Lite can &lt;a href="https://lite.datasette.io/?csv=https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/export?format=csv#/data/export?_facet=Category"&gt;open that directly&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?csv=https%3A%2F%2Fgist.githubusercontent.com%2Fsimonw%2F8a2494a3402450716f4c8129d280b133%2Fraw%2F76f4f030a64318082496c51ffa6e572f3d8d00bd%2FScotRail%252520announcements%252520June%2525202022%252520index%252520-%252520Index.csv#/data/ScotRail~252520announcements~252520June~2525202022~252520index~252520-~252520Index?_facet=Category"&gt;Here's the result in Datasette Lite&lt;/a&gt;. Just being able to facet by Category was already pretty interesting:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/scotrail-facets.jpg" alt="A table of snippets, faceted by Category so you can see there are 1,271 destinations, 421 reasons, 161 times and so on." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I &lt;a href="https://twitter.com/simonw/status/1561024141121048576"&gt;started a Twitter thread&lt;/a&gt; sharing my progress so far. This ended up running for the next eight hours as my experiments got increasingly ambitious.&lt;/p&gt;
&lt;h4&gt;Adding an audio player&lt;/h4&gt;
&lt;p&gt;Wouldn't it be neat if you could listen to those MP3 files right there in the Datasette interface?&lt;/p&gt;
&lt;p&gt;A few days ago I added &lt;a href="https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/"&gt;plugin support to Datasette Lite&lt;/a&gt; - you can now add &lt;code&gt;?install=name-of-plugin&lt;/code&gt; to the URL to install and use a new plugin.&lt;/p&gt;
&lt;p&gt;This seemed like the perfect opportunity to put that new capability to good use!&lt;/p&gt;
&lt;p&gt;Matt had uploaded all of the individual MP3 files to &lt;a href="https://github.com/matteason/scotrail-announcements-june-2022"&gt;his scotrail-announcements-june-2022 GitHub repository&lt;/a&gt;. This meant that you could get the URL for a snippet called &lt;code&gt;0008.mp3&lt;/code&gt; by constructing the following URL:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;https://github.com/matteason/scotrail-announcements-june-2022/raw/main/announcements/0008.mp3&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;What I needed was a Datasette plugin that could spot URLs ending in &lt;code&gt;.mp3&lt;/code&gt; and turn them into an interactive audio player.&lt;/p&gt;
&lt;p&gt;The easiest way to create a player in modern HTML is like this:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;audio&lt;/span&gt; &lt;span class="pl-c1"&gt;controls&lt;/span&gt; &lt;span class="pl-c1"&gt;src&lt;/span&gt;="&lt;span class="pl-s"&gt;https://github.com/matteason/scotrail-announcements-june-2022/raw/main/announcements/0008.mp3&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
  Audio not supported
&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;audio&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Datasette has a &lt;a href="https://docs.datasette.io/en/stable/plugin_hooks.html#render-cell-row-value-column-table-database-datasette"&gt;render_cell() plugin hook&lt;/a&gt; which allows plugins to customize the way individual table cells are rendered.&lt;/p&gt;
&lt;p&gt;Here's the full implementation of a plugin that turns &lt;code&gt;.mp3&lt;/code&gt; URLs into audio elements:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;hookimpl&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;markupsafe&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Markup&lt;/span&gt;, &lt;span class="pl-s1"&gt;escape&lt;/span&gt;


&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;hookimpl&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;render_cell&lt;/span&gt;(&lt;span class="pl-s1"&gt;value&lt;/span&gt;):
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;not&lt;/span&gt; &lt;span class="pl-en"&gt;isinstance&lt;/span&gt;(&lt;span class="pl-s1"&gt;value&lt;/span&gt;, &lt;span class="pl-s1"&gt;str&lt;/span&gt;):
        &lt;span class="pl-k"&gt;return&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt;.&lt;span class="pl-en"&gt;endswith&lt;/span&gt;(&lt;span class="pl-s"&gt;".mp3"&lt;/span&gt;) &lt;span class="pl-c1"&gt;and&lt;/span&gt; (
        &lt;span class="pl-s1"&gt;value&lt;/span&gt;.&lt;span class="pl-en"&gt;startswith&lt;/span&gt;(&lt;span class="pl-s"&gt;"http://"&lt;/span&gt;)
        &lt;span class="pl-c1"&gt;or&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt;.&lt;span class="pl-en"&gt;startswith&lt;/span&gt;(&lt;span class="pl-s"&gt;"https://"&lt;/span&gt;)
        &lt;span class="pl-c1"&gt;or&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt;.&lt;span class="pl-en"&gt;startswith&lt;/span&gt;(&lt;span class="pl-s"&gt;"/"&lt;/span&gt;)
    ):
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-v"&gt;Markup&lt;/span&gt;(
            &lt;span class="pl-s"&gt;'&amp;lt;audio controls src="{}"&amp;gt;Audio not supported&amp;lt;/audio&amp;gt;'&lt;/span&gt;.&lt;span class="pl-en"&gt;format&lt;/span&gt;(&lt;span class="pl-en"&gt;escape&lt;/span&gt;(&lt;span class="pl-s1"&gt;value&lt;/span&gt;))
        )&lt;/pre&gt;
&lt;p&gt;I fired up a new plugin using my &lt;a href="https://github.com/simonw/datasette-plugin-template-repository"&gt;datasette-plugin&lt;/a&gt; cookiecutter/GitHub repository template called &lt;a href="https://github.com/simonw/datasette-mp3-audio"&gt;datasette-mp3-audio&lt;/a&gt;, dropped in that implementation, added &lt;a href="https://github.com/simonw/datasette-mp3-audio/blob/0.1/tests/test_mp3_audio.py"&gt;a basic test&lt;/a&gt; and shipped it &lt;a href="https://pypi.org/project/datasette-mp3-audio"&gt;to PyPI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now, adding &lt;code&gt;?install=datasette-mp3-audio&lt;/code&gt; to the Datasette Lite URL would install that plugin!&lt;/p&gt;
&lt;p&gt;I built a &lt;a href="https://gist.github.com/simonw/0a30d52feeb3ff60f7d8636b0bde296b"&gt;new copy of the CSV file&lt;/a&gt; with those full MP3 URLs in it, and loaded that up in Datasette Lite:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?install=datasette-mp3-audio&amp;amp;csv=https://gist.githubusercontent.com/simonw/0a30d52feeb3ff60f7d8636b0bde296b/raw/c078a9e5a0151331e2e46c04c1ebe7edc9f45e8c/scotrail-announcements.csv#/data/scotrail-announcements"&gt;https://lite.datasette.io/?install=datasette-mp3-audio&amp;amp;csv=https://gist.githubusercontent.com/simonw/0a30d52feeb3ff60f7d8636b0bde296b/raw/c078a9e5a0151331e2e46c04c1ebe7edc9f45e8c/scotrail-announcements.csv#/data/scotrail-announcements&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/scotrail-mp3-players.jpg" alt="The scotrail announcements table, now with each row featuring an interactive MP3 player widget in the mp3 column." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Being able to listen to the clips right there in the Datasette interface made the whole project instantly enormously more fun and interesting.&lt;/p&gt;
&lt;h4&gt;Upgrading to a full Datasette&lt;/h4&gt;
&lt;p&gt;Prototyping this on Datasette Lite had been fast and fun, but the long load times were beginning to grate - downloading and running a full copy of Python-compiled-to-WebAssembly every time someone loads the site didn't make for a great first impression.&lt;/p&gt;
&lt;p&gt;I decided to ship a server-side Datasette instance. The database is tiny - just 504KB - so I decided to use &lt;a href="https://vercel.com/"&gt;Vercel&lt;/a&gt; for this. I tend to switch to Cloud Run when my databases grow above about 50MB.&lt;/p&gt;
&lt;p&gt;I like to automate my projects as early as possible, so I started a &lt;a href="https://github.com/simonw/scotrail-datasette"&gt;simonw/scotrail-datasette&lt;/a&gt; GitHub repository so I could run the deploys using GitHub Actions.&lt;/p&gt;
&lt;h4&gt;Fetching the CSV with GitHub Actions&lt;/h4&gt;
&lt;p&gt;First, I wanted a copy of the data in my repository - so that any future changes to the Google Sheet wouldn't break my project.&lt;/p&gt;
&lt;p&gt;I followed my &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt; pattern and built &lt;a href="https://github.com/simonw/scotrail-datasette/blob/b1126635050303476f691a074a77ba6b1dae6b18/.github/workflows/fetch-csv.yml"&gt;a quick GitHub Actions workflow&lt;/a&gt; to download the Google Sheets data as CSV and stash it in the repository:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Fetch CSV from Google Sheets&lt;/span&gt;

&lt;span class="pl-ent"&gt;on&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;workflow_dispatch&lt;/span&gt;:

&lt;span class="pl-ent"&gt;permissions&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;contents&lt;/span&gt;: &lt;span class="pl-s"&gt;write&lt;/span&gt;

&lt;span class="pl-ent"&gt;jobs&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;fetch-csv&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;runs-on&lt;/span&gt;: &lt;span class="pl-s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="pl-ent"&gt;steps&lt;/span&gt;:
    - &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;actions/checkout@v3&lt;/span&gt;
    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Set up Python 3.10&lt;/span&gt;
      &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;actions/setup-python@v3&lt;/span&gt;
      &lt;span class="pl-ent"&gt;with&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;python-version&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;3.10&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
        &lt;span class="pl-ent"&gt;cache&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pip&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Install dependencies&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;        pip install -r requirements.txt&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Fetch&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|        &lt;/span&gt;
&lt;span class="pl-s"&gt;        curl -s -L 'https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/export?format=csv' | \&lt;/span&gt;
&lt;span class="pl-s"&gt;          sqlite-utils memory stdin:csv \&lt;/span&gt;
&lt;span class="pl-s"&gt;          'select [File], [NRE ID], Transcription, Category, Notes, Timestamp, [mp3 file] from stdin' \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --no-detect-types \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --csv &amp;gt; announcements.csv&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Commit and push&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;        git config user.name "Automated"&lt;/span&gt;
&lt;span class="pl-s"&gt;        git config user.email "actions@users.noreply.github.com"&lt;/span&gt;
&lt;span class="pl-s"&gt;        git add -A&lt;/span&gt;
&lt;span class="pl-s"&gt;        timestamp=$(date -u)&lt;/span&gt;
&lt;span class="pl-s"&gt;        git commit -m "${timestamp}" || exit 0&lt;/span&gt;
&lt;span class="pl-s"&gt;        git pull --rebase&lt;/span&gt;
&lt;span class="pl-s"&gt;        git push&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I was hoping I could just grab the CSV file and save it directly to the repo, but it turned out the raw data had some weirdness to it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% curl -s -L 'https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/export?format=csv' | head -n 5 
File,NRE ID,Transcription,Category,Notes,Timestamp,Length,mp3 file,,,,,,,,,,,,,,,,,,,,,,,,
1869,BAI,Blairhill,Destination,,,00:00:00,1869.mp3,,,,,,,,,,,,,,,,,,,,,,,,
1703,BYK,Bentley,Destination,,,00:00:00,1703.mp3,,,,,,,,,,,,,,,,,,,,,,,,
0083,HEX,Hexham,Destination,,,00:00:00,0083.mp3,,,,,,,,,,,,,,,,,,,,,,,,
0004,,South West Trains service to,Train operating company,Bit of a detour,0:00:07,00:00:02,0004.mp3,Progress,#DIV/0!,Progress,#DIV/0!,Progress,#DIV/0!,,,,,,,,,,,,,,,,,,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What's with all of those &lt;code&gt;,,,,,&lt;/code&gt; blank lines?&lt;/p&gt;
&lt;p&gt;It turned out enthusiastic data entry volunteers had scattered some emoji around in the spreadsheet grid to the right of the data, resulting in a whole bunch of extra blank columns in the CSV!&lt;/p&gt;
&lt;p&gt;I decided to solve this using by using my &lt;a href="https://sqlite-utils.readthedocs.io/"&gt;sqlite-utils&lt;/a&gt; tool to clean up the data - in particular, the &lt;a href="https://simonwillison.net/2021/Jun/19/sqlite-utils-memory/"&gt;sqlite-utils memory&lt;/a&gt; mechanism. That's what this bit does:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s -L 'https://docs.google.com/spreadsheets/d/1jAtNLBXLYwTraaC_IGAAs53jJWWEQUtFrocS5jW31JM/export?format=csv' | \
    sqlite-utils memory stdin:csv \
    'select [File], [NRE ID], Transcription, Category, Notes, Timestamp, [mp3 file] from stdin' \
    --no-detect-types \
    --csv &amp;gt; announcements.csv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This fetches the raw CSV data with &lt;code&gt;curl&lt;/code&gt; and loads it into an in-memory database, using &lt;code&gt;stdin:csv&lt;/code&gt; to hint to &lt;code&gt;sqlite-utils&lt;/code&gt; that the data being piped to standard input is in CSV format rather than the default JSON.&lt;/p&gt;
&lt;p&gt;Then it runs a SQL query directly against that in-memory database to pull out just the columns I want to keep.&lt;/p&gt;
&lt;p&gt;The tool automatically detects types in CSV data. In this particular case filenames such as &lt;code&gt;0001&lt;/code&gt; were being truncated to the integer &lt;code&gt;1&lt;/code&gt;, which I didn't want to happen - so I added the &lt;code&gt;--no-detect-types&lt;/code&gt; option to turn that off.&lt;/p&gt;
&lt;p&gt;Finally, I told it to output &lt;code&gt;--csv&lt;/code&gt; and write that to the &lt;code&gt;announcements.csv&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;The workflow ends by writing that new file back to the repository and pushing it, if it has changed.&lt;/p&gt;
&lt;p&gt;The workflow is triggered on &lt;code&gt;workflow_dispatch&lt;/code&gt;, which means I have to click a button in the GitHub Actions UI to trigger it. I clicked that button, waited a few seconds and GitHub Actions wrote &lt;a href="https://github.com/simonw/scotrail-datasette/blob/main/announcements.csv"&gt;this CSV file&lt;/a&gt; back to my repo.&lt;/p&gt;
&lt;h4&gt;Building the SQLite database&lt;/h4&gt;
&lt;p&gt;The next step was to write a script that would load that CSV file into a SQLite database. Again, I used &lt;code&gt;sqlite-utils&lt;/code&gt; for this. Here's the &lt;code&gt;build-db.sh&lt;/code&gt; script I wrote:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#!&lt;/span&gt;/bin/bash&lt;/span&gt;
sqlite-utils insert scotrail.db announcements announcements.csv --csv --pk File
sqlite-utils transform scotrail.db announcements \
  --rename &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;NRE ID&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; NRE_ID \
  --rename &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;mp3 file&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;mp3&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  -o File \
  -o Transcription \
  -o Category \
  -o mp3 \
  -o Notes \
  -o Timestamp \
  -o NRE_ID
sqlite-utils scotrail.db &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;update announcements set mp3 = 'https://github.com/matteason/scotrail-announcements-june-2022/raw/main/announcements/' || mp3&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Enable search&lt;/span&gt;
sqlite-utils enable-fts scotrail.db announcements Transcription --tokenize porter&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It took a few iterations to get this exactly right.&lt;/p&gt;
&lt;p&gt;The first line imports the CSV data into a table.&lt;/p&gt;
&lt;p&gt;The second line does most of the work: it uses &lt;a href="https://simonwillison.net/2020/Sep/23/sqlite-advanced-alter-table/"&gt;sqlite-utils transform&lt;/a&gt; to rename a couple of columns, then change the order of the columns in that table to present them in a more useful order in the Datasette UI.&lt;/p&gt;
&lt;p&gt;Next the script updates the &lt;code&gt;mp3&lt;/code&gt; column to turn those filenames into full URLs.&lt;/p&gt;
&lt;p&gt;Finally, it configures &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#configuring-full-text-search"&gt;full-text search&lt;/a&gt; on the &lt;code&gt;Transcription&lt;/code&gt; column, with porter stemming enabled.&lt;/p&gt;
&lt;h4&gt;Deploying to Vercel&lt;/h4&gt;
&lt;p&gt;Build script in place, the last step was to automate the process of building the database and then deploying it to Vercel.&lt;/p&gt;
&lt;p&gt;I wrote &lt;a href="https://github.com/simonw/scotrail-datasette/blob/88173856254018614c9620d8099a4bb8d80fbb5b/.github/workflows/build-and-deploy.yml"&gt;a second workflow&lt;/a&gt; for that:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Build and deploy&lt;/span&gt;

&lt;span class="pl-ent"&gt;on&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;workflow_dispatch&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;push&lt;/span&gt;:

&lt;span class="pl-ent"&gt;jobs&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;build-and-deploy&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;runs-on&lt;/span&gt;: &lt;span class="pl-s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="pl-ent"&gt;steps&lt;/span&gt;:
    - &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;actions/checkout@v3&lt;/span&gt;
    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Set up Python 3.10&lt;/span&gt;
      &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;actions/setup-python@v3&lt;/span&gt;
      &lt;span class="pl-ent"&gt;with&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;python-version&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;3.10&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
        &lt;span class="pl-ent"&gt;cache&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pip&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Install dependencies&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;        pip install -r requirements.txt&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Build SQLite database&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;./build-db.sh&lt;/span&gt;
    - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Deploy to Vercel&lt;/span&gt;
      &lt;span class="pl-ent"&gt;env&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;VERCEL_TOKEN&lt;/span&gt;: &lt;span class="pl-s"&gt;${{ secrets.VERCEL_TOKEN }}&lt;/span&gt;
      &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;        datasette publish vercel scotrail.db \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --token $VERCEL_TOKEN \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --scope datasette \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --project scotrail \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --install datasette-mp3-audio \&lt;/span&gt;
&lt;span class="pl-s"&gt;          --metadata metadata.yml&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This workflow runs on every push to the repo, but can also be triggered manually.&lt;/p&gt;
&lt;p&gt;It installs Python 3.10 and the dependencies I need, then runs the build script to build the database.&lt;/p&gt;
&lt;p&gt;The interesting bit is that last block, titled "Deploy to Vercel".&lt;/p&gt;
&lt;p&gt;I created a Vercel API token in their dashboard and saved that as the &lt;code&gt;VERCEL_TOKEN&lt;/code&gt; secret in the repository.&lt;/p&gt;
&lt;p&gt;The final block uses the &lt;a href="https://datasette.io/plugins/datasette-publish-vercel"&gt;datasette-publish-vercel&lt;/a&gt; plugin to deploy the site:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;datasette publish vercel scotrail.db \
  --token &lt;span class="pl-smi"&gt;$VERCEL_TOKEN&lt;/span&gt; \
  --scope datasette \
  --project scotrail \
  --install datasette-mp3-audio \
  --metadata metadata.yml&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here we're deploying the &lt;code&gt;scotrail.db&lt;/code&gt; database that was created by our earlier build script. I'm using &lt;code&gt;--scope datasette&lt;/code&gt; to specify the Vercel organization that I want to deploy to, and &lt;code&gt;--project scotrail&lt;/code&gt; to specify the project.&lt;/p&gt;
&lt;p&gt;Vercel will create a new project the first time you use that name, then deploy to that existing project on subsequent runs.&lt;/p&gt;
&lt;p&gt;I install the &lt;a href="https://datasette.io/plugins/datasette-mp3-audio"&gt;datasette-mp3-audio plugin&lt;/a&gt;, described earlier.&lt;/p&gt;
&lt;p&gt;Finally, I specify &lt;code&gt;--metadata metadata.yml&lt;/code&gt; to upload a metadata file with details to be displayed on the homepage of the site. You can see an early version of that file &lt;a href="https://github.com/simonw/scotrail-datasette/blob/15a40c70ca4e21e267fad84d75be96fb61e025c8/metadata.yml"&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This worked! I pushed the new workflow, it ran, and Vercel made my new application available at &lt;code&gt;scotrail.vercel.app&lt;/code&gt;. I used their dashboard to assign a vanity URL of &lt;a href="https://scotrail.datasette.io"&gt;scotrail.datasette.io&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Adding a "play all" button&lt;/h4&gt;
&lt;p&gt;I quickly found myself frustrated at having to click the play button on so many different clips!&lt;/p&gt;
&lt;p&gt;I decided to see if I could use JavaScript to hit play on everything in succession instead.&lt;/p&gt;
&lt;p&gt;I opened up the Firefox DevTools console and started messing around in JavaScript. I eventually found that this recipe did what I wanted:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;playAllAudios&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;audios&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Array&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;from&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;querySelectorAll&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'audio'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;playNext&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;audios&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-k"&gt;return&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;next&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;audios&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;shift&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;next&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addEventListener&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'ended'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;playNext&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;next&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;play&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-en"&gt;playNext&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-en"&gt;playAllAudios&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I &lt;a href="https://github.com/simonw/datasette-mp3-audio/issues/2"&gt;opened an issue&lt;/a&gt; to record my prototype.&lt;/p&gt;
&lt;p&gt;This was while I was still working with Datasette Lite, which still &lt;a href="https://github.com/simonw/datasette-lite/issues/8"&gt;has some limitations&lt;/a&gt; in terms of executing JavaScript from plugins.&lt;/p&gt;
&lt;p&gt;Once I'd upgraded to a full Datasette, I revisited the issue.&lt;/p&gt;
&lt;p&gt;I figured out an extended version of my JavaScript prototype which would add a "Play 33 MP3s on this page" button to any page with two or more audio elements. Here's &lt;a href="https://github.com/simonw/datasette-mp3-audio/blob/7206b759c7888c10cb823f10962fa8f393d61261/datasette_mp3_audio/static/datasette-mp3-audio.js"&gt;the code&lt;/a&gt;. I &lt;a href="https://github.com/simonw/datasette-mp3-audio/commit/4885926f5cd015af29e41928ef272cba1dd9c150"&gt;implemented that in datasette-mp3-audio&lt;/a&gt;, shipped a &lt;a href="https://github.com/simonw/datasette-mp3-audio/releases/0.2"&gt;0.2 release&lt;/a&gt; and added that to the site.&lt;/p&gt;
&lt;p&gt;The new button is really fun! Here it is on &lt;a href="https://scotrail.datasette.io/scotrail?sql=select+*+from+announcements+order+by+random%28%29+limit+100"&gt;a query that returns 100 random snippets&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/scotrail-random.jpg" alt="A custom SQL query: select * from announcements order by random() limit 100 - with a Play 100 MPs3 on this page button." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="random-apologies"&gt;Generating random apologies with a SQL query&lt;/h4&gt;
&lt;p&gt;To really exercise this new feature, I decided to try and build a &lt;a href="https://en.wikipedia.org/wiki/Mad_Libs"&gt;mad lib&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I figured out a SQL query that would construct a random apology, stringing together random snippets from a number of different categories.&lt;/p&gt;
&lt;p&gt;I used CTEs (common table expressions - the &lt;code&gt;with x as (query)&lt;/code&gt; blocks) to pick each of the random components of the final sentence.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://scotrail.datasette.io/scotrail?sql=with+apology+as+%28%0A++++select+Transcription%2C+mp3+from+announcements+where%0A++++++++Category+%3D+%27Apology%27%0A++++order+by+random%28%29+limit+1%0A%29%2C%0Atrain_company+as+%28%0A++++select+Transcription%2C+mp3+from+announcements+where%0A++++++++Category+%3D+%27Train+operating+company%27%0A++++++++and+Transcription+like+%27%25to%25%27%0A++++order+by+random%28%29+limit+1%0A%29%2C%0Adestination+as+%28%0A++++select+Transcription%2C+mp3+from+announcements+where%0A++++++++Category+%3D+%27Destination%27%0A++++order+by+random%28%29+limit+1%0A%29%2C%0Acancelled+as+%28%0A++++select+Transcription%2C+mp3+from+announcements+where%0A++++++++Transcription+%3D+%27has+been+cancelled%27%0A++++limit+1%0A%29%2C%0Adue_to+as+%28%0A++++select+Transcription%2C+mp3+from+announcements+where%0A++++++++Transcription+%3D+%27due+to%27%0A++++limit+1%0A%29%2C%0Areason+as+%28%0A++++select+Transcription%2C+mp3+from+announcements+where%0A++++++++Category+%3D+%27Reason%27%0A++++order+by+random%28%29+limit+1%0A%29%2C%0Acombined+as+%28%0A++select+1+as+sort%2C+%2A+from+apology%0A++union+select+2%2C+%2A+from+train_company%0A++union+select+3%2C+%2A+from+destination%0A++union+select+4%2C+%2A+from+cancelled%0A++union+select+5%2C+%2A+from+due_to%0A++union+select+6%2C+%2A+from+reason%0A%29%0Aselect+%2A+from+combined+order+by+sort"&gt;the query I came up with&lt;/a&gt;, with some extra inline commentary:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;with apology &lt;span class="pl-k"&gt;as&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; Transcription, mp3 &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;where&lt;/span&gt;
        Category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Apology&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;order by&lt;/span&gt; random() &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Apology"&gt;Apology category&lt;/a&gt; only has 8 rows, each along the lines of "I am sorry to announce that the" or "We are sorry to announce that the".&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;train_company &lt;span class="pl-k"&gt;as&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; Transcription, mp3 &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;where&lt;/span&gt;
        Category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Train operating company&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
        &lt;span class="pl-k"&gt;and&lt;/span&gt; Transcription &lt;span class="pl-k"&gt;like&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%to%&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;order by&lt;/span&gt; random() &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There are &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Train+operating+company"&gt;76 Train operating company&lt;/a&gt; snippets, in formats that include "Midland Main Line service from" and "Southeastern Trains service to". I filtered for just the ones with &lt;code&gt;to&lt;/code&gt; in the text.&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;destination &lt;span class="pl-k"&gt;as&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; Transcription, mp3 &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;where&lt;/span&gt;
        Category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Destination&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;order by&lt;/span&gt; random() &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There are &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Destination"&gt;1,271&lt;/a&gt; potential destinations - single word names of places, like "Bath Spa" or "Dunfermline Queen Margaret".&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;cancelled &lt;span class="pl-k"&gt;as&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; Transcription, mp3 &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;where&lt;/span&gt;
        Transcription &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;has been cancelled&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
),
due_to &lt;span class="pl-k"&gt;as&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; Transcription, mp3 &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;where&lt;/span&gt;
        Transcription &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;due to&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There's probably a neater way to do this - I just wanted the literal text "has been cancelled due to" in every result.&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;reason &lt;span class="pl-k"&gt;as&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; Transcription, mp3 &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;where&lt;/span&gt;
        Category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Reason&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;order by&lt;/span&gt; random() &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;These are the really fun ones. There are &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Reason"&gt;421&lt;/a&gt; snippets marked "Reason", everything from "bad weather conditions" to "...a burst water main near the railway, yesterday" to "a wartime bomb near the railway earlier today"!&lt;/p&gt;
&lt;p&gt;The final piece of the query combined these together into a list, with an artificially added &lt;code&gt;sort&lt;/code&gt; column to guarantee the order (which I don't think is strictly necessary in SQLite, but better safe than sorry):&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;combined &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; sort, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; apology
  &lt;span class="pl-k"&gt;union&lt;/span&gt; &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; train_company
  &lt;span class="pl-k"&gt;union&lt;/span&gt; &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;3&lt;/span&gt;, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; destination
  &lt;span class="pl-k"&gt;union&lt;/span&gt; &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;4&lt;/span&gt;, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; cancelled
  &lt;span class="pl-k"&gt;union&lt;/span&gt; &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;5&lt;/span&gt;, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; due_to
  &lt;span class="pl-k"&gt;union&lt;/span&gt; &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;6&lt;/span&gt;, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; reason
)
&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; combined &lt;span class="pl-k"&gt;order by&lt;/span&gt; sort&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This totally works!&lt;/p&gt;
&lt;p&gt;Since the bookmarked query is a pretty long URL I decided to use Datasette's &lt;a href="https://docs.datasette.io/en/stable/sql_queries.html#canned-queries"&gt;canned query&lt;/a&gt; mechanism to turn it into a more easily discovered page:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://scotrail.datasette.io/scotrail/random_apology"&gt;scotrail.datasette.io/scotrail/random_apology&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I also chose to hide the SQL query by default, to put the essential "Play 6 MP3s on this page" button above the fold.&lt;/p&gt;
&lt;p&gt;I linked to it from the &lt;a href="https://scotrail.datasette.io/"&gt;site homepage&lt;/a&gt; too, using &lt;code&gt;description_html&lt;/code&gt; in &lt;a href="https://github.com/simonw/scotrail-datasette/blob/b1126635050303476f691a074a77ba6b1dae6b18/metadata.yml"&gt;the site metadata.yml&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/scotrail-homepage.jpg" alt="The homepage of the site reads: ScotRail announcements. ScotRail released an audio file of all of their announcements, voiced by Alison McKay. Matt Eason and a team of volunteers split the file into separate mp3s and transcribed them. This Datasette instance provides an interface and JSON API to that data. Search for an announcement snippet, or assemble and listen to a random apology." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="assembling-sentences"&gt;Assembling full sentences from user input&lt;/h4&gt;
&lt;p&gt;I wanted the ability to stitch together my own custom phrases - specifying a sequence of clips to be played in order.&lt;/p&gt;
&lt;p&gt;I decided to do this using an input that was a comma separated list of search terms:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;i am sorry, scotrail, from, bath spa, is delayed, due to, bomb&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;My idea was to split this into search terms and return the first clip for each of those, in order. Then I could hit the "play all" button to listen to them in sequence.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://scotrail.datasette.io/scotrail/assemble_sentence?terms=i+am+sorry%2C+scotrail%2C+from%2C+bath+spa%2C+is+delayed%2C+due+to%2C+bomb"&gt;Try this feature out&lt;/a&gt; - I built it as another canned query. Click the &lt;a href="https://scotrail.datasette.io/scotrail/assemble_sentence?terms=i+am+sorry%2C+scotrail%2C+from%2C+bath+spa%2C+is+delayed%2C+due+to%2C+bomb&amp;amp;_show_sql=1"&gt;show link&lt;/a&gt; to see the full SQL query.&lt;/p&gt;
&lt;p&gt;Here's that SQL, with additional comments:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;with phrases &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    key, value
  &lt;span class="pl-k"&gt;from&lt;/span&gt;
    json_each(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;["&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; replace(:terms, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;,&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;","&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;)
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is the trickiest bit. I needed to split that user input of a comma separated string into separate terms, so I could run a search against each one.&lt;/p&gt;
&lt;p&gt;The trick I'm using here is to first convert that string into JSON. Here's a simplified version:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;["&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; replace(:terms, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;,&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;","&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://scotrail.datasette.io/scotrail?sql=select+%27%5B%22%27+%7C%7C+replace%28%3Aterms%2C+%27%2C%27%2C+%27%22%2C%22%27%29+%7C%7C+%27%22%5D%27&amp;amp;terms=a%2C+b%2C+c"&gt;Try that here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Note that users can break this by including double quotes in their input - I'm OK with that, it just means the query will exit with an error.&lt;/p&gt;
&lt;p&gt;Once I have a JSON list of terms I can use the SQLite &lt;a href="https://www.sqlite.org/json1.html#jeach"&gt;json_each table-valued function&lt;/a&gt;. This turns that JSON into a table you can join against other things.&lt;/p&gt;
&lt;p&gt;Try &lt;a href="https://scotrail.datasette.io/scotrail?sql=select+*+from+json_each%28%27%5B%22one%22%2C+%22two%22%2C+%22three%22%5D%27%29"&gt;select * from json_each('["one", "two", "three"]')&lt;/a&gt; to see what that table looks like:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;atom&lt;/th&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;parent&lt;/th&gt;
&lt;th&gt;fullkey&lt;/th&gt;
&lt;th&gt;path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$[0]&lt;/td&gt;
&lt;td&gt;$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;two&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;two&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$[1]&lt;/td&gt;
&lt;td&gt;$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;three&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;three&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$[2]&lt;/td&gt;
&lt;td&gt;$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;We only need the &lt;code&gt;key&lt;/code&gt; column (to maintain the order of the search terms) and the &lt;code&gt;value&lt;/code&gt; column (the search term itself).&lt;/p&gt;
&lt;p&gt;Back to the next part of that big SQL query:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;matches &lt;span class="pl-k"&gt;as&lt;/span&gt; (&lt;span class="pl-k"&gt;select&lt;/span&gt;
  &lt;span class="pl-c1"&gt;phrases&lt;/span&gt;.&lt;span class="pl-c1"&gt;key&lt;/span&gt;,
  &lt;span class="pl-c1"&gt;phrases&lt;/span&gt;.&lt;span class="pl-c1"&gt;value&lt;/span&gt;,
  (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; File &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements
    &lt;span class="pl-k"&gt;where&lt;/span&gt; &lt;span class="pl-c1"&gt;announcements&lt;/span&gt;.&lt;span class="pl-c1"&gt;Transcription&lt;/span&gt; &lt;span class="pl-k"&gt;like&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-c1"&gt;trim&lt;/span&gt;(&lt;span class="pl-c1"&gt;phrases&lt;/span&gt;.&lt;span class="pl-c1"&gt;value&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-k"&gt;order by&lt;/span&gt; length(&lt;span class="pl-c1"&gt;announcements&lt;/span&gt;.&lt;span class="pl-c1"&gt;Transcription&lt;/span&gt;)
    &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;1&lt;/span&gt;
  ) &lt;span class="pl-k"&gt;as&lt;/span&gt; File
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  phrases
),&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is the part that runs a search for each of the phrases from that JSON list.&lt;/p&gt;
&lt;p&gt;It's using a subselect in the &lt;code&gt;select&lt;/code&gt; clause to run the actual search.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://scotrail.datasette.io/scotrail?sql=select+File%2C+Transcription%2C+mp3+from+announcements%0D%0Awhere+announcements.Transcription+like+%27%25%27+%7C%7C+trim%28%3Aphrase%29+%7C%7C+%27%25%27%0D%0Aorder+by+length%28announcements.Transcription%29%0D%0Alimit+20&amp;amp;phrase=the"&gt;Try a variant of that subselect&lt;/a&gt; to see how it works - it looks for clips that match a like query of &lt;code&gt;%search term%&lt;/code&gt;, then orders by length to find the &lt;em&gt;shortest&lt;/em&gt; match. This is so that if you type &lt;code&gt;the&lt;/code&gt; you'll get back the word "the" and not the destination "Atherton".&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;results &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; key, &lt;span class="pl-c1"&gt;announcements&lt;/span&gt;.&lt;span class="pl-c1"&gt;Transcription&lt;/span&gt;, &lt;span class="pl-c1"&gt;announcements&lt;/span&gt;.&lt;span class="pl-c1"&gt;mp3&lt;/span&gt;
  &lt;span class="pl-k"&gt;from&lt;/span&gt; announcements &lt;span class="pl-k"&gt;join&lt;/span&gt; matches &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;announcements&lt;/span&gt;.&lt;span class="pl-c1"&gt;File&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;matches&lt;/span&gt;.&lt;span class="pl-c1"&gt;File&lt;/span&gt;
  &lt;span class="pl-k"&gt;order by&lt;/span&gt; key
)&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;We only returned the &lt;code&gt;File&lt;/code&gt; column from our subselect, because when you put a subselect in a SELECT you can only return a single column.&lt;/p&gt;
&lt;p&gt;This next bit joins our matches back against the announcements table to get the other data we need: the transcription text and the mp3 file.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://scotrail.datasette.io/scotrail?sql=with+phrases+as+%28%0D%0A++select%0D%0A++++key%2C+value%0D%0A++from%0D%0A++++json_each%28%27%5B%22%27+%7C%7C+replace%28%3Aterms%2C+%27%2C%27%2C+%27%22%2C%22%27%29+%7C%7C+%27%22%5D%27%29%0D%0A%29%2C%0D%0Amatches+as+%28select%0D%0A++phrases.key%2C%0D%0A++phrases.value%2C%0D%0A++%28%0D%0A++++select+File+from+announcements%0D%0A++++where+announcements.Transcription+like+%27%25%27+%7C%7C+trim%28phrases.value%29+%7C%7C+%27%25%27%0D%0A++++order+by+length%28announcements.Transcription%29%0D%0A++++limit+1%0D%0A++%29+as+File%0D%0Afrom%0D%0A++phrases%0D%0A%29%2C%0D%0Aresults+as+%28%0D%0A++select+key%2C+announcements.Transcription%2C+announcements.mp3%0D%0A++from+announcements+join+matches+on+announcements.File+%3D+matches.File%0D%0A++order+by+key%0D%0A%29%0D%0Aselect+*+from+results&amp;amp;terms=i+am+sorry%2C+scotrail%2C+from%2C+bath+spa%2C+is+delayed%2C+due+to%2C+bomb"&gt;Run a select * from results query&lt;/a&gt; to see our results so far.&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Combined sentence:&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; mp3,
  group_concat(Transcription, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt; &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;as&lt;/span&gt; Transcription,
  &lt;span class="pl-k"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; key
&lt;span class="pl-k"&gt;from&lt;/span&gt; results
  &lt;span class="pl-k"&gt;union&lt;/span&gt;
&lt;span class="pl-k"&gt;select&lt;/span&gt;
  mp3, Transcription, key
&lt;span class="pl-k"&gt;from&lt;/span&gt; results
&lt;span class="pl-k"&gt;order by&lt;/span&gt; key&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This last step is an extra flourish. I decided it would be useful to show the full combined sentence as text, in addition to presenting the individual clips.&lt;/p&gt;
&lt;p&gt;I'm using a union for this. The first &lt;code&gt;select&lt;/code&gt; query sets the &lt;code&gt;mp3&lt;/code&gt; column to the text "Combined sentence:" and then uses the &lt;code&gt;group_concat()&lt;/code&gt; SQLite function to join all of the Transcription values together as a space-separated list. It adds on a &lt;code&gt;-1&lt;/code&gt; as the &lt;code&gt;key&lt;/code&gt; so we can sort by that later and have this artificial row show up first.&lt;/p&gt;
&lt;p&gt;Then it runs a union against the rows from that &lt;code&gt;results&lt;/code&gt; CTE.&lt;/p&gt;
&lt;p&gt;The final output looks like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mp3&lt;/th&gt;
&lt;th&gt;Transcription&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Combined sentence:&lt;/td&gt;
&lt;td&gt;I am sorry to announce that the ScotRail service from Bath Spa is delayed due to A wartime bomb near the railway&lt;/td&gt;
&lt;td&gt;-1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../0031.mp3&lt;/td&gt;
&lt;td&gt;I am sorry to announce that the&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../0032.mp3&lt;/td&gt;
&lt;td&gt;ScotRail&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../1778.mp3&lt;/td&gt;
&lt;td&gt;service from&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../0006.mp3&lt;/td&gt;
&lt;td&gt;Bath Spa&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../1750.mp3&lt;/td&gt;
&lt;td&gt;is delayed&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../1528.mp3&lt;/td&gt;
&lt;td&gt;due to&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.../0946.mp3&lt;/td&gt;
&lt;td&gt;A wartime bomb near the railway&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;Make fun stuff with the API&lt;/h4&gt;
&lt;p&gt;As you can probably tell, I had a ridiculous amount of fun playing with this data.&lt;/p&gt;
&lt;p&gt;If you want to build some things with this, you should know that every page on the Datasette instance has an accompanying API. Look for the &lt;code&gt;JSON&lt;/code&gt; links on each page - Datasette serves them with CORS headers so you can fetch data from them from any domain on the Web.&lt;/p&gt;
&lt;p&gt;I recommend adding &lt;code&gt;?_shape=objects&lt;/code&gt; to the array, since this shape of data is closer to the breaking change I have planned for Datasette 1.0. Here's an example returning one of those random apologies:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://scotrail.datasette.io/scotrail/random_apology.json?_shape=objects"&gt;https://scotrail.datasette.io/scotrail/random_apology.json?_shape=objects&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I also installed the &lt;a href="https://datasette.io/plugins/datasette-graphql"&gt;datasette-graphql&lt;/a&gt; plugin, so if you want to use GraphQL instead you can do something &lt;a href="https://scotrail.datasette.io/graphql/scotrail?query=%7B%0A%20%20announcements(filter%3A%20%7BCategory%3A%20%7Beq%3A%20%22Special%20train%22%7D%7D)%20%7B%0A%20%20%20%20nodes%20%7B%0A%20%20%20%20%20%20File%0A%20%20%20%20%20%20Transcription%0A%20%20%20%20%20%20Category%0A%20%20%20%20%20%20mp3%0A%20%20%20%20%20%20Notes%0A%20%20%20%20%20%20Timestamp%0A%20%20%20%20%20%20NRE_ID%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D"&gt;like this&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-graphql"&gt;&lt;pre&gt;{
  &lt;span class="pl-v"&gt;announcements&lt;/span&gt;(&lt;span class="pl-v"&gt;filter&lt;/span&gt;: {&lt;span class="pl-c1"&gt;&lt;span class="pl-s"&gt;Category&lt;/span&gt;&lt;/span&gt;: {&lt;span class="pl-c1"&gt;&lt;span class="pl-s"&gt;eq&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Special train&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;}}) {
    &lt;span class="pl-v"&gt;nodes&lt;/span&gt; {
      &lt;span class="pl-v"&gt;File&lt;/span&gt;
      &lt;span class="pl-v"&gt;Transcription&lt;/span&gt;
      &lt;span class="pl-v"&gt;Category&lt;/span&gt;
      &lt;span class="pl-v"&gt;mp3&lt;/span&gt;
      &lt;span class="pl-v"&gt;Notes&lt;/span&gt;
      &lt;span class="pl-v"&gt;Timestamp&lt;/span&gt;
      &lt;span class="pl-v"&gt;NRE_ID&lt;/span&gt;
    }
  }
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That query returns the &lt;a href="https://scotrail.datasette.io/scotrail/announcements?Category=Special+train"&gt;12 rows in the Special train category&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Let me know &lt;a href="https://twitter.com/simonw"&gt;on Twitter&lt;/a&gt; if you build something fun!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prototyping"&gt;prototyping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="prototyping"/><category term="datasette"/><category term="datasette-lite"/></entry><entry><title>Plugin support for Datasette Lite</title><link href="https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/#atom-series" rel="alternate"/><published>2022-08-17T18:20:59+00:00</published><updated>2022-08-17T18:20:59+00:00</updated><id>https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/#atom-series</id><summary type="html">
    &lt;p&gt;I've added a new feature to &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt;, my distribution of &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; that runs entirely in the browser using Python and SQLite compiled to WebAssembly. You can now install additional &lt;a href="https://datasette.io/plugins"&gt;Datasette plugins&lt;/a&gt; by passing them in the URL.&lt;/p&gt;
&lt;h4&gt;Datasette Lite background&lt;/h4&gt;
&lt;p&gt;Datasette Lite runs Datasette in the browser. I initially built it as a fun technical proof of concept, but I'm increasingly finding it to be a genuinely useful tool for quick ad-hoc data analysis and publication. Not having any server-side components at all makes it effectively free to use without fear of racking up cloud computing costs for a throwaway project.&lt;/p&gt;
&lt;p&gt;You can read more about Datasette Lite in these posts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;Datasette Lite: a server-side Python web application running in a browser&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/"&gt;Joining CSV files in your browser using Datasette Lite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2022/Jul/30/psf-board/#scraping-datasette-lite"&gt;Scraping data into Datasette Lite&lt;/a&gt; shows an example project where I scraped PSF board resolutions, stored the results in a CSV file in &lt;a href="https://gist.github.com/simonw/71b03ca3688c8f37fe1b35859ebc458b"&gt;a GitHub Gist&lt;/a&gt; and then constructed &lt;a href="https://lite.datasette.io/?csv=https%3A%2F%2Fgist.githubusercontent.com%2Fsimonw%2F71b03ca3688c8f37fe1b35859ebc458b%2Fraw%2Fd3ca708e5d6242848201d1d4c1c2ddbba15c4d28%2Fpsf-resolutions.csv#/data?sql=with+filtered+as+%28%0A++select+*+from%0A++++%5Bpsf-resolutions%5D%0A++where%0A++++%22dollars%22+is+not+null%0A++++and+%22text%22+like+%27%25%27+%7C%7C+%3Asearch+%7C%7C+%27%25%27%0A%29%0Aselect%0A++%27Total%3A+%24%27+%7C%7C+printf%28%27%25%2Cd%27%2C+sum%28dollars%29%29+as+text%2C%0A++null+as+date%0Afrom+filtered%0Aunion+all%0Aselect%0A++text%2C+date%0Afrom+filtered%3B&amp;amp;search=Nigeria"&gt;this URL&lt;/a&gt; to open the result in Datasette Lite and execute a SQL query.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Adding plugins to Datasette Lite&lt;/h4&gt;
&lt;p&gt;One of Datasette's key features is support for plugins. There are over 90 listed in the &lt;a href="https://datasette.io/plugins"&gt;plugin directory&lt;/a&gt; now, with more emerging all the time. They're a fantastic way to explore new feature ideas and extend the software to handle non-default use cases.&lt;/p&gt;
&lt;p&gt;Plugins are Python packages, published to &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt;. You can add them to Datasette Lite using the new &lt;code&gt;?install=name-of-plugin&lt;/code&gt; query string parameter.&lt;/p&gt;
&lt;p&gt;Here's an example URL that loads the &lt;a href="https://datasette.io/plugins/datasette-jellyfish"&gt;datasette-jellyfish&lt;/a&gt; plugin, which adds new SQL functions for calculating distances between strings, then executes a SQL query that demonstrates that plugin:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?install=datasette-jellyfish#/fixtures?sql=SELECT%0A++++levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++damerau_levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++hamming_distance%28%3As1%2C+%3As2%29%2C%0A++++jaro_similarity%28%3As1%2C+%3As2%29%2C%0A++++jaro_winkler_similarity%28%3As1%2C+%3As2%29%2C%0A++++match_rating_comparison%28%3As1%2C+%3As2%29%3B&amp;amp;s1=barrack+obama&amp;amp;s2=barrack+h+obama"&gt;https://lite.datasette.io/?install=datasette-jellyfish#/fixtures?sql=SELECT%0A++++levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++damerau_levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++hamming_distance%28%3As1%2C+%3As2%29%2C%0A++++jaro_similarity%28%3As1%2C+%3As2%29%2C%0A++++jaro_winkler_similarity%28%3As1%2C+%3As2%29%2C%0A++++match_rating_comparison%28%3As1%2C+%3As2%29%3B&amp;amp;s1=barrack+obama&amp;amp;s2=barrack+h+obama&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That URL uses &lt;code&gt;?install=datasette-jellyfish&lt;/code&gt; to install the plugin, then executes the following SQL query:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;SELECT&lt;/span&gt;
    levenshtein_distance(:s1, :s2),
    damerau_levenshtein_distance(:s1, :s2),
    hamming_distance(:s1, :s2),
    jaro_similarity(:s1, :s2),
    jaro_winkler_similarity(:s1, :s2),
    match_rating_comparison(:s1, :s2);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It sets &lt;code&gt;s1&lt;/code&gt; to &lt;code&gt;"barack obama"&lt;/code&gt; and &lt;code&gt;s2&lt;/code&gt; to &lt;code&gt;"barrack h obama&lt;/code&gt;".&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite-plugin.jpg" alt="Screenshot showing the results of that SQL query running in Datasette Lite. It compares the string barrack obama with the string barrack h obama and shows various different scores." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;Plugin compatibility&lt;/h4&gt;
&lt;p&gt;Unfortunately, many existing Datasette plugins aren't yet compatible with Datasette Lite. Most importantly, visualization plugins such as &lt;a href="https://datasette.io/plugins/datasette-cluster-map"&gt;datasette-cluster-map&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-vega"&gt;datasette-vega&lt;/a&gt; don't work.&lt;/p&gt;
&lt;p&gt;This is because I haven't yet solved the challenge of loading additional JavaScript and CSS into Datasette Lite - see &lt;a href="https://github.com/simonw/datasette-lite/issues/8"&gt;issue #8&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's the full list of plugins that I've confirmed work with Datasette Lite so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-packages"&gt;datasette-packages&lt;/a&gt; - Show a list of currently installed Python packages - &lt;a href="https://lite.datasette.io/?install=datasette-packages#/-/packages"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-dateutil"&gt;datasette-dateutil&lt;/a&gt; - dateutil functions for Datasette - &lt;a href="https://lite.datasette.io/?install=datasette-dateutil#/fixtures?sql=select%0A++dateutil_parse%28%2210+october+2020+3pm%22%29%2C%0A++dateutil_parse_fuzzy%28%22This+is+due+10+september%22%29%2C%0A++dateutil_parse%28%221%2F2%2F2020%22%29%2C%0A++dateutil_parse%28%222020-03-04%22%29%2C%0A++dateutil_parse_dayfirst%28%222020-03-04%22%29%3B"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-schema-versions"&gt;datasette-schema-versions&lt;/a&gt; - Datasette plugin that shows the schema version of every attached database - &lt;a href="https://lite.datasette.io/?install=datasette-schema-versions#/-/schema-versions"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-debug-asgi"&gt;datasette-debug-asgi&lt;/a&gt; - Datasette plugin for dumping out the ASGI scope. - &lt;a href="https://lite.datasette.io/?install=datasette-debug-asgi#/-/asgi-scope"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-query-links"&gt;datasette-query-links&lt;/a&gt; - Turn SELECT queries returned by a query into links to execute them - &lt;a href="https://lite.datasette.io/?install=datasette-query-links#/fixtures?sql=select%0D%0A++'select+*+from+%5Bfacetable%5D'+as+query%0D%0Aunion%0D%0Aselect%0D%0A++'select+sqlite_version()'%0D%0Aunion%0D%0Aselect%0D%0A++'select+this+is+invalid+SQL+so+will+not+be+linked'"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-json-html"&gt;datasette-json-html&lt;/a&gt; - Datasette plugin for rendering HTML based on JSON values - &lt;a href="https://lite.datasette.io/?install=datasette-json-html#/fixtures?sql=select+%27%5B%0A++++%7B%0A++++++++%22href%22%3A+%22https%3A%2F%2Fsimonwillison.net%2F%22%2C%0A++++++++%22label%22%3A+%22Simon+Willison%22%0A++++%7D%2C%0A++++%7B%0A++++++++%22href%22%3A+%22https%3A%2F%2Fgithub.com%2Fsimonw%2Fdatasette%22%2C%0A++++++++%22label%22%3A+%22Datasette%22%0A++++%7D%0A%5D%27+as+output"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-haversine"&gt;datasette-haversine&lt;/a&gt; - Datasette plugin that adds a custom SQL function for haversine distances - &lt;a href="https://lite.datasette.io/?install=datasette-haversine#/fixtures?sql=select+haversine%280%2C+154%2C+1%2C+131%29"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-jellyfish"&gt;datasette-jellyfish&lt;/a&gt; - Datasette plugin that adds custom SQL functions for fuzzy string matching, built on top of the Jellyfish Python library - &lt;a href="https://lite.datasette.io/?install=datasette-jellyfish#/fixtures?sql=SELECT%0A++++levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++damerau_levenshtein_distance%28%3As1%2C+%3As2%29%2C%0A++++hamming_distance%28%3As1%2C+%3As2%29%2C%0A++++jaro_similarity%28%3As1%2C+%3As2%29%2C%0A++++jaro_winkler_similarity%28%3As1%2C+%3As2%29%2C%0A++++match_rating_comparison%28%3As1%2C+%3As2%29%3B&amp;amp;s1=barrack+obama&amp;amp;s2=barrack+h+obama"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-pretty-json"&gt;datasette-pretty-json&lt;/a&gt; - Datasette plugin that pretty-prints any column values that are valid JSON objects or arrays. - &lt;a href="https://lite.datasette.io/?install=datasette-pretty-json#/fixtures?sql=select+%27%7B%22this%22%3A+%5B%22is%22%2C+%22nested%22%2C+%22json%22%5D%7D%27"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-yaml"&gt;datasette-yaml&lt;/a&gt; - Export Datasette records as YAML - &lt;a href="https://lite.datasette.io/?install=datasette-yaml#/fixtures/compound_three_primary_keys.yaml"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datasette.io/plugins/datasette-copyable"&gt;datasette-copyable&lt;/a&gt; - Datasette plugin for outputting tables in formats suitable for copy and paste - &lt;a href="https://lite.datasette.io/?install=datasette-copyable#/fixtures/compound_three_primary_keys.copyable?_table_format=github"&gt;demo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;How it works&lt;/h4&gt;
&lt;p&gt;The implementation is pretty simple - it can be seen &lt;a href="https://github.com/simonw/datasette-lite/commit/5e348e84ff2cb38e4749aa36182db78e235dd9bc"&gt;in this commit&lt;/a&gt;. The short version is that &lt;code&gt;?install=&lt;/code&gt; options are passed through to the Python web worker that powers Datasette Lite, which then runs the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;install_url&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;install_urls&lt;/span&gt;:
    &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;.&lt;span class="pl-en"&gt;install&lt;/span&gt;(&lt;span class="pl-s1"&gt;install_url&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://pyodide.org/en/stable/usage/api/micropip-api.html"&gt;micropip&lt;/a&gt; is a component of Pyodide which knows how to install pure Python wheels directly from PyPI into the browser's emulated Python environment. If you open up the browser devtools networking panel you can see that in action!&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite-devtools.jpg" alt="The Firefox Network pane shows a flurry of traffic, some of it to PyPI to look up the JSON descriptions of packages followed by downloads of .whl files from files.pythonhosted.org" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Since the &lt;code&gt;?install=&lt;/code&gt; parameter is being passed directly to &lt;code&gt;micropip.install()&lt;/code&gt; you don't even need to provide names of packages hosted on PyPI - you could instead provide the URL to a wheel file that you're hosting elsewhere.&lt;/p&gt;
&lt;p&gt;This means you can use &lt;code&gt;?install=&lt;/code&gt; as a code injection attack - you can install any Python code you want into the environent. I think that's fine - the only person who will be affected by this is the user who is viewing the page, and the &lt;code&gt;lite.datasette.io&lt;/code&gt; domain deliberately doesn't have any cookies set that could cause  problems if someone were to steal them in some way.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="pypi"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="datasette-lite"/><category term="cors"/></entry><entry><title>Joining CSV files in your browser using Datasette Lite</title><link href="https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/#atom-series" rel="alternate"/><published>2022-06-20T21:20:16+00:00</published><updated>2022-06-20T21:20:16+00:00</updated><id>https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/#atom-series</id><summary type="html">
    &lt;p&gt;I added a new feature to &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt; - my version of &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; that runs entirely in your browser using WebAssembly (&lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;previously&lt;/a&gt;): you can now use it to load one or more CSV files by URL, and then run SQL queries against them - including joins across data from multiple files.&lt;/p&gt;
&lt;p&gt;Your CSV file needs to be hosted somewhere with &lt;code&gt;access-control-allow-origin: *&lt;/code&gt; CORS headers. Any CSV file hosted on GitHub provides these, if you use the link you get by clicking on the "Raw" version.&lt;/p&gt;
&lt;h4&gt;Loading CSV data from a URL&lt;/h4&gt;
&lt;p&gt;Here's the URL to a CSV file of college fight songs collected by FiveThirtyEight &lt;a href="https://github.com/fivethirtyeight/data/tree/master/fight-songs"&gt;in their data repo&lt;/a&gt; as part of the reporting for &lt;a href="https://projects.fivethirtyeight.com/college-fight-song-lyrics/"&gt;this story&lt;/a&gt; a few years ago:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv"&gt;https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can pass this to Datasette Lite in two ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You can &lt;a href="https://lite.datasette.io/"&gt;load the web app&lt;/a&gt;, click the "Load data by URL to a CSV file" button and paste in the URL&lt;/li&gt;
&lt;li&gt;Or you can pass it as a &lt;code&gt;?csv=&lt;/code&gt; parameter to the application, like this: &lt;a href="https://lite.datasette.io/?csv=https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv"&gt;https://lite.datasette.io/?csv=https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once Datasette has loaded, a &lt;code&gt;data&lt;/code&gt; database will be available with a single table called &lt;code&gt;fight-songs&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;As you navigate around in Datasette the URL bar will update to reflect current state - which means you can deep-link to table views with applied filters and facets:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?csv=https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv#/data/fight-songs?_facet=conference&amp;amp;_facet=student_writer&amp;amp;_facet=official_song"&gt;https://lite.datasette.io/?csv=https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv#/data/fight-songs?_facet=conference&amp;amp;_facet=student_writer&amp;amp;_facet=official_song&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Or even link to the result of a custom SQL query:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/?csv=https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv#/data?sql=select+school%2C+conference%2C+song_name%2C+writers%2C+year%2C+student_writer+spotify_id+from+%5Bfight-songs%5D+order+by+rowid+limit+101"&gt;https://lite.datasette.io/?csv=https://raw.githubusercontent.com/fivethirtyeight/data/master/fight-songs/fight-songs.csv#/data?sql=select+school%2C+conference%2C+song_name%2C+writers%2C+year%2C+student_writer+spotify_id+from+%5Bfight-songs%5D+order+by+rowid+limit+101&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;Loading multiple files and joining data&lt;/h4&gt;
&lt;p&gt;You can pass the &lt;code&gt;?csv=&lt;/code&gt; parameter more than once to load data from multiple CSV files into the same virtual &lt;code&gt;data&lt;/code&gt; database. Each CSV file will result in a separate table.&lt;/p&gt;
&lt;p&gt;For this demo I'll use two CSV files.&lt;/p&gt;
&lt;p&gt;The first is &lt;a href="https://github.com/nytimes/covid-19-data/blob/master/us-counties-recent.csv"&gt;us-counties-recent.csv&lt;/a&gt; from the NY Times &lt;a href="https://github.com/nytimes/covid-19-data"&gt;covid-19-data&lt;/a&gt; repository, which lists the most recent numbers for Covid cases for every US county.&lt;/p&gt;
&lt;p&gt;The second is &lt;a href="https://github.com/simonw/covid-19-datasette/blob/main/us_census_county_populations_2019.csv"&gt;us_census_county_populations_2019.csv&lt;/a&gt;, a CSV file listing the population of each county according to the 2019 US Census which I extracted from &lt;a href="https://www.census.gov/data/datasets/time-series/demo/popest/2010s-state-total.html"&gt;this page&lt;/a&gt; on the US Census website.&lt;/p&gt;
&lt;p&gt;Both of those tables include a column called &lt;code&gt;fips&lt;/code&gt;, representing the &lt;a href="https://en.wikipedia.org/wiki/FIPS_county_code"&gt;FIPS county code&lt;/a&gt; for each county. These 4-5 digit codes are ideal for joining the two tables.&lt;/p&gt;
&lt;p&gt;Here's a SQL query which joins the two tables, filters for the data for the most recent date represented (using &lt;code&gt;where date = (select max(date) from [us-counties-recent])&lt;/code&gt;) and calculates &lt;code&gt;cases_per_million&lt;/code&gt; using the cases and the population:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  [us&lt;span class="pl-k"&gt;-&lt;/span&gt;counties&lt;span class="pl-k"&gt;-&lt;/span&gt;recent].&lt;span class="pl-k"&gt;*&lt;/span&gt;,
  &lt;span class="pl-c1"&gt;us_census_county_populations_2019&lt;/span&gt;.&lt;span class="pl-c1"&gt;population&lt;/span&gt;,
  &lt;span class="pl-c1"&gt;1&lt;/span&gt;.&lt;span class="pl-c1"&gt;0&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; [us&lt;span class="pl-k"&gt;-&lt;/span&gt;counties&lt;span class="pl-k"&gt;-&lt;/span&gt;recent].cases &lt;span class="pl-k"&gt;/&lt;/span&gt; &lt;span class="pl-c1"&gt;us_census_county_populations_2019&lt;/span&gt;.&lt;span class="pl-c1"&gt;population&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-c1"&gt;1000000&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; cases_per_million
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  [us&lt;span class="pl-k"&gt;-&lt;/span&gt;counties&lt;span class="pl-k"&gt;-&lt;/span&gt;recent]
  &lt;span class="pl-k"&gt;join&lt;/span&gt; us_census_county_populations_2019 &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;us_census_county_populations_2019&lt;/span&gt;.&lt;span class="pl-c1"&gt;fips&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; [us&lt;span class="pl-k"&gt;-&lt;/span&gt;counties&lt;span class="pl-k"&gt;-&lt;/span&gt;recent].fips
&lt;span class="pl-k"&gt;where&lt;/span&gt;
  &lt;span class="pl-k"&gt;date&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; (&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-c1"&gt;max&lt;/span&gt;(&lt;span class="pl-k"&gt;date&lt;/span&gt;) &lt;span class="pl-k"&gt;from&lt;/span&gt; [us&lt;span class="pl-k"&gt;-&lt;/span&gt;counties&lt;span class="pl-k"&gt;-&lt;/span&gt;recent])
&lt;span class="pl-k"&gt;order by&lt;/span&gt;
  cases_per_million &lt;span class="pl-k"&gt;desc&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite-csv-join.png" alt="A screenshot of that query running in Datasette. Loving county Texas has the worst result - 1,289,940 cases per million - but that's because they have a population of just 169 people and 218 recorded cases." style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;And since everything in Datasette Lite can be bookmarked, here's the super long URL (&lt;a href="https://lite.datasette.io/?csv=https://raw.githubusercontent.com/nytimes/covid-19-data/master/us-counties-recent.csv&amp;amp;csv=https://raw.githubusercontent.com/simonw/covid-19-datasette/main/us_census_county_populations_2019.csv#/data?sql=select%0A++%5Bus-counties-recent%5D.*%2C%0A++us_census_county_populations_2019.population%2C%0A++1.0+*+%5Bus-counties-recent%5D.cases+%2F+us_census_county_populations_2019.population+*+1000000+as+cases_per_million%0Afrom%0A++%5Bus-counties-recent%5D%0A++join+us_census_county_populations_2019+on+us_census_county_populations_2019.fips+%3D+%5Bus-counties-recent%5D.fips%0Awhere%0A++date+%3D+%28select+max%28date%29+from+%5Bus-counties-recent%5D%29%0Aorder+by%0A++cases_per_million+desc"&gt;clickable version here&lt;/a&gt;) that executes that query against those two CSV files:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;https://lite.datasette.io/?csv=https://raw.githubusercontent.com/nytimes/covid-19-data/master/us-counties-recent.csv&amp;amp;csv=https://raw.githubusercontent.com/simonw/covid-19-datasette/main/us_census_county_populations_2019.csv#/data?sql=select%0A++%5Bus-counties-recent%5D.*%2C%0A++us_census_county_populations_2019.population%2C%0A++1.0+*+%5Bus-counties-recent%5D.cases+%2F+us_census_county_populations_2019.population+*+1000000+as+cases_per_million%0Afrom%0A++%5Bus-counties-recent%5D%0A++join+us_census_county_populations_2019+on+us_census_county_populations_2019.fips+%3D+%5Bus-counties-recent%5D.fips%0Awhere%0A++date+%3D+%28select+max%28date%29+from+%5Bus-counties-recent%5D%29%0Aorder+by%0A++cases_per_million+desc&lt;/code&gt;&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/csv"&gt;csv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="csv"/><category term="projects"/><category term="sql"/><category term="datasette"/><category term="webassembly"/><category term="datasette-lite"/><category term="cors"/></entry><entry><title>Datasette Lite: a server-side Python web application running in a browser</title><link href="https://simonwillison.net/2022/May/4/datasette-lite/#atom-series" rel="alternate"/><published>2022-05-04T15:16:49+00:00</published><updated>2022-05-04T15:16:49+00:00</updated><id>https://simonwillison.net/2022/May/4/datasette-lite/#atom-series</id><summary type="html">
    &lt;p&gt;&lt;a href="https://github.com/simonw/datasette-lite"&gt;Datasette Lite&lt;/a&gt; is a new way to run &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;: entirely in a browser, taking advantage of the incredible &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; project which provides Python compiled to WebAssembly plus a whole suite of useful extras.&lt;/p&gt;
&lt;p&gt;You can try it out here:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/"&gt;https://lite.datasette.io/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-lite.jpg" alt="A screenshot of the pypi_packages database table running in Google Chrome in a page with the URL of lite.datasette.io/#/content/pypi_packages?_facet=author" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update 20th June 2022&lt;/strong&gt;: Datasette Lite can now load CSV files directly by URL, see &lt;a href="https://simonwillison.net/2022/Jun/20/datasette-lite-csvs/"&gt;Joining CSV files in your browser using Datasette Lite&lt;/a&gt; for details.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update 17th August 2022&lt;/strong&gt;: It can now &lt;a href="https://simonwillison.net/2022/Aug/17/datasette-lite-plugins/"&gt;load Datasette plugins as well&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The initial example loads two databases - the classic &lt;a href="https://latest.datasette.io/fixtures"&gt;fixtures.db&lt;/a&gt; used by the Datasette test suite, and the &lt;a href="https://datasette.io/content"&gt;content.db&lt;/a&gt; database that powers the official &lt;a href="https://datasette.io/"&gt;datasette.io&lt;/a&gt; website (described in some detail in &lt;a href="https://simonwillison.net/2021/Jul/28/baked-data/"&gt;my post about Baked Data&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;You can instead use the "Load database by URL to a SQLite DB" button to paste in a URL to your own database. That file will need to be served with CORS headers that allow it to be fetched by the website (&lt;a href="https://github.com/simonw/datasette-lite/#opening-other-databases"&gt;see README&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Try this URL, for example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://congress-legislators.datasettes.com/legislators.db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can &lt;a href="https://lite.datasette.io/?url=https%3A%2F%2Fcongress-legislators.datasettes.com%2Flegislators.db"&gt;follow this link&lt;/a&gt; to open that database in Datasette Lite.&lt;/p&gt;
&lt;p&gt;Datasette Lite supports almost all of Datasette's regular functionality: you can view tables, apply facets, run your own custom SQL results and export the results as CSV or JSON.&lt;/p&gt;
&lt;p&gt;It's basically the full Datasette experience, except it's running entirely in your browser with no server (other than the static file hosting provided here by GitHub Pages) required.&lt;/p&gt;
&lt;p&gt;I’m pretty stunned that this is possible now.&lt;/p&gt;
&lt;p&gt;I had to make some small changes to Datasette to get this to work, detailed below, but really nothing extravagant - the demo is running the exact same Python code as the regular server-side Datasette application, just inside a web worker process in a browser rather than on a server.&lt;/p&gt;
&lt;p&gt;The implementation is pretty small - around 300 lines of JavaScript. You can see the code in the &lt;a href="https://github.com/simonw/datasette-lite"&gt;simonw/datasette-lite&lt;/a&gt; repository - in two files, &lt;a href="https://github.com/simonw/datasette-lite/blob/6ae4cacf140f0c7c6eafa8cf0f92a2dae44425ff/index.html"&gt;index.html&lt;/a&gt; and &lt;a href="https://github.com/simonw/datasette-lite/blob/main/webworker.js"&gt;webworker.js&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;Why build this?&lt;/h4&gt;
&lt;p&gt;I built this because I want as many people as possible to be able to use my software.&lt;/p&gt;
&lt;p&gt;I've invested a ton of effort in reducing the friction to getting started with Datasette. I've &lt;a href="https://docs.datasette.io/en/stable/installation.html"&gt;documented the install process&lt;/a&gt;, I've &lt;a href="https://formulae.brew.sh/formula/datasette"&gt;packaged it for Homebrew&lt;/a&gt;, I've written guides to &lt;a href="https://docs.datasette.io/en/stable/getting_started.html#try-datasette-without-installing-anything-using-glitch"&gt;running it on Glitch&lt;/a&gt;, I've built tools to help deploy it to &lt;a href="https://docs.datasette.io/en/stable/publish.html#publishing-to-heroku"&gt;Heroku&lt;/a&gt;, &lt;a href="https://docs.datasette.io/en/stable/publish.html#publishing-to-google-cloud-run"&gt;Cloud Run&lt;/a&gt;, &lt;a href="https://docs.datasette.io/en/stable/publish.html#publishing-to-vercel"&gt;Vercel&lt;/a&gt; and &lt;a href="https://simonwillison.net/2022/Feb/15/fly-volumes/"&gt;Fly.io&lt;/a&gt;. I even taught myself Electron and built a macOS &lt;a href="https://datasette.io/desktop"&gt;Datasette Desktop&lt;/a&gt; application, so people could install it without having to think about their Python environment.&lt;/p&gt;
&lt;p&gt;Datasette Lite is my latest attempt at this. Anyone with a browser that can run WebAssembly can now run Datasette in it - if they can afford the 10MB load (which in many places with metered internet access is way too much).&lt;/p&gt;
&lt;p&gt;I also built this because I'm fascinated by WebAssembly and I've been looking for an opportunity to really try it out.&lt;/p&gt;
&lt;p&gt;And, I find this project deeply amusing. Running a Python server-side web application in a browser still feels like an absurd thing to do. I love that it works.&lt;/p&gt;
&lt;p&gt;I'm deeply inspired by &lt;a href="https://jupyterlite.readthedocs.io/en/latest/"&gt;JupyterLite&lt;/a&gt;. Datasette Lite's name is a tribute to that project.&lt;/p&gt;
&lt;h4&gt;How it works: Python in a Web Worker&lt;/h4&gt;
&lt;p&gt;Datasette Lite does most of its work in a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers"&gt;Web Worker&lt;/a&gt; - a separate process that can run expensive CPU operations (like an entire Python interpreter) without blocking the main browser's UI thread.&lt;/p&gt;
&lt;p&gt;The worker starts running when you load the page. It loads a WebAssembly compiled Python interpreter from a CDN, then installs Datasette and its dependencies into that interpreter using &lt;a href="https://pyodide.org/en/stable/usage/loading-packages.html#micropip"&gt;micropip&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It also downloads the specified SQLite database files using the browser's HTTP fetching mechanism and writes them to a virtual in-memory filesystem managed by Pyodide.&lt;/p&gt;
&lt;p&gt;Once everything is installed, it imports &lt;code&gt;datasette&lt;/code&gt; and creates a &lt;code&gt;Datasette()&lt;/code&gt; object called &lt;code&gt;ds&lt;/code&gt;. This object stays resident in the web worker.&lt;/p&gt;
&lt;p&gt;To render pages, the &lt;code&gt;index.html&lt;/code&gt; page sends a message to the web worker specifying which Datasette path has been requested - &lt;code&gt;/&lt;/code&gt; for the homepage, &lt;code&gt;/fixtures&lt;/code&gt; for the database index page, &lt;code&gt;/fixtures/facetable&lt;/code&gt; for a table page and so on.&lt;/p&gt;
&lt;p&gt;The web worker then simulates an HTTP GET against that path within Datasette using the following code:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;ds&lt;/span&gt;.&lt;span class="pl-s1"&gt;client&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;, &lt;span class="pl-s1"&gt;follow_redirects&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;This takes advantage of a really useful internal Datasette API: &lt;a href="https://docs.datasette.io/en/stable/internals.html#datasette-client"&gt;datasette.client&lt;/a&gt; is an &lt;a href="https://www.python-httpx.org/"&gt;HTTPX&lt;/a&gt; client object that can be used to execute HTTP requests against Datasette internally, without doing a round-trip across the network.&lt;/p&gt;
&lt;p&gt;I initially added &lt;code&gt;datasette.client&lt;/code&gt; with the goal of making any JSON APIs that Datasette provides available for internal calls by plugins as well, and to make it easier to write automated tests. It turns out to have other interesting applications too!&lt;/p&gt;
&lt;p&gt;The web worker sends a message back to &lt;code&gt;index.html&lt;/code&gt; with the status code, content type and content retrieved from Datasette. JavaScript in &lt;code&gt;index.html&lt;/code&gt; then injects that HTML into the page using &lt;code&gt;.innerHTML&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To get internal links working, Datasette Lite uses a trick I originally learned from jQuery: it applies a capturing event listener to the area of the page displaying the content, such that any link clicks or form submissions will be intercepted by a JavaScript function. That JavaScript can then turn them into new messages to the web worker rather than navigating to another page.&lt;/p&gt;
&lt;h4&gt;Some annotated code&lt;/h4&gt;
&lt;p&gt;Here are annotated versions of the most important pieces of code. In &lt;code&gt;index.html&lt;/code&gt; this code manages the worker and updates the page when it recieves messages from it:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Load the worker script&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Worker&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"webworker.js"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Extract the ?url= from the current page's URL&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;URLSearchParams&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;location&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;search&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;get&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'url'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Message that to the worker: {type: 'startup', initialUrl: url}&lt;/span&gt;
&lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;'startup'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; initialUrl&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// This function does most of the work - it responds to messages sent&lt;/span&gt;
&lt;span class="pl-c"&gt;// back from the worker to the index page:&lt;/span&gt;
&lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;onmessage&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// {type: log, line: ...} messages are appended to a log textarea:&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;ta&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'loading-logs'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;'log'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;loadingLogs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;line&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;loadingLogs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;join&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"\n"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;scrollTop&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;scrollHeight&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;''&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// If it's an {error: ...} message show it in a &amp;lt;pre&amp;gt; in a &amp;lt;div&amp;gt;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&amp;lt;div style="padding: 0.5em"&amp;gt;&amp;lt;h3&amp;gt;Error&amp;lt;/h3&amp;gt;&amp;lt;pre&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;escapeHtml&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/pre&amp;gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// If contentType is text/html, show it as straight HTML&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;else&lt;/span&gt; &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;^&lt;/span&gt;text&lt;span class="pl-cce"&gt;\/&lt;/span&gt;html&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;exec&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;contentType&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// For contentType of application/json parse and pretty-print it&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;else&lt;/span&gt; &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;^&lt;/span&gt;application&lt;span class="pl-cce"&gt;\/&lt;/span&gt;json&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;exec&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;contentType&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&amp;lt;pre style="padding: 0.5em"&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;escapeHtml&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;4&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/pre&amp;gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Anything else (likely CSV data) escape it and show in a &amp;lt;pre&amp;gt;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;else&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`&amp;lt;pre style="padding: 0.5em"&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;escapeHtml&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/pre&amp;gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Add the result to &amp;lt;div id="output"&amp;gt; using innerHTML&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"output"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerHTML&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;html&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Update the document.title if a &amp;lt;title&amp;gt; element is present&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;title&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"output"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;querySelector&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"title"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;title&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;title&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;title&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerText&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Scroll to the top of the page after each new page is loaded&lt;/span&gt;
  &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;scrollTo&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;top&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;left&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// If we're showing the initial loading indicator, hide it&lt;/span&gt;
  &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'loading-indicator'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;style&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;display&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'none'&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;webworker.js&lt;/code&gt; script is where the real magic happens:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Load Pyodide from the CDN&lt;/span&gt;
&lt;span class="pl-en"&gt;importScripts&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Deliver log messages back to the index.html page&lt;/span&gt;
&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;line&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;'log'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;line&lt;/span&gt;: &lt;span class="pl-s1"&gt;line&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;

&lt;span class="pl-c"&gt;// This function initializes Pyodide and installs Datasette&lt;/span&gt;
&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;startDatasette&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Mechanism for downloading and saving specified DB files&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;toLoad&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;name&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;split&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'.db'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;split&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'/'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;slice&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;name&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;initialUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;else&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// If no ?url= provided, loads these two demo databases instead:&lt;/span&gt;
    &lt;span class="pl-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"fixtures.db"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"https://latest.datasette.io/fixtures.db"&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"content.db"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"https://datasette.io/content.db"&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-c"&gt;// This does a LOT of work - it pulls down the WASM blob and starts it running&lt;/span&gt;
  &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;pyodide&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;loadPyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;indexURL&lt;/span&gt;: &lt;span class="pl-s"&gt;"https://cdn.jsdelivr.net/pyodide/dev/full/"&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// We need these packages for the next bit of code to work&lt;/span&gt;
  &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;loadPackage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'micropip'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;loadPackage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'ssl'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;loadPackage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'setuptools'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-c"&gt;// For pkg_resources&lt;/span&gt;
  &lt;span class="pl-k"&gt;try&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Now we switch to Python code&lt;/span&gt;
    &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;runPythonAsync&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;`&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Here's where we download and save those .db files - they are saved&lt;/span&gt;
&lt;span class="pl-s"&gt;    # to a virtual in-memory filesystem provided by Pyodide&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    # pyfetch is a wrapper around the JS fetch() function - calls using&lt;/span&gt;
&lt;span class="pl-s"&gt;    # it are handled by the browser's regular HTTP fetching mechanism&lt;/span&gt;
&lt;span class="pl-s"&gt;    from pyodide.http import pyfetch&lt;/span&gt;
&lt;span class="pl-s"&gt;    names = []&lt;/span&gt;
&lt;span class="pl-s"&gt;    for name, url in &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;toLoad&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;:&lt;/span&gt;
&lt;span class="pl-s"&gt;        response = await pyfetch(url)&lt;/span&gt;
&lt;span class="pl-s"&gt;        with open(name, "wb") as fp:&lt;/span&gt;
&lt;span class="pl-s"&gt;            fp.write(await response.bytes())&lt;/span&gt;
&lt;span class="pl-s"&gt;        names.append(name)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    import micropip&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Workaround for Requested 'h11&amp;lt;0.13,&amp;gt;=0.11', but h11==0.13.0 is already installed&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install("h11==0.12.0")&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Install Datasette itself!&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install("datasette==0.62a0")&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Now we can create a Datasette() object that can respond to fake requests&lt;/span&gt;
&lt;span class="pl-s"&gt;    from datasette.app import Datasette&lt;/span&gt;
&lt;span class="pl-s"&gt;    ds = Datasette(names, settings={&lt;/span&gt;
&lt;span class="pl-s"&gt;        "num_sql_threads": 0,&lt;/span&gt;
&lt;span class="pl-s"&gt;    }, metadata = {&lt;/span&gt;
&lt;span class="pl-s"&gt;        # This metadata is displayed in Datasette's footer&lt;/span&gt;
&lt;span class="pl-s"&gt;        "about": "Datasette Lite",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "about_url": "https://github.com/simonw/datasette-lite"&lt;/span&gt;
&lt;span class="pl-s"&gt;    })&lt;/span&gt;
&lt;span class="pl-s"&gt;    `&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-en"&gt;datasetteLiteReady&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;catch&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;error&lt;/span&gt;: &lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;

&lt;span class="pl-c"&gt;// Outside promise pattern&lt;/span&gt;
&lt;span class="pl-c"&gt;// https://github.com/simonw/datasette-lite/issues/25#issuecomment-1116948381&lt;/span&gt;
&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;datasetteLiteReady&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;readyPromise&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Promise&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;resolve&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-s1"&gt;datasetteLiteReady&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;resolve&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// This function handles messages sent from index.html to webworker.js&lt;/span&gt;
&lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;onmessage&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// The first message should be that startup message, carrying the URL&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;'startup'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;startDatasette&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;initialUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
  &lt;span class="pl-c"&gt;// This promise trick ensures that we don't run the next block until we&lt;/span&gt;
  &lt;span class="pl-c"&gt;// are certain that startDatasette() has finished and the ds.client&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Python object is ready to use&lt;/span&gt;
  &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;readyPromise&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Run the reuest in Python to get a status code, content type and text&lt;/span&gt;
  &lt;span class="pl-k"&gt;try&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;status&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;contentType&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;runPythonAsync&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
      &lt;span class="pl-s"&gt;`&lt;/span&gt;
&lt;span class="pl-s"&gt;      import json&lt;/span&gt;
&lt;span class="pl-s"&gt;      # ds.client.get(path) simulates running a request through Datasette&lt;/span&gt;
&lt;span class="pl-s"&gt;      response = await ds.client.get(&lt;/span&gt;
&lt;span class="pl-s"&gt;          # Using json here is a quick way to generate a quoted string&lt;/span&gt;
&lt;span class="pl-s"&gt;          &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;data&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;,&lt;/span&gt;
&lt;span class="pl-s"&gt;          # If Datasette redirects to another page we want to follow that&lt;/span&gt;
&lt;span class="pl-s"&gt;          follow_redirects=True&lt;/span&gt;
&lt;span class="pl-s"&gt;      )&lt;/span&gt;
&lt;span class="pl-s"&gt;      [response.status_code, response.headers.get("content-type"), response.text]&lt;/span&gt;
&lt;span class="pl-s"&gt;      `&lt;/span&gt;
    &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Message the results back to index.html&lt;/span&gt;
    &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;status&lt;span class="pl-kos"&gt;,&lt;/span&gt; contentType&lt;span class="pl-kos"&gt;,&lt;/span&gt; text&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;catch&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// If an error occurred, send that back as a {error: ...} message&lt;/span&gt;
    &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;error&lt;/span&gt;: &lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;One last bit of code: here's the JavaScript in &lt;code&gt;index.html&lt;/code&gt; which intercepts clicks on links and turns them into messages to the worker:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;output&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'output'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-c"&gt;// This captures any click on any element within &amp;lt;div id="output"&amp;gt;&lt;/span&gt;
&lt;span class="pl-s1"&gt;output&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addEventListener&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'click'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;ev&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// .closest("a") traverses up the DOM to find if this is an a&lt;/span&gt;
  &lt;span class="pl-c"&gt;// or an element nested in an a. We ignore other clicks.&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;link&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ev&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;srcElement&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;closest&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"a"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;link&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// It was a click on a &amp;lt;a href="..."&amp;gt; link! Cancel the event:&lt;/span&gt;
    &lt;span class="pl-s1"&gt;ev&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stopPropagation&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;ev&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;preventDefault&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-c"&gt;// I want #fragment links to still work, using scrollIntoView()&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-en"&gt;isFragmentLink&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c"&gt;// Jump them to that element, but don't update the URL bar&lt;/span&gt;
      &lt;span class="pl-c"&gt;// since we use # in the URL to mean something else&lt;/span&gt;
      &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;fragment&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-c1"&gt;URL&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;hash&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"#"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;""&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fragment&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
        &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;el&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fragment&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
        &lt;span class="pl-s1"&gt;el&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;scrollIntoView&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-kos"&gt;}&lt;/span&gt;
      &lt;span class="pl-k"&gt;return&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;href&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;link&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getAttribute&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"href"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Links to external sites should open in a new window&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-en"&gt;isExternal&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;open&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-k"&gt;return&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-c"&gt;// It's an internal link navigation - send it to the worker&lt;/span&gt;
    &lt;span class="pl-en"&gt;loadPath&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;loadPath&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c"&gt;// We don't want anything after #, and we only want the /path&lt;/span&gt;
  &lt;span class="pl-s1"&gt;path&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;split&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"#"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"http://localhost"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;""&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Update the URL with the new # location&lt;/span&gt;
  &lt;span class="pl-s1"&gt;history&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;pushState&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;path&lt;/span&gt;: &lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"#"&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Plausible analytics, see:&lt;/span&gt;
  &lt;span class="pl-c"&gt;// https://github.com/simonw/datasette-lite/issues/22&lt;/span&gt;
  &lt;span class="pl-s1"&gt;useAnalytics&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-en"&gt;plausible&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'pageview'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;u&lt;/span&gt;: &lt;span class="pl-s1"&gt;location&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;href&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'?url='&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;''&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'#'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'/'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Send a {path: "/path"} message to the worker&lt;/span&gt;
  &lt;span class="pl-s1"&gt;datasetteWorker&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;postMessage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;path&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h4&gt;Getting Datasette to work in Pyodide&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; is the secret sauce that makes this all possible. That project provides several key components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A custom WebAssembly build of the core Python interpreter, bundling the standard library (including a compiled WASM version of SQLite)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pyodide.org/en/stable/usage/loading-packages.html#micropip"&gt;micropip&lt;/a&gt; - a package that can install additional Python dependencies by downloading them from &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A comprehensive JavaScript to Python bridge, including mechanisms for &lt;a href="https://pyodide.org/en/stable/usage/type-conversions.html"&gt;translating Python objects&lt;/a&gt; to JavaScript and vice-versa&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://pyodide.org/en/stable/usage/api/js-api.html"&gt;JavaScript API&lt;/a&gt; for launching and then managing a Python interpreter process&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I found the documentation on &lt;a href="https://pyodide.org/en/stable/usage/webworker.html"&gt;Using Pyodide in a web worker&lt;/a&gt; particularly helpful.&lt;/p&gt;
&lt;p&gt;I had to make a few changes to Datasette to get it working with Pyodide. My &lt;a href="https://github.com/simonw/datasette/issues/1733"&gt;tracking issue for that&lt;/a&gt; has the full details, but the short version is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ensure each of Datasette's dependencies had a wheel package on PyPI (as opposed to just a &lt;code&gt;.tar.gz&lt;/code&gt;) - &lt;code&gt;micropip&lt;/code&gt; only works with wheels. I ended up removing &lt;code&gt;python-baseconv&lt;/code&gt; as a dependency and replacing &lt;code&gt;click-default-group&lt;/code&gt; with my own &lt;code&gt;click-default-group-wheel&lt;/code&gt; forked package (&lt;a href="https://github.com/simonw/click-default-group-wheel"&gt;repo here&lt;/a&gt;). I got &lt;code&gt;sqlite-utils&lt;/code&gt; working in Pyodide with this change too, see the &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-26-1"&gt;3.26.1 release notes&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Work around an error caused by importing &lt;code&gt;uvicorn&lt;/code&gt;. Since Datasette Lite doesn't actually run its own web server that dependency wasn't necessary, so I changed my code to catch the &lt;code&gt;ImportError&lt;/code&gt; in the right place.&lt;/li&gt;
&lt;li&gt;The biggest change: WebAssembly can't run threads, which means Python can't run threads, which means any attempts to start a thread in Python cause an error. Datasette only uses threads in one place: to execute SQL queries in a thread pool where they won't block the event loop. I added a new &lt;code&gt;--setting num_sql_threads 0&lt;/code&gt; feature for disabling threading entirely, see &lt;a href="https://github.com/simonw/datasette/issues/1735"&gt;issue 1735&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Having made those changes I shipped them in a &lt;a href="https://github.com/simonw/datasette/releases/tag/0.62a0"&gt;Datasette 0.62a0&lt;/a&gt; release. It's this release that Datasette Lite installs from PyPI.&lt;/p&gt;
&lt;h4&gt;Fragment hashes for navigation&lt;/h4&gt;
&lt;p&gt;You may have noticed that as you navigate through Datasette Lite the URL bar updates with URLs that look like the following:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/#/content/pypi_packages?_facet=author"&gt;https://lite.datasette.io/#/content/pypi_packages?_facet=author&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I'm using the &lt;code&gt;#&lt;/code&gt; here to separate out the path within the virtual Datasette instance from the URL to the Datasette Lite application itself.&lt;/p&gt;
&lt;p&gt;Maintaining the state in the URL like this means that the Back and Forward browser buttons work, and also means that users can bookmark pages within the application and share links to them.&lt;/p&gt;
&lt;p&gt;I usually like to avoid &lt;code&gt;#&lt;/code&gt; URLs - the HTML history API makes it possible to use "real" URLs these days, even for JavaScript applications. But in the case of Datasette Lite those URLs wouldn't actually work - if someone attempted to refresh the page or navigate to a link GitHub Pages wouldn't know what file to serve.&lt;/p&gt;
&lt;p&gt;I could run this on my own domain with a catch-all page handler that serves the Datasette Lite HTML and JavaScript no matter what path is requested, but I wanted to keep this as pure and simple as possible.&lt;/p&gt;
&lt;p&gt;This also means I can reserve Datasette Lite's own query string for things like specifying the database to load, and potentially other options in the future.&lt;/p&gt;
&lt;h4&gt;Web Workers or Service Workers?&lt;/h4&gt;
&lt;p&gt;My initial idea for this project was to build it with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers"&gt;Service Workers&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Service Workers are some deep, deep browser magic: they let you install a process that can intercept browser traffic to a specific domain (or path within that domain) and run custom code to return a result. Effectively they let you run your own server-side code in the browser itself.&lt;/p&gt;
&lt;p&gt;They're mainly designed for building offline applications, but my hope was that I could use them to offer a full simulation of a server-side application instead.&lt;/p&gt;
&lt;p&gt;Here's my TIL on &lt;a href="https://til.simonwillison.net/service-workers/intercept-fetch"&gt;Intercepting fetch in a service worker&lt;/a&gt; that came out of my initial research.&lt;/p&gt;
&lt;p&gt;I managed to get a server-side JavaScript "hello world" demo working, but when I tried to add Pyodide I ran into some unavoidable road blocks. It turns out Service Workers are very restricted in which APIs they provide - in particular, they don't allow &lt;code&gt;XMLHttpRequest&lt;/code&gt; calls. Pyodide apparently depends on &lt;code&gt;XMLHttpRequest&lt;/code&gt;, so it was unable to run in a Service Worker at all. I &lt;a href="https://github.com/pyodide/pyodide/issues/2432"&gt;filed an issue&lt;/a&gt; about it with the Pyodide project.&lt;/p&gt;
&lt;p&gt;Initially I thought this would block the whole project, but eventually I figured out a way to achieve the same goals using Web Workers instead.&lt;/p&gt;
&lt;h3&gt;Is this an SPA or an MPA?&lt;/h3&gt;
&lt;p&gt;SPAs are Single Page Applications. MPAs are Multi Page Applications. Datasette Lite is a weird hybrid of the two.&lt;/p&gt;
&lt;p&gt;This amuses me greatly.&lt;/p&gt;
&lt;p&gt;Datasette itself is very deliberately architected as a multi page application.&lt;/p&gt;
&lt;p&gt;I think SPAs, as developed over the last decade, have mostly been a mistake. In my experience they take longer to build, have more bugs and provide worse performance than a server-side, multi-page alternative implementation.&lt;/p&gt;
&lt;p&gt;Obviously if you are building Figma or VS Code then SPAs are the right way to go. But most web applications are not Figma, and don't need to be!&lt;/p&gt;
&lt;p&gt;(I used to think Gmail was a shining example of an SPA, but it's so sludgy and slow loading these days that I now see it as more of an argument against the paradigm.)&lt;/p&gt;
&lt;p&gt;Datasette Lite is an SPA wrapper around an MPA. It literally simulates the existing MPA by running it in a web worker.&lt;/p&gt;
&lt;p&gt;It's very heavy - it loads 11MB of assets before it can show you anything. But it also inherits many of the benefits of the underlying MPA: it has obvious distinctions between pages, a deeply interlinked interface, working back and forward buttons, it's bookmarkable and it's easy to maintain and add new features.&lt;/p&gt;
&lt;p&gt;I'm not sure what my conclusion here is. I'm skeptical of SPAs, and now I've built a particularly weird one. Is this even a good idea? I'm looking forward to finding that out for myself.&lt;/p&gt;
&lt;h4&gt;Coming soon: JavaScript!&lt;/h4&gt;
&lt;p&gt;Another amusing detail about Datasette Lite is that the one part of Datasette that doesn't work yet is Datasette's existing JavaScript features!&lt;/p&gt;
&lt;p&gt;Datasette currently makes very sparing use of JavaScript in the UI: it's used to add some drop-down interactive menus (including the handy "cog" menu on column headings) and for a CodeMirror-enhanced SQL editing interface.&lt;/p&gt;
&lt;p&gt;JavaScript is used much more extensively by several popular Datasette plugins, including &lt;a href="https://datasette.io/plugins/datasette-cluster-map"&gt;datasette-cluster-map&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-vega"&gt;datasette-vega&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Unfortunately none of this works in Datasette Lite at the moment - because I don't yet have a good way to turn &lt;code&gt;&amp;lt;script src="..."&amp;gt;&lt;/code&gt; links into things that can load content from the Web Worker.&lt;/p&gt;
&lt;p&gt;This is one of the reasons I was initially hopeful about Service Workers.&lt;/p&gt;
&lt;p&gt;Thankfully, since Datasette is built on the principles of progressive enhancement this doesn't matter: the application remains usable even if none of the JavaScript enhancements are applied.&lt;/p&gt;
&lt;p&gt;I have an &lt;a href="https://github.com/simonw/datasette-lite/issues/8"&gt;open issue for this&lt;/a&gt;. I welcome suggestions as to how I can get all of Datasette's existing JavaScript working in the new environment with as little effort as possible.&lt;/p&gt;
&lt;h4 id="bonus-shot-scraper"&gt;Bonus: Testing it with shot-scraper&lt;/h4&gt;
&lt;p&gt;In building Datasette Lite, I've committed to making Pyodide a supported runtime environment for Datasette. How can I ensure that future changes I make to Datasette - accidentally introducing a new dependency that doesn't work there for example - don't break in Pyodide without me noticing?&lt;/p&gt;
&lt;p&gt;This felt like a great opportunity to exercise my &lt;a href="https://datasette.io/tools/shot-scraper"&gt;shot-scraper&lt;/a&gt; CLI tool, in particular its ability to run some JavaScript against a page and &lt;a href="https://github.com/simonw/shot-scraper/blob/0.13/README.md#handling-javascript-errors"&gt;pass or fail a CI job&lt;/a&gt; depending on if that JavaScript throws an error.&lt;/p&gt;
&lt;p&gt;Pyodide needs you to run it from a real web server, not just an HTML file saved to disk - so I put together a &lt;a href="https://github.com/simonw/datasette/blob/280ff372ab30df244f6c54f6f3002da57334b3d7/test-in-pyodide-with-shot-scraper.sh"&gt;very scrappy shell script&lt;/a&gt; which builds a Datasette wheel package, starts a localhost file server (using &lt;code&gt;python3 -m http.server&lt;/code&gt;), then uses &lt;code&gt;shot-scraper javascript&lt;/code&gt; to execute a test against it that installs Datasette from the wheel using &lt;code&gt;micropip&lt;/code&gt; and confirms that it can execute a simple SQL query via the JSON API.&lt;/p&gt;
&lt;p&gt;Here's the script in full, with extra comments:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#!&lt;/span&gt;/bin/bash&lt;/span&gt;
&lt;span class="pl-c1"&gt;set&lt;/span&gt; -e
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; I always forget to do this in my bash scripts - without it, any&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; commands that fail in the script won't result in the script itself&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; returning a non-zero exit code. I need it for running tests in CI.&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Build the wheel - this generates a file with a name similar to&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; dist/datasette-0.62a0-py3-none-any.whl&lt;/span&gt;
python3 -m build

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Find the name of that wheel file, strip off the dist/&lt;/span&gt;
wheel=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;basename &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;ls dist/&lt;span class="pl-k"&gt;*&lt;/span&gt;.whl&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; $wheel is now datasette-0.62a0-py3-none-any.whl&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Create a blank index page that loads Pyodide&lt;/span&gt;
&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&amp;lt;script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; dist/index.html

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Run a localhost web server for that dist/ folder, in the background&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; so we can do more stuff in this script&lt;/span&gt;
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; dist
python3 -m http.server 8529 &lt;span class="pl-k"&gt;&amp;amp;&lt;/span&gt;
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; ..

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Now we use shot-scraper to run a block of JavaScript against our&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; temporary web server. This will execute in the context of that&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; index.html page we created earlier, which has loaded Pyodide&lt;/span&gt;
shot-scraper javascript http://localhost:8529/ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;async () =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;  // Load Pyodide and all of its necessary assets&lt;/span&gt;
&lt;span class="pl-s"&gt;  let pyodide = await loadPyodide();&lt;/span&gt;
&lt;span class="pl-s"&gt;  // We also need these packages for Datasette to work&lt;/span&gt;
&lt;span class="pl-s"&gt;  await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']);&lt;/span&gt;
&lt;span class="pl-s"&gt;  // We need to escape the backticks because of Bash escaping rules&lt;/span&gt;
&lt;span class="pl-s"&gt;  let output = await pyodide.runPythonAsync(&lt;span class="pl-cce"&gt;\`&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    import micropip&lt;/span&gt;
&lt;span class="pl-s"&gt;    // This is needed to avoid a dependency conflict error&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install('h11==0.12.0')&lt;/span&gt;
&lt;span class="pl-s"&gt;    // Here we install the Datasette wheel package we created earlier&lt;/span&gt;
&lt;span class="pl-s"&gt;    await micropip.install('http://localhost:8529/&lt;span class="pl-smi"&gt;$wheel&lt;/span&gt;')&lt;/span&gt;
&lt;span class="pl-s"&gt;    // These imports avoid Pyodide errors importing datasette itself&lt;/span&gt;
&lt;span class="pl-s"&gt;    import ssl&lt;/span&gt;
&lt;span class="pl-s"&gt;    import setuptools&lt;/span&gt;
&lt;span class="pl-s"&gt;    from datasette.app import Datasette&lt;/span&gt;
&lt;span class="pl-s"&gt;    // num_sql_threads=0 is essential or Datasette will crash, since&lt;/span&gt;
&lt;span class="pl-s"&gt;    // Pyodide and WebAssembly cannot start threads&lt;/span&gt;
&lt;span class="pl-s"&gt;    ds = Datasette(memory=True, settings={'num_sql_threads': 0})&lt;/span&gt;
&lt;span class="pl-s"&gt;    // Simulate a hit to execute 'select 55 as itworks' and return the text&lt;/span&gt;
&lt;span class="pl-s"&gt;    (await ds.client.get(&lt;/span&gt;
&lt;span class="pl-s"&gt;      '/_memory.json?sql=select+55+as+itworks&amp;amp;_shape=array'&lt;/span&gt;
&lt;span class="pl-s"&gt;    )).text&lt;/span&gt;
&lt;span class="pl-s"&gt;  &lt;span class="pl-cce"&gt;\`&lt;/span&gt;);&lt;/span&gt;
&lt;span class="pl-s"&gt;  // The last expression in the runPythonAsync block is returned, here&lt;/span&gt;
&lt;span class="pl-s"&gt;  // that's the text returned by the simulated HTTP response to the JSON API&lt;/span&gt;
&lt;span class="pl-s"&gt;  if (JSON.parse(output)[0].itworks != 55) {&lt;/span&gt;
&lt;span class="pl-s"&gt;    // This throws if the JSON API did not return the expected result&lt;/span&gt;
&lt;span class="pl-s"&gt;    // shot-scraper turns that into a non-zero exit code for the script&lt;/span&gt;
&lt;span class="pl-s"&gt;    // which will cause the CI task to fail&lt;/span&gt;
&lt;span class="pl-s"&gt;    throw 'Got ' + output + ', expected itworks: 55';&lt;/span&gt;
&lt;span class="pl-s"&gt;  }&lt;/span&gt;
&lt;span class="pl-s"&gt;  // This gets displayed on the console, with a 0 exit code for a pass&lt;/span&gt;
&lt;span class="pl-s"&gt;  return 'Test passed!';&lt;/span&gt;
&lt;span class="pl-s"&gt;}&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Shut down the server we started earlier, by searching for and killing&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; a process that's running on the port we selected&lt;/span&gt;
pkill -f &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;http.server 8529&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webworkers"&gt;webworkers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="projects"/><category term="python"/><category term="webworkers"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="datasette-lite"/><category term="cors"/></entry></feed>