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); --- <Default><slot /></Default> @@ -29,7 +34,11 @@ const markdownPath = markdownSiblingPath(Astro.url.pathname); <meta name="author" content="Shorebird, Inc." /> <!-- AI Agent Discovery & Alternate Formats --> -<link rel="alternate" type="text/markdown" href={markdownPath} /> +{ + markdownPath && ( + <link rel="alternate" type="text/markdown" href={markdownPath} /> + ) +} <link rel="alternate" type="text/plain" diff --git a/src/content/docs/zap.mdx b/src/content/docs/zap.mdx new file mode 100644 index 00000000..1f9b6e25 --- /dev/null +++ b/src/content/docs/zap.mdx @@ -0,0 +1,237 @@ +--- +title: Shorebird Zap +description: + Instructions for using Shorebird Zap, a prototype currently in testing with + select customers. +# This page is intentionally unlisted: it is only reachable via its direct URL. +# The frontmatter below keeps it out of the sidebar, search, pagination, and +# search engine indexes; `src/unlisted.ts` keeps it out of everything else. +sidebar: + hidden: true +prev: false +next: false +pagefind: false +head: + - tag: meta + attrs: + name: robots + content: noindex, nofollow +--- + +import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components'; + +<Aside type="caution" title="Prototype"> + 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). +</Aside> + +## 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 + +<Aside type="caution" title="Claude Code only"> + Right now, Shorebird Zap only supports Claude Code. Support for other coding + agents and platforms (Cursor, Codex, Antigravity) is coming soon. +</Aside> + +## Installing + +<Tabs> + <TabItem label="Android"> + <Steps> + + 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 + <Aside type="note">It may take 5 to 10 minutes for your membership to allow you to download the app</Aside> + + 3. [Download and install](https://play.google.com/store/apps/details?id=dev.shorebird.companion) the app + + </Steps> + + </TabItem> + + <TabItem label="iOS"> + Visit [Shorebird's TestFlight link](https://testflight.apple.com/join/5zqR6tQx) and install the app. + </TabItem> +</Tabs> + +## 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.** + +<Tabs> + + <TabItem label="With MCP"> + + <Steps> + + 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. + + ![Claude's + menu open to Connectors, then Add connector, then Add custom connector](../../assets/zap_add_custom_connector.png) + + Set the name to anything you'd like, and the URL to + `https://mcp.shorebird.dev/mcp` + + ![The Add custom connector dialog with the name and URL fields filled out](../../assets/zap_custom_connector_form.png) + + 2. Use the default authentication settings, and authenticate the MCP using + your Shorebird account (via OAuth). + + 3. <a id="skip-mcp"></a>Create a new coding agent thread and connect it to + your GitHub repo + + <Aside type="note" title="Local source"> + If you are using an agent platform that supports local source control, + like Claude Code Desktop or CLI, you can have your agent build locally + instead. + </Aside> + + 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... + ``` + + </Steps> + + </TabItem> + + <TabItem label="Without MCP"> + + <Steps> + + 1. Create a new coding agent thread and connect it to your GitHub repo + + <Aside type="note" title="Local source"> + If you are using an agent platform that supports local source control, + like Claude Code Desktop or CLI, you can have your agent build locally + instead. + </Aside> + + 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. + + </Steps> + + </TabItem> + +</Tabs> + +### 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. + +<Steps> + +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" + +</Steps> + +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 + +<details> +<summary>(Android) The link for downloading the app doesn't work</summary> + +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). + +</details> + +<details> +<summary>The agent can't access the link in the copy/paste prompt</summary> + +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. + +</details> + +## 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'];