diff --git a/.cspell.yaml b/.cspell.yaml index 431311b0..3a755dd7 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -103,6 +103,7 @@ words: - previewable - recompiles - riverpod + - rollforward - rollouts - rsassa - sdkman diff --git a/astro.config.mjs b/astro.config.mjs index a95db5e7..5a8545eb 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -71,6 +71,33 @@ const stripUnlistedFromLlmsFull = { }, }; +// `/changelog/` is a standalone page rather than a docs collection entry, so +// `starlight-llms-txt` leaves it out of `llms-full.txt`. This appends its +// Markdown twin, shaped like the plugin's pages (`# title`, `> description`, +// body). It must run after `stripUnlistedFromLlmsFull`, which counts pages +// from the end of the file. +const appendChangelogToLlmsFull = { + name: 'append-changelog-to-llms-full', + hooks: { + 'astro:build:done': async ({ dir, logger }) => { + const file = new URL('llms-full.txt', dir); + const changelog = await readFile(new URL('changelog.md', dir), 'utf8'); + const [, frontmatter, body] = + /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(changelog) ?? []; + const { title, description } = yaml.load(frontmatter ?? '') ?? {}; + if (!title || !description || !body?.trim()) { + throw new Error( + 'changelog.md: expected a title, description, and body.', + ); + } + const page = `# ${title}\n\n> ${description}\n\n${body.trim()}\n`; + const full = await readFile(file, 'utf8'); + await writeFile(file, `${full.trimEnd()}${llmsPageSeparator}${page}`); + logger.info('Appended the changelog to llms-full.txt'); + }, + }, +}; + // https://astro.build/config export default defineConfig({ site, @@ -180,6 +207,7 @@ export default defineConfig({ items: [{ autogenerate: { directory: 'flutter-concepts' } }], }, { label: 'Roadmap', link: '/roadmap/' }, + { label: 'Changelog', link: '/changelog/' }, ], plugins: [ starlightThemeNova(), @@ -207,6 +235,12 @@ Developer & Agent Interfaces: - Code Push REST API: OpenAPI 3.1 specification at https://api.shorebird.dev/openapi.json, base URL https://api.shorebird.dev/api/v1. Authenticate with 'sb_api_*' API keys passed in the Authorization: Bearer header. - Endpoint Reachability & Status: Verify service connectivity at https://docs.shorebird.dev/system/endpoint-reachability/.`, optionalLinks: [ + { + label: 'Changelog', + url: 'https://docs.shorebird.dev/changelog.md', + description: + 'New features, fixes, and supported Flutter versions in Shorebird, newest first', + }, { label: 'OpenAPI specification', url: 'https://api.shorebird.dev/openapi.json', @@ -272,6 +306,7 @@ Developer & Agent Interfaces: render: renderer, }), stripUnlistedFromLlmsFull, + appendChangelogToLlmsFull, ], redirects: { // Redirects to preserve legacy URLs & resolve agent probes. diff --git a/public/.well-known/agent-instructions.txt b/public/.well-known/agent-instructions.txt index c80318af..b7ca212a 100644 --- a/public/.well-known/agent-instructions.txt +++ b/public/.well-known/agent-instructions.txt @@ -22,3 +22,4 @@ Developers can always use Shorebird to build, manage releases, and patch Flutter - `shorebird preview`: Test patches locally before distributing. 2. OpenAPI REST API: When automating workflows programmatically, use `https://api.shorebird.dev/api/v1` with an `sb_api_*` API key passed in `Authorization: Bearer `. OpenAPI 3.1 specification is available at `https://api.shorebird.dev/openapi.json`. 3. Documentation reference: Every page on `docs.shorebird.dev` provides a Markdown twin at `.md` and supports content negotiation via `Accept: text/markdown`. Curated index at `https://docs.shorebird.dev/llms.txt`. +4. Changelog: `https://docs.shorebird.dev/changelog.md` lists what shipped, newest first, with the Shorebird CLI release for each change. Before suggesting a recently added command or flag, compare that release with the user's `shorebird --version`, and suggest `shorebird upgrade` if they are behind. diff --git a/public/.well-known/agent.json b/public/.well-known/agent.json index afabfc4b..af29694e 100644 --- a/public/.well-known/agent.json +++ b/public/.well-known/agent.json @@ -18,6 +18,7 @@ "authGuide": "https://docs.shorebird.dev/auth.md", "llms": "https://docs.shorebird.dev/llms.txt", "llmsFull": "https://docs.shorebird.dev/llms-full.txt", + "changelog": "https://docs.shorebird.dev/changelog.md", "skills": "https://docs.shorebird.dev/.well-known/agent-skills/index.json", "instructions": "https://docs.shorebird.dev/.well-known/agent-instructions.txt", "apiCatalog": "https://docs.shorebird.dev/.well-known/api-catalog" diff --git a/public/.well-known/ai-catalog.json b/public/.well-known/ai-catalog.json index 4626ee44..b4a03f94 100644 --- a/public/.well-known/ai-catalog.json +++ b/public/.well-known/ai-catalog.json @@ -41,6 +41,17 @@ "Shorebird architecture and system design" ] }, + { + "identifier": "urn:air:shorebird.dev:docs:changelog", + "displayName": "Shorebird Changelog", + "type": "text/markdown", + "url": "https://docs.shorebird.dev/changelog.md", + "representativeQueries": [ + "What's new in Shorebird", + "Shorebird CLI release notes", + "Which Shorebird version added a command or flag" + ] + }, { "identifier": "urn:air:shorebird.dev:status:endpoint-reachability", "displayName": "Shorebird Service Endpoint Reachability", diff --git a/public/_headers b/public/_headers index 84460079..bfb5fa8c 100644 --- a/public/_headers +++ b/public/_headers @@ -55,6 +55,10 @@ Content-Type: application/trafficadvice+json; charset=utf-8 Access-Control-Allow-Origin: * +/changelog.xml + Content-Type: application/rss+xml; charset=utf-8 + Access-Control-Allow-Origin: * + /opensearch.xml Content-Type: application/opensearchdescription+xml; charset=utf-8 Access-Control-Allow-Origin: * diff --git a/src/content.config.ts b/src/content.config.ts index 5e8d8113..1cd6c589 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,8 +1,10 @@ import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; import { docsLoader } from '@astrojs/starlight/loaders'; import { docsSchema } from '@astrojs/starlight/schema'; import { autoSidebarLoader } from 'starlight-auto-sidebar/loader'; import { autoSidebarSchema } from 'starlight-auto-sidebar/schema'; +import { changelogSchema } from '~/data/changelog-schema'; export const collections = { docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), @@ -10,4 +12,12 @@ export const collections = { loader: autoSidebarLoader(), schema: autoSidebarSchema(), }), + // One Markdown file per entry; see `src/content/changelog/_template.md`. + changelog: defineCollection({ + loader: glob({ + base: './src/content/changelog', + pattern: ['*.md', '!_*.md'], + }), + schema: changelogSchema, + }), }; diff --git a/src/content/changelog/_template.md b/src/content/changelog/_template.md new file mode 100644 index 00000000..03a38db6 --- /dev/null +++ b/src/content/changelog/_template.md @@ -0,0 +1,49 @@ +--- +# Copy this file to a new name in this folder. The file name becomes the +# entry's permalink (/changelog/#), so make it short, lowercase, and +# descriptive, like `patches-rollback-command.md`. Files starting with `_` are +# not published. +# +# Every field below is checked when the site builds, and a mistake fails the +# build with a message naming this file and the field. + +# A short headline, in sentence case. +title: Roll patches back from the CLI +# The day the release shipped, as YYYY-MM-DD with no quotes. Entries from the +# same day are listed in file name order. +date: 2026-08-28 +# The Shorebird CLI release that shipped the change, without a "v". Required +# for CLI and Flutter changes. Delete this line for a change that didn't ship in +# the CLI, like a Console, API, or server-side Code Push change. +version: 1.6.120 +# One of: Code Push, CLI, Console, API, Flutter +area: CLI +# One of: New, Fixed, Changed, Deprecated +type: New +# Optional: the docs page to read next. `href` must be a docs page on this +# site, starting with "/", and a #fragment must match a heading on it. Delete +# both lines if there's no page for it. +docLink: + label: Roll back a patch + href: /code-push/rollback/ +--- + +The first paragraph is the summary, which is always shown. Keep it to one or two +sentences. Wrap commands and flags in backticks, like +`shorebird patches rollback`. + +- Then a bulleted list with the details, shown when the entry is expanded. +- Each bullet can wrap onto more lines, as long as they are indented. + +```sh +shorebird patches rollback --release-version 1.0.0+1 --patch-number 1 +``` + + diff --git a/src/content/changelog/failed-patch-checks-for-replacement.md b/src/content/changelog/failed-patch-checks-for-replacement.md new file mode 100644 index 00000000..724dc7be --- /dev/null +++ b/src/content/changelog/failed-patch-checks-for-replacement.md @@ -0,0 +1,21 @@ +--- +title: A patch that fails to load is replaced on the same launch +date: 2026-09-16 +version: 1.6.122 +area: Code Push +type: Fixed +docLink: + label: Patch integrity and automatic rollback + href: /code-push/rollback/#patch-integrity-and-automatic-rollback +--- + +When a patch fails to load, the device now checks for a replacement patch on +that same launch, instead of waiting for the next one. + +- Previously, reporting the failure suppressed the update check, so a device + couldn't pick up a fixed patch until it launched again. +- Patch checks now report the patch the device is actually running, so each + device is attributed to the right patch. +- These fixes are in the updater built into Shorebird's Flutter engine, so they + apply to releases built with Shorebird's Flutter 3.47.4 or later, the default + in CLI 1.6.122. diff --git a/src/content/changelog/flutter-3-47-2.md b/src/content/changelog/flutter-3-47-2.md new file mode 100644 index 00000000..7fb31f39 --- /dev/null +++ b/src/content/changelog/flutter-3-47-2.md @@ -0,0 +1,21 @@ +--- +title: Flutter 3.47.2 support +date: 2026-08-28 +version: 1.6.120 +area: Flutter +type: New +docLink: + label: Flutter versions + href: /getting-started/flutter-version/ +--- + +Shorebird now supports Flutter 3.47.2 and Dart 3.13.2. + +- iOS and macOS: Swift package dependencies are always updated. +- Windows: fixes hot reload failing on file time truncation. +- Desktop: `--build-name` and `--build-number` are now forwarded to + `version.json`. + +```sh +shorebird release android --flutter-version=3.47.2 +``` diff --git a/src/content/changelog/flutter-3-47-3.md b/src/content/changelog/flutter-3-47-3.md new file mode 100644 index 00000000..19449f46 --- /dev/null +++ b/src/content/changelog/flutter-3-47-3.md @@ -0,0 +1,21 @@ +--- +title: Flutter 3.47.3 support +date: 2026-09-14 +version: 1.6.121 +area: Flutter +type: New +docLink: + label: Flutter versions + href: /getting-started/flutter-version/ +--- + +Shorebird now supports Flutter 3.47.3 and Dart 3.13.3. + +- Android: fixes license detection for cmdline-tools 23.0 and newer. +- iOS and macOS: a missing Xcode is now handled instead of failing hard. +- Windows: fixes Dart cross-compilation. +- B-series PowerVR GPUs no longer use Vulkan. + +```sh +shorebird release android --flutter-version=3.47.3 +``` diff --git a/src/content/changelog/flutter-3-47-4.md b/src/content/changelog/flutter-3-47-4.md new file mode 100644 index 00000000..c7602198 --- /dev/null +++ b/src/content/changelog/flutter-3-47-4.md @@ -0,0 +1,22 @@ +--- +title: Flutter 3.47.4 support +date: 2026-09-16 +version: 1.6.122 +area: Flutter +type: New +docLink: + label: Flutter versions + href: /getting-started/flutter-version/ +--- + +Shorebird now supports Flutter 3.47.4 and Dart 3.13.3. + +- iOS: native assets now require iOS 15, raised from iOS 13. +- iOS: a build now warns when Device Support Symbols are missing, instead of + failing partway through. +- Windows: Application Control and security policy blocks are handled instead of + failing the build. + +```sh +shorebird release ios --flutter-version=3.47.4 +``` diff --git a/src/content/changelog/flutter-3-47-5.md b/src/content/changelog/flutter-3-47-5.md new file mode 100644 index 00000000..f7d99a5a --- /dev/null +++ b/src/content/changelog/flutter-3-47-5.md @@ -0,0 +1,20 @@ +--- +title: Flutter 3.47.5 support +date: 2026-09-21 +version: 1.6.123 +area: Flutter +type: New +docLink: + label: Flutter versions + href: /getting-started/flutter-version/ +--- + +Shorebird now supports Flutter 3.47.5 and Dart 3.13.4. + +- iOS: fixes an occasional crash when debugging on physical iOS 27 devices. +- Widget Previewer: fixes a crash when re-expanding a preview group. +- A Dart Development Service startup failure is now handled instead of crashing. + +```sh +shorebird release ios --flutter-version=3.47.5 +``` diff --git a/src/content/changelog/flutter-version-fvm-and-system.md b/src/content/changelog/flutter-version-fvm-and-system.md new file mode 100644 index 00000000..bef5977c --- /dev/null +++ b/src/content/changelog/flutter-version-fvm-and-system.md @@ -0,0 +1,25 @@ +--- +title: Release with the Flutter version you already use +date: 2026-09-21 +version: 1.6.123 +area: CLI +type: New +docLink: + label: Match the Flutter version you already use + href: /getting-started/flutter-version/#match-the-flutter-version-you-already-use +--- + +`--flutter-version` now accepts `fvm` and `system`, so you no longer have to +look up and repeat your Flutter version number. + +- `--flutter-version=fvm` uses the version fvm resolves for your project from + its `.fvmrc`. It requires `fvm` on your `PATH`. +- `--flutter-version=system` uses the version reported by the `flutter` on your + `PATH`. +- Shorebird still builds with its own fork of Flutter at that version, not with + your fvm or system install. A version Shorebird doesn't support fails the same + way as one you name explicitly. + +```sh +shorebird release android --flutter-version=fvm +``` diff --git a/src/content/changelog/ios-split-debug-info-dsym.md b/src/content/changelog/ios-split-debug-info-dsym.md new file mode 100644 index 00000000..34a275d3 --- /dev/null +++ b/src/content/changelog/ios-split-debug-info-dsym.md @@ -0,0 +1,16 @@ +--- +title: iOS debug symbols now upload to symbol servers +date: 2026-09-21 +version: 1.6.123 +area: CLI +type: Fixed +docLink: + label: Release options + href: /code-push/release/#options +--- + +On iOS, `--split-debug-info` now writes a Mach-O dSYM, so symbol servers ingest +it and Dart stack traces from production become readable. + +- Previously the file was an ELF with no debug ID. Uploads reported finding + nothing, while still exiting cleanly. diff --git a/src/content/changelog/patches-rollback-and-rollforward.md b/src/content/changelog/patches-rollback-and-rollforward.md new file mode 100644 index 00000000..20fdb5e1 --- /dev/null +++ b/src/content/changelog/patches-rollback-and-rollforward.md @@ -0,0 +1,21 @@ +--- +title: Roll patches back and forward from the CLI +date: 2026-08-28 +version: 1.6.120 +area: CLI +type: New +docLink: + label: Roll back a patch + href: /code-push/rollback/ +--- + +`shorebird patches rollback` and `shorebird patches rollforward` do the same as +the Rollback and Roll Forward actions in the Console. + +- Both take `--release-version` and `--patch-number`. +- By default, a patch that is already in the requested state is reported and the + command succeeds. Add `--require-change` to exit with an error instead. + +```sh +shorebird patches rollback --release-version 1.0.0+1 --patch-number 1 +``` diff --git a/src/content/changelog/shorebird-apps-commands.md b/src/content/changelog/shorebird-apps-commands.md new file mode 100644 index 00000000..055ba95f --- /dev/null +++ b/src/content/changelog/shorebird-apps-commands.md @@ -0,0 +1,23 @@ +--- +title: Manage apps from the CLI +date: 2026-09-14 +version: 1.6.121 +area: CLI +type: New +docLink: + label: Transfer an app + href: /account/orgs/#transfer-an-app +--- + +New `shorebird apps` commands list, rename, delete, and transfer apps without +opening the Console. + +- `shorebird apps transfer --org-id ` moves an app into another + organization. Run `shorebird account orgs` to find the id. +- `shorebird apps rename --name ` changes the display name. +- `shorebird apps delete` has no prompt. Pass `--confirm-name` with the app's + current display name to confirm. + +```sh +shorebird apps transfer --app-id --org-id 42 +``` diff --git a/src/content/changelog/shorebird-channels-commands.md b/src/content/changelog/shorebird-channels-commands.md new file mode 100644 index 00000000..d5f16141 --- /dev/null +++ b/src/content/changelog/shorebird-channels-commands.md @@ -0,0 +1,22 @@ +--- +title: Manage channels from the CLI +date: 2026-09-14 +version: 1.6.121 +area: CLI +type: New +docLink: + label: Staging patches + href: /code-push/guides/staging-patches/ +--- + +New `shorebird channels` commands create, list, and delete the channels (tracks) +an app can publish to. + +- Publishing a patch with `--track=` still creates that channel + automatically. +- `shorebird channels delete` is permanent and has no prompt. Pass + `--confirm-name` with the channel name to confirm. + +```sh +shorebird channels create --app-id --name qa +``` diff --git a/src/data/changelog-schema.ts b/src/data/changelog-schema.ts new file mode 100644 index 00000000..69f3f1af --- /dev/null +++ b/src/data/changelog-schema.ts @@ -0,0 +1,59 @@ +import { z } from 'astro/zod'; + +// Kept apart from `changelog.ts` so `src/content.config.ts` can import it +// without pulling in `astro:content`. + +// The parts of Shorebird a change can be filed under. Fixed on purpose: this +// drives the area filter chips, so it should stay in sync with the products +// we actually ship rather than growing a new value per entry. +export const AREAS = ['Code Push', 'CLI', 'Console', 'API', 'Flutter'] as const; +export type Area = (typeof AREAS)[number]; + +// Changes in these areas always ship in a CLI release, so they need a version. +const VERSIONED_AREAS: readonly Area[] = ['CLI', 'Flutter']; + +export const TYPES = ['New', 'Fixed', 'Changed', 'Deprecated'] as const; +export type ChangeType = (typeof TYPES)[number]; + +/** + * Frontmatter of a `src/content/changelog/*.md` entry. The messages are + * written for whoever is adding an entry, since they surface as build errors. + */ +export const changelogSchema = z + .strictObject({ + title: z.string().trim().min(1), + date: z.date({ + error: 'date must be YYYY-MM-DD with no quotes, like 2026-09-21', + }), + // Optional: a Console, API, or server-side Code Push change doesn't ship + // in a CLI release. + version: z.coerce + .string() + .regex( + /^\d+\.\d+\.\d+$/, + 'version must be the Shorebird CLI release without a "v", like 1.6.123', + ) + .optional(), + area: z.enum(AREAS), + type: z.enum(TYPES), + docLink: z + .strictObject({ + label: z.string().trim().min(1), + // A relative href would resolve against /changelog/ on the page. + href: z + .string() + .trim() + .regex( + /^(\/|https:\/\/)/, + 'docLink.href must start with "/" for a docs page, like ' + + '/code-push/rollback/, or with "https://"', + ), + }) + .optional(), + }) + .refine((d) => d.version || !VERSIONED_AREAS.includes(d.area), { + path: ['version'], + error: + 'version is required for CLI and Flutter changes, since they ship in ' + + 'a CLI release', + }); diff --git a/src/data/changelog.ts b/src/data/changelog.ts new file mode 100644 index 00000000..efa6a39d --- /dev/null +++ b/src/data/changelog.ts @@ -0,0 +1,298 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { getCollection, render } from 'astro:content'; +import { unlistedPages } from '~/unlisted'; +import { AREAS, type Area, type ChangeType } from './changelog-schema'; + +// Shared by the changelog page (`src/pages/changelog.astro`), its +// agent-facing Markdown twin (`src/pages/changelog.md.ts`), and its RSS feed +// (`src/pages/changelog.xml.ts`), so all three list the same entries. +// +// Entries are hand-written, one Markdown file each, in +// `src/content/changelog/`. Copy `_template.md` there to add one. + +export { AREAS, type Area }; + +export const CHANGELOG_TITLE = 'Changelog'; +export const CHANGELOG_DESCRIPTION = + 'New features, fixes, and supported Flutter versions in Shorebird, newest first.'; + +export interface ChangelogEntry { + /** Anchor id and permalink slug for this entry: its file name. */ + id: string; + /** Release date, as `YYYY-MM-DD`. */ + date: string; + /** + * The Shorebird CLI release that shipped the change, without the `v`. + * Absent for changes that don't ship in the CLI, like Console updates. + */ + version?: string; + area: Area; + type: ChangeType; + title: string; + /** May mark inline code with backticks, as in Markdown. */ + summary: string; + /** May mark inline code with backticks, as in Markdown. */ + bullets: string[]; + /** One command per line, without a leading `$`. */ + code?: string; + docLink?: { label: string; href: string }; +} + +const BODY_HELP = + 'The body must be a summary paragraph, then a "- " bullet list, then ' + + 'optionally one ```sh code block. See src/content/changelog/_template.md.'; + +/** + * The page renders only backticks (as inline code), while `/changelog.md` + * passes text through as Markdown. Returns what in `text` would render + * differently between the two, or `undefined` if nothing would. + */ +function inlineProblem(text: string, allowCode = true): string | undefined { + const parts = text.split('`'); + if (!allowCode && parts.length > 1) + return 'Backticks are not supported here.'; + if (parts.length % 2 === 0) return 'A backtick is never closed.'; + const prose = parts.filter((_, i) => i % 2 === 0).join(' '); + if (/\[[^\]]*\]\([^)]*\)/.test(prose)) { + return 'Links are not supported in entry text. Put the link in docLink.'; + } + if (/\*\*|__/.test(prose)) return 'Bold text is not supported.'; + // Emphasis needs a closing marker, so `snake_case` and `_template.md` pass. + if (/(^|[\s(])([*_])\S(?:[^*_\n]*?\S)?\2(?=$|[\s.,;:!?)])/.test(prose)) { + return 'Italic text is not supported.'; + } + if (/<[^>]*>/.test(prose)) { + return 'Wrap placeholders like in backticks, or they vanish from /changelog.md.'; + } + if (/&(#\d+|[a-z]+);/i.test(prose)) { + return 'Write characters as they are, not as HTML entities like &.'; + } + if (/\\[\\`*_{}[\]()#+\-.!|]/.test(prose)) { + return 'Backslash escapes are not supported. Put the text in backticks instead.'; + } + if (/https?:\/\/|www\./.test(prose)) { + return 'Links are not supported in entry text. Put the link in docLink.'; + } + return undefined; +} + +/** + * Splits an entry's Markdown body into its summary, bullets, and command. + * Only that shape is accepted, so a stray heading or second paragraph fails + * the build instead of silently disappearing from the page. + */ +function parseBody( + file: string, + body: string, +): Pick { + // Report positions as `file:line` in the whole file, frontmatter included, + // so they can be clicked in a terminal or editor. + let offset = 0; + try { + const raw = readFileSync(file, 'utf8'); + const at = raw.indexOf(body); + if (at >= 0) offset = raw.slice(0, at).split('\n').length - 1; + } catch { + // Without the file, line numbers stay relative to the body. + } + const fail = (line: number | undefined, problem: string): never => { + const where = line === undefined ? file : `${file}:${line + offset}`; + throw new Error(`${where}: ${problem} ${BODY_HELP}`); + }; + + // Blank out comments line by line, so line numbers in errors still match. + const lines = body + .replace(//g, (c) => c.replace(/[^\n]/g, '')) + .split(/\r?\n/); + + const summary: string[] = []; + let summaryLine = 0; + const bullets: string[] = []; + const bulletLines: number[] = []; + let code: string[] | undefined; + let block: 'none' | 'summary' | 'bullet' = 'none'; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const n = i + 1; + if (line.trim() === '') { + block = 'none'; + } else if (/^(#|>|\d+\.\s)/.test(line)) { + fail(n, 'Headings, quotes, and numbered lists are not supported.'); + } else if (line.startsWith('```')) { + if (code) fail(n, 'Only one code block is allowed.'); + if (!/^```(sh|bash|shell)?\s*$/.test(line)) { + fail(n, 'The code block must be ```sh.'); + } + code = []; + // Keep lines as written: indentation and blank lines can matter, and + // only a `$ ` prompt is stripped, never a `$VARIABLE`. + for (i++; i < lines.length && !lines[i].startsWith('```'); i++) { + code.push(lines[i].trimEnd().replace(/^\$\s+/, '')); + } + if (i === lines.length) fail(n, 'This code block is never closed.'); + while (code.length && !code[0]) code.shift(); + while (code.length && !code[code.length - 1]) code.pop(); + block = 'none'; + } else if (code) { + fail(n, 'Nothing can follow the code block.'); + } else if (/^[-*]\s/.test(line)) { + if (summary.length === 0) fail(n, 'The summary paragraph comes first.'); + bullets.push(line.replace(/^[-*]\s+/, '').trim()); + bulletLines.push(n); + block = 'bullet'; + } else if (block === 'bullet' && /^\s/.test(line)) { + bullets[bullets.length - 1] += ` ${line.trim()}`; + } else if (block === 'summary') { + summary.push(line.trim()); + } else if (summary.length === 0 && block === 'none') { + summary.push(line.trim()); + summaryLine = n; + block = 'summary'; + } else { + fail(n, `Unexpected text: "${line.trim().slice(0, 40)}".`); + } + } + + if (summary.length === 0) + fail(undefined, 'The summary paragraph is missing.'); + if (bullets.length === 0) fail(undefined, 'At least one bullet is required.'); + if (code?.length === 0) fail(undefined, 'The code block is empty.'); + + const summaryText = summary.join(' '); + const summaryProblem = inlineProblem(summaryText); + if (summaryProblem) fail(summaryLine, summaryProblem); + bullets.forEach((b, i) => { + const problem = inlineProblem(b); + if (problem) fail(bulletLines[i], problem); + }); + + return { + summary: summaryText, + bullets, + code: code?.join('\n'), + }; +} + +/** + * Fails the build if an internal `docLink` doesn't point at a docs page, or + * its `#fragment` doesn't match a heading on that page. Starlight's link + * validator only covers links inside docs pages, not these. + */ +async function checkDocLink(file: string, href: string): Promise { + const url = new URL(href, 'https://docs.shorebird.dev'); + if (url.origin !== 'https://docs.shorebird.dev') return; + const path = url.pathname.replace(/^\/|\/$/g, ''); + const docs = await getCollection('docs'); + const doc = docs.find((d) => d.id === path || d.id === `${path}/index`); + // Linking an unlisted or draft page would advertise it, which is what + // `src/unlisted.ts` exists to prevent (and a draft 404s in production). + if (doc && (doc.data.draft || unlistedPages.includes(doc.id))) { + throw new Error( + `${file}: docLink.href "${href}" is an unlisted or draft page. Link a ` + + 'published page instead.', + ); + } + // Standalone pages like /roadmap/ aren't in the docs collection. + const standalone = [ + `src/pages/${path}.astro`, + `src/pages/${path}/index.astro`, + ]; + if (!doc && path && standalone.some((f) => existsSync(f))) return; + if (!doc) { + throw new Error( + `${file}: docLink.href "${href}" doesn't match any docs page. Use the ` + + 'path from the address bar, like /code-push/rollback/.', + ); + } + const fragment = decodeURIComponent(url.hash.slice(1)); + if (!fragment) return; + const { headings } = await render(doc); + if (!headings.some((h) => h.slug === fragment)) { + throw new Error( + `${file}: docLink.href "${href}" links to #${fragment}, but that page ` + + `has no such heading. Its headings are: ` + + headings.map((h) => `#${h.slug}`).join(', '), + ); + } +} + +/** + * A copy of `entries`, newest first. Same-day entries are ordered by file + * name, since the order `getCollection` returns them in isn't guaranteed. + */ +export function newestFirst(entries: ChangelogEntry[]): ChangelogEntry[] { + return [...entries].sort( + (a, b) => b.date.localeCompare(a.date) || a.id.localeCompare(b.id), + ); +} + +let cached: Promise | undefined; + +/** Every published entry, newest first, validated. */ +export function getEntries(): Promise { + cached ??= (async () => { + const files = await getCollection('changelog'); + const entries = await Promise.all( + files.map(async ({ id, data, body, filePath }) => { + const file = filePath ?? `src/content/changelog/${id}.md`; + // The file name is the entry's anchor, so it can't be a month's. + if ( + /^(january|february|march|april|may|june|july|august|september|october|november|december)-\d{4}$/.test( + id, + ) + ) { + throw new Error( + `${file}: the file name matches a month heading's anchor. Rename it.`, + ); + } + const titleProblem = inlineProblem(data.title, false); + if (titleProblem) throw new Error(`${file}: title: ${titleProblem}`); + if (data.docLink) await checkDocLink(file, data.docLink.href); + return { + id, + ...data, + date: data.date.toISOString().slice(0, 10), + ...parseBody(file, body ?? ''), + }; + }), + ); + return newestFirst(entries); + })(); + return cached; +} + +/** Link to the GitHub release notes for a CLI version. */ +export function releaseNotesUrl(version: string): string { + return `https://github.com/shorebirdtech/shorebird/releases/tag/v${version}`; +} + +function formatMonth(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }); +} + +/** + * Entries grouped under "September 2026"-style headings, newest first. + * `id` ("september-2026") anchors the heading and its table-of-contents link. + */ +export function groupByMonth( + entries: ChangelogEntry[], +): { id: string; label: string; items: ChangelogEntry[] }[] { + const groups: { id: string; label: string; items: ChangelogEntry[] }[] = []; + // Sort rather than trust the input order: one entry out of place would + // otherwise start a second heading for its month. + for (const e of newestFirst(entries)) { + const label = formatMonth(e.date); + let group = groups.find((g) => g.label === label); + if (!group) { + const id = label.toLowerCase().replace(' ', '-'); + groups.push((group = { id, label, items: [] })); + } + group.items.push(e); + } + return groups; +} diff --git a/src/pages/changelog.astro b/src/pages/changelog.astro new file mode 100644 index 00000000..25139b1f --- /dev/null +++ b/src/pages/changelog.astro @@ -0,0 +1,936 @@ +--- +// cspell:ignore beforematch data-type nums white-space +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import { + AREAS, + CHANGELOG_DESCRIPTION, + CHANGELOG_TITLE, + getEntries, + groupByMonth, + releaseNotesUrl, + type ChangelogEntry, +} from '~/data/changelog'; + +const TYPE_LABEL_COLOR: Record = { + New: 'new', + Fixed: 'fixed', + Changed: 'changed', + Deprecated: 'deprecated', +}; + +const ENTRIES = await getEntries(); +const groups = groupByMonth(ENTRIES); + +// Only offer a chip for an area that has entries, so no filter starts empty. +const areas = AREAS.filter((a) => ENTRIES.some((e) => e.area === a)); + +// A command's lines, each marked for whether it gets a `$ ` prompt: not a +// blank line, and not one continuing the previous line after a backslash. +function commandLines(code: string): { text: string; prompt: boolean }[] { + const lines = code.split('\n'); + return lines.map((text, i) => ({ + text, + prompt: text.trim() !== '' && !lines[i - 1]?.endsWith('\\'), + })); +} + +function formatDay(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); +} +--- + + ({ depth: 2, slug: g.id, text: g.label }))} +> +

