diff --git a/.vale/styles/Shorebird/Headings.yml b/.vale/styles/Shorebird/Headings.yml
index c875e622..86e6057e 100644
--- a/.vale/styles/Shorebird/Headings.yml
+++ b/.vale/styles/Shorebird/Headings.yml
@@ -9,6 +9,7 @@ match: $sentence
exceptions:
- Shorebird
- Shorebird's
+ - Zap
- Flutter
- Flutter's
- Dart
diff --git a/astro.config.mjs b/astro.config.mjs
index f40ff033..700ad5e2 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,6 +2,7 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
+import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import starlightLinksValidator from 'starlight-links-validator';
import starlightAutoSidebar from 'starlight-auto-sidebar';
@@ -9,15 +10,67 @@ import starlightImageZoom from 'starlight-image-zoom';
import starlightThemeNova from 'starlight-theme-nova';
import opengraphImages from 'astro-opengraph-images';
import { renderer } from './src/og/renderer.tsx';
-import { readFileSync } from 'node:fs';
+import { globSync, readFileSync } from 'node:fs';
+import { readFile, writeFile } from 'node:fs/promises';
+import yaml from 'js-yaml';
import starlightLlmsTxt from 'starlight-llms-txt';
import { remarkReplaceVersions } from './src/plugins/replace-versions.ts';
import mermaid from 'astro-mermaid';
import remarkGfm from 'remark-gfm';
import { unified } from '@astrojs/markdown-remark';
+import { unlistedPages } from './src/unlisted.ts';
const site = 'https://docs.shorebird.dev/';
+// `starlight-llms-txt` joins pages with this string. The default is a bare
+// blank line, which is indistinguishable from a paragraph break; an HTML
+// comment gives `stripUnlistedFromLlmsFull` a reliable page boundary, and
+// Markdown renderers ignore it.
+const llmsPageSeparator = '\n\n\n\n';
+
+// The plugin's `exclude` option only filters `llms-small.txt` (deliberately,
+// see its 0.2.1 changelog), so unlisted pages are stripped from
+// `llms-full.txt` after the build instead. They are found by position, not by
+// title, since titles are not unique (several pages are titled "Overview"):
+// `demote` sorts them to the end of the file, in `unlistedPages` order.
+const stripUnlistedFromLlmsFull = {
+ name: 'strip-unlisted-from-llms-full',
+ hooks: {
+ 'astro:build:done': async ({ dir, logger }) => {
+ if (unlistedPages.length === 0) return;
+ const file = new URL('llms-full.txt', dir);
+ const pages = (await readFile(file, 'utf8')).split(llmsPageSeparator);
+ const kept = pages.slice(0, -unlistedPages.length);
+ const stripped = pages.slice(-unlistedPages.length);
+ // Check each stripped page is the expected one before writing, so a
+ // change in the plugin's output fails the build instead of silently
+ // dropping a real page. The plugin starts each page with `#
`.
+ // This hook runs after the content collection APIs are torn down, so
+ // titles are read off disk.
+ unlistedPages.forEach((id, i) => {
+ const [path] = globSync(
+ `src/content/docs/{${id},${id}/index}.{md,mdx}`,
+ );
+ if (!path) throw new Error(`Unlisted page "${id}" not found.`);
+ const [, frontmatter] =
+ /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(path, 'utf8')) ?? [];
+ const data = yaml.load(frontmatter ?? '');
+ const title = data?.hero?.title || data?.title;
+ if (!title) throw new Error(`No title in ${path}.`);
+ if (!stripped[i]?.startsWith(`# ${title}\n`)) {
+ throw new Error(
+ `llms-full.txt: expected unlisted page "${id}" at position ${kept.length + i}, found "${stripped[i]?.split('\n', 1)[0]}".`,
+ );
+ }
+ });
+ await writeFile(file, kept.join(llmsPageSeparator));
+ logger.info(
+ `Stripped ${stripped.length} unlisted page(s) from llms-full.txt`,
+ );
+ },
+ },
+};
+
// https://astro.build/config
export default defineConfig({
site,
@@ -29,6 +82,14 @@ export default defineConfig({
},
integrations: [
mermaid({ autoTheme: true }),
+ // Starlight adds `@astrojs/sitemap` itself unless it is already in this
+ // array, so configuring it here is what lets unlisted pages be filtered out.
+ sitemap({
+ filter: (page) =>
+ !unlistedPages.some(
+ (id) => page === `${site}${id}/` || page === `${site}${id}`,
+ ),
+ }),
starlight({
expressiveCode: false,
title: 'Shorebird',
@@ -190,6 +251,11 @@ Developer & Agent Interfaces:
'Machine-readable agent capability card for agent-to-agent discovery',
},
],
+ pageSeparator: llmsPageSeparator,
+ // `exclude` only affects `llms-small.txt`, and `demote` lines
+ // unlisted pages up for `stripUnlistedFromLlmsFull`.
+ exclude: [...unlistedPages],
+ demote: [...unlistedPages],
}),
],
}),
@@ -206,6 +272,7 @@ Developer & Agent Interfaces:
},
render: renderer,
}),
+ stripUnlistedFromLlmsFull,
],
redirects: {
// Redirects to preserve legacy URLs & resolve agent probes.
diff --git a/package-lock.json b/package-lock.json
index bb7749f7..cdd0c6f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@astrojs/check": "^0.9.10",
"@astrojs/markdown-remark": "^7.3.1",
"@astrojs/mdx": "^8.0.1",
+ "@astrojs/sitemap": "^3.7.4",
"@astrojs/starlight": "^0.42.0",
"@lavamoat/preinstall-always-fail": "^3.0.0",
"@tailwindcss/vite": "^4.3.3",
@@ -602,13 +603,12 @@
}
},
"node_modules/@astrojs/sitemap": {
- "version": "3.7.3",
- "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz",
- "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==",
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz",
+ "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==",
"license": "MIT",
"dependencies": {
"sitemap": "^9.0.0",
- "stream-replace-string": "^2.0.0",
"zod": "^4.3.6"
}
},
@@ -12133,12 +12133,6 @@
"csstype": "3.2.3"
}
},
- "node_modules/stream-replace-string": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
- "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
- "license": "MIT"
- },
"node_modules/strictdom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz",
diff --git a/package.json b/package.json
index af5aef39..ecae50af 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"@astrojs/check": "^0.9.10",
"@astrojs/markdown-remark": "^7.3.1",
"@astrojs/mdx": "^8.0.1",
+ "@astrojs/sitemap": "^3.7.4",
"@astrojs/starlight": "^0.42.0",
"@lavamoat/preinstall-always-fail": "^3.0.0",
"@tailwindcss/vite": "^4.3.3",
diff --git a/src/assets/zap_add_custom_connector.png b/src/assets/zap_add_custom_connector.png
new file mode 100644
index 00000000..c283923d
Binary files /dev/null and b/src/assets/zap_add_custom_connector.png differ
diff --git a/src/assets/zap_custom_connector_form.png b/src/assets/zap_custom_connector_form.png
new file mode 100644
index 00000000..3dde1dbf
Binary files /dev/null and b/src/assets/zap_custom_connector_form.png differ
diff --git a/src/components/starlight/Head.astro b/src/components/starlight/Head.astro
index 478e136f..8e5aff2e 100644
--- a/src/components/starlight/Head.astro
+++ b/src/components/starlight/Head.astro
@@ -2,10 +2,15 @@
import Default from '@astrojs/starlight/components/Head.astro';
import { getImagePath } from 'astro-opengraph-images';
import { markdownSiblingPath } from '~/utils/markdown-path';
+import { unlistedPages } from '~/unlisted';
const ogImageUrl = getImagePath({ url: Astro.url, site: Astro.site });
-const markdownPath = markdownSiblingPath(Astro.url.pathname);
+// Unlisted pages have no `.md` sibling (see `src/pages/[...slug].md.ts`).
+const { entry } = Astro.locals.starlightRoute;
+const markdownPath = unlistedPages.includes(entry.id)
+ ? undefined
+ : markdownSiblingPath(Astro.url.pathname);
---
@@ -29,7 +34,11 @@ const markdownPath = markdownSiblingPath(Astro.url.pathname);
-
+{
+ markdownPath && (
+
+ )
+}
+ Shorebird Zap is an early prototype available to a limited set of customers.
+ Behavior and commands may change without notice, and there may be rough edges.
+ [Feedback is welcome](https://forms.gle/fjfjb1SMMnDz2VEN9).
+
+
+## What is Shorebird Zap?
+
+Shorebird Zap is a tool for running, using, and improving Flutter projects on
+your mobile device. It allows you to quickly preview changes made to your
+Flutter project by a coding agent, so you can feel the user experience
+firsthand, and rapidly iterate on improvements.
+
+The core cycle for using Shorebird Zap is:
+
+1. Build a Flutter project with a coding agent
+2. Open, use, and annotate your project with Shorebird Zap
+3. Instruct your agent to apply changes based on your annotations
+4. Repeat steps 2 and 3 until you are satisfied
+
+**Shorebird Zap is for previewing your personal Flutter projects only: it does
+not allow you to publish, share, collaborate on, or distribute your app.**
+
+### How it works
+
+Zap works by hosting a build of your Flutter project on Shorebird
+infrastructure, and serving it to the Shorebird Zap app, along with a set of
+tools for annotating and improving the user experience. The infrastructure
+around Zap is optimized to work with AI agents, and you bring your own, rather
+than you needing to go through some proprietary Shorebird agent.
+
+## Requirements
+
+- iOS 15.0 or greater, or Android 7.0 or greater
+- A Shorebird account or beta key
+- A repo with at least 1 commit (a committed empty README file is enough)
+- Your preferred coding agent
+
+
+
+## Installing
+
+
+
+
+
+ 1. Join the [Shorebird Companion Google Group](https://groups.google.com/a/shorebird.dev/g/shorebirdcompanion)
+
+ 2. Join the [testing group](https://play.google.com/apps/testing/dev.shorebird.companion) for the app
+
+
+ 3. [Download and install](https://play.google.com/store/apps/details?id=dev.shorebird.companion) the app
+
+
+
+
+
+
+ Visit [Shorebird's TestFlight link](https://testflight.apple.com/join/5zqR6tQx) and install the app.
+
+
+
+## Using Zap
+
+To use Zap, you will first create a Zap project via your AI coding agent. Then,
+you'll open it with the Shorebird Zap app, leave Notes, and iterate on the user
+experience.
+
+### Create a Zap project
+
+To create a Zap, you will need access to Claude Code. **It's recommended, but
+not required, to use Shorebird's MCP server.**
+
+
+
+
+
+
+
+ 1. **If you already have the Shorebird MCP installed,
+ [skip to step 3](#skip-mcp).** Otherwise, install the MCP.
+
+ The Shorebird MCP isn't available in official plugin/MCP marketplaces
+ yet, so you'll need to add a custom connector.
+
+ 
+
+ Set the name to anything you'd like, and the URL to
+ `https://mcp.shorebird.dev/mcp`
+
+ 
+
+ 2. Use the default authentication settings, and authenticate the MCP using
+ your Shorebird account (via OAuth).
+
+ 3. Create a new coding agent thread and connect it to
+ your GitHub repo
+
+
+
+ 4. Prompt your agent to build you an app using Shorebird Zap. You can use
+ whatever prompt you'd like, but here's one that has worked well:
+
+ ```text
+ Use Shorebird Zap to make an app I can run on my phone. It should...
+ ```
+
+
+
+
+
+
+
+
+
+ 1. Create a new coding agent thread and connect it to your GitHub repo
+
+
+
+ 2. Copy the default prompt provided on the Projects tab of the Shorebird
+ Zap app, and send it to your coding agent
+
+ 3. When prompted by your agent, visit the link to authenticate your
+ Shorebird account via OAuth. If prompted, enter the device code as well.
+
+
+
+
+
+
+
+### Opening your Zap
+
+Once your agent has successfully built your Zap, it will appear in the Shorebird
+Zap app, and you can begin iterating on it.
+
+
+
+1. Open the app and sign in with your Shorebird account
+
+2. Navigate to the Projects tab
+
+3. Select your project and tap "Start Session"
+
+
+
+This will open your Zap in the Session tab, allowing you to navigate and use it.
+At any time, if you want to return to your projects, you can tap the floating
+note button and select "Back to projects," or hold three fingers on the screen
+for about a second.
+
+### Notes and improving your Zap
+
+As you experience your Zap firsthand, you can identify friction points, bugs,
+and enhancement opportunities in real time via **Notes**. Notes are saved in
+your Shorebird account, and are accessible to your coding agent via the MCP
+server.
+
+To take a note, tap the floating note button and select "New note."
+
+- New notes automatically add a screenshot of the current screen state
+- Tap "Mark up" to annotate the screenshot (for example, circle a button that
+ doesn't respond)
+- Notes can be edited, deleted, or resolved after being saved
+
+Since all your Notes are accessible via the MCP, it's very easy to have your
+agent address them. When you are ready for another version of your Zap, return
+to your coding agent thread, and prompt it to address the notes.
+
+Any prompt should work, but one that has worked well is:
+
+```text
+Address the open notes for this project
+```
+
+## Troubleshooting
+
+
+(Android) The link for downloading the app doesn't work
+
+It can take some time for your Google Group membership to be validated. Wait 10
+minutes and try visiting the Play Store link again.
+
+If the download still fails, please reach out via the
+[Feedback form](https://forms.gle/fjfjb1SMMnDz2VEN9).
+
+
+
+
+The agent can't access the link in the copy/paste prompt
+
+This usually happens because your agent's environment is restricted in what URLs
+it can visit. This can usually be resolved in the environment settings for your
+coding agent provider.
+
+Note: environment network settings may not be available via mobile app.
+
+
+
+## Feedback
+
+Shorebird Zap is a prototype, and your feedback shapes where it goes next. Reach
+out on [Discord](https://discord.gg/shorebird) or fill out the
+[Feedback form](https://forms.gle/fjfjb1SMMnDz2VEN9).
diff --git a/src/pages/[...slug].md.ts b/src/pages/[...slug].md.ts
index 57971135..994f71d2 100644
--- a/src/pages/[...slug].md.ts
+++ b/src/pages/[...slug].md.ts
@@ -9,6 +9,7 @@ import rehypeRemark from 'rehype-remark';
import remarkGfm from 'remark-gfm';
import remarkStringify from 'remark-stringify';
import { unified } from 'unified';
+import { unlistedPages } from '~/unlisted';
// Serves Markdown for every docs page at its URL + `.md`, so AI agents can
// fetch page content directly instead of scraping rendered HTML. Renders
@@ -20,7 +21,13 @@ import { unified } from 'unified';
export const prerender = true;
export const getStaticPaths = (async () => {
- const docs = await getCollection('docs', (entry) => !entry.data.draft);
+ // Unlisted pages are reachable at their HTML URL but are not published as
+ // agent-facing Markdown, which would hand out the full text of a page that
+ // is deliberately not advertised.
+ const docs = await getCollection(
+ 'docs',
+ (entry) => !entry.data.draft && !unlistedPages.includes(entry.id),
+ );
return docs.map((entry) => ({
params: { slug: entry.id },
props: { entry },
diff --git a/src/unlisted.ts b/src/unlisted.ts
new file mode 100644
index 00000000..679c05ec
--- /dev/null
+++ b/src/unlisted.ts
@@ -0,0 +1,9 @@
+// Pages that are reachable only by their direct URL. They are kept out of the
+// sidebar and Pagefind (via their own frontmatter), and out of the sitemap, the
+// `llms*.txt` bundles, and the agent-facing `.md` routes (via this list), so
+// nothing advertises them.
+//
+// Values are content collection entry IDs, which are also the page's URL path:
+// the file path under `src/content/docs/` without its extension, and without a
+// trailing `/index` (`zap.mdx` -> `zap`, `foo/index.mdx` -> `foo`).
+export const unlistedPages: readonly string[] = ['zap'];