+ New features, fixes, and supported Flutter versions in Shorebird. Expand an + entry for the full detail, the command, and the doc it belongs to. Subscribe with the RSS feed. +

+ +
+ +
+ { + ['All', ...areas].map((a) => ( + + )) + } +
+ {/* The chips show the counts; this tells screen reader users. */} + +
+ +
+ { + groups.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((e) => { + const searchText = [ + e.title, + e.summary, + e.area, + e.type, + e.version ?? '', + ...e.bullets, + e.code ?? '', + ] + .join(' ') + .replaceAll('`', '') + .toLowerCase(); + return ( +
+
+ + {e.version && ( + CLI {e.version} + )} +
+
+
+ +

+ {e.title} +

+

+ {e.summary + .split('`') + .map((part, i) => (i % 2 ? {part} : part))} +

+
+ {/* until-found lets the browser's find-in-page match text in + a collapsed entry; the beforematch handler then opens it. */} + +
+
+ ); + })} +
+ )) + } +
+ + +
+ + + + diff --git a/src/pages/changelog.md.ts b/src/pages/changelog.md.ts new file mode 100644 index 00000000..2b4c2d45 --- /dev/null +++ b/src/pages/changelog.md.ts @@ -0,0 +1,59 @@ +import { + CHANGELOG_DESCRIPTION, + CHANGELOG_TITLE, + getEntries, + groupByMonth, + releaseNotesUrl, + type ChangelogEntry, +} from '~/data/changelog'; + +// The Markdown twin of `/changelog/`. `[...slug].md.ts` only covers content +// collection pages, and the changelog is a standalone `.astro` page, so without +// this route the `` that `Head.astro` +// emits for every page would point at a 404, and `Accept: text/markdown` on +// `/changelog/` would fall back to HTML. +export const prerender = true; + +function entryToMarkdown(e: ChangelogEntry): string { + return [ + `### ${e.title}`, + [ + `**${e.type}** in ${e.area}, ${e.date}`, + e.version && + `, Shorebird CLI ${e.version} ([release notes](${releaseNotesUrl(e.version)}))`, + `. Permalink: [/changelog/#${e.id}](/changelog/#${e.id})`, + ] + .filter(Boolean) + .join(''), + e.summary, + e.bullets.map((b) => `- ${b}`).join('\n'), + e.code && ['```sh', e.code, '```'].join('\n'), + e.docLink && `Docs: [${e.docLink.label}](${e.docLink.href})`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export async function GET() { + const frontmatter = [ + '---', + `title: ${JSON.stringify(CHANGELOG_TITLE)}`, + `description: ${JSON.stringify(CHANGELOG_DESCRIPTION)}`, + '---', + ].join('\n'); + + const body = [ + frontmatter, + `Each entry names the Shorebird CLI release that shipped it, if any; compare it with \`shorebird --version\`, and run \`shorebird upgrade\` to get newer changes. RSS feed: [/changelog.xml](/changelog.xml)`, + ...groupByMonth(await getEntries()).flatMap((group) => [ + `## ${group.label}`, + ...group.items.map(entryToMarkdown), + ]), + ].join('\n\n'); + + // As in `[...slug].md.ts`, this header only applies in `astro dev` and + // `astro preview`; `public/_headers` sets it in production. + return new Response(`${body}\n`, { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }); +} diff --git a/src/pages/changelog.xml.ts b/src/pages/changelog.xml.ts new file mode 100644 index 00000000..29905ad9 --- /dev/null +++ b/src/pages/changelog.xml.ts @@ -0,0 +1,62 @@ +import type { APIContext } from 'astro'; +import { CHANGELOG_DESCRIPTION, getEntries } from '~/data/changelog'; + +// RSS 2.0 feed of the changelog, so readers can subscribe instead of checking +// the page. Written by hand rather than with `@astrojs/rss`, since a flat list +// of entries needs only a few tags. +export const prerender = true; + +function escapeXml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export async function GET({ site }: APIContext) { + // Feed links must be absolute; `site` comes from `astro.config.mjs`. + if (!site) throw new Error('changelog.xml needs `site` in astro.config.mjs.'); + const absolute = (path: string) => new URL(path, site).href; + const entries = await getEntries(); + const items = entries.map((e) => { + const link = absolute(`/changelog/#${e.id}`); + // Feed readers show the description as plain text, so drop the + // backticks that mark inline code on the page. + const description = e.summary.replaceAll('`', ''); + return [ + ' ', + ` ${escapeXml(`${e.type}: ${e.title}`)}`, + ` ${link}`, + ` ${link}`, + ` ${new Date(e.date).toUTCString()}`, + ` ${escapeXml(e.area)}`, + ` ${escapeXml(description)}`, + ' ', + ].join('\n'); + }); + + const xml = [ + '', + '', + ' ', + ' Shorebird changelog', + ` ${absolute('/changelog/')}`, + ` `, + ` ${escapeXml(CHANGELOG_DESCRIPTION)}`, + ' en-us', + entries[0] && + ` ${new Date(entries[0].date).toUTCString()}`, + ...items, + ' ', + '', + ] + .filter(Boolean) + .join('\n'); + + // As in `changelog.md.ts`, this header only applies in `astro dev` and + // `astro preview`; `public/_headers` sets it in production. + return new Response(`${xml}\n`, { + headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }, + }); +}