Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ updates:
update-types:
- minor
- patch
dev-deps:
dependency-type: development
update-types:
- minor
- patch

# @ai-react-markdown/mantine
- package-ecosystem: npm
Expand Down Expand Up @@ -143,6 +148,11 @@ updates:
update-types:
- minor
- patch
dev-deps:
dependency-type: development
update-types:
- minor
- patch

# GitHub Actions used in workflows — major-block only; no lint-plugin lock needed.
- package-ecosystem: github-actions
Expand Down
45 changes: 36 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ on:
branches: [main]
pull_request:
branches: [main]
# Manual re-runs on any branch (runner debugging, flake triage).
workflow_dispatch:

# Cancel any in-progress run on the same ref when a new commit arrives.
# Cancel superseded runs on PRs only: every main commit keeps its full CI
# conclusion (bisect / branch-protection readability).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
ci:
Expand All @@ -27,6 +30,7 @@ jobs:
- format-check
- typecheck
- test
- storybook-test
- build

steps:
Expand Down Expand Up @@ -55,22 +59,45 @@ jobs:
if: matrix.task == 'format-check'
run: pnpm format:check

# mantine's typecheck/tests resolve @ai-react-markdown/core through its
# exports map, which points at dist/ — absent on a fresh checkout since
# matrix tasks run in isolated jobs. Build core first so dependents
# check against the real built artifact (what npm consumers get).
- name: Build core (dependents resolve its dist)
# Dependents (mantine src, stories importing package names) resolve
# through the exports maps, which point at dist/ — absent on a fresh
# checkout since matrix tasks run in isolated jobs. Building first means
# they check against the real built artifact (what npm consumers get).
- name: Build packages (dependents resolve dist)
if: matrix.task == 'typecheck' || matrix.task == 'test'
run: pnpm --filter @ai-react-markdown/core build
run: pnpm build

# --include-workspace-root also runs the root typecheck script, which
# covers .storybook/ and the root config files (no package tsconfig does).
- name: Typecheck (all packages)
if: matrix.task == 'typecheck'
run: pnpm -r typecheck
run: pnpm -r --include-workspace-root typecheck

- name: Test (all packages)
if: matrix.task == 'test'
run: pnpm -r test

# Smoke-renders every story in headless Chromium via the addon-vitest
# project — the only automated coverage the stories/decorators have.
- name: Storybook browser tests
if: matrix.task == 'storybook-test'
run: |
pnpm build
pnpm exec playwright install chromium --with-deps
pnpm vitest run --project storybook

- name: Build (all packages)
if: matrix.task == 'build'
run: pnpm build

# Machine-guard the packaging invariants (exports map resolution across
# node10/node16/bundler, tarball contents) instead of relying on memory.
# ./styles.css is excluded from attw: it is a CSS entrypoint, which attw
# can only ever see as an unresolvable JS module.
- name: Package checks (attw + publint)
if: matrix.task == 'build'
run: |
pnpm exec attw --pack packages/core
pnpm exec attw --pack packages/mantine --exclude-entrypoints ./styles.css
pnpm exec publint packages/core
pnpm exec publint packages/mantine
111 changes: 99 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history: the ancestry check below needs main's commit graph.
fetch-depth: 0

# Any `v*` tag triggers this workflow, wherever it points. Refuse tags
# on commits that never landed on main (unreviewed / orphan commits).
- name: Verify tag commit is on main
run: |
git fetch origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Tag ${GITHUB_REF_NAME} points at a commit that is not on main"
exit 1
fi

- name: Setup pnpm
uses: pnpm/action-setup@v4
Expand All @@ -51,14 +64,21 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Verify tag matches package version
# Checks every published package, not just the root: if versions drift
# (bad merge, partial bump), `pnpm publish -r` silently skips versions
# that already exist on the registry and the release would ship nothing.
- name: Verify tag matches all package versions
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PKG_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag v${TAG_VERSION} does not match package.json version ${PKG_VERSION}"
exit 1
fi
FAIL=0
for PKG in package.json packages/*/package.json; do
PKG_VERSION="$(node -p "require('./${PKG}').version")"
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag v${TAG_VERSION} does not match ${PKG} version ${PKG_VERSION}"
FAIL=1
fi
done
exit "$FAIL"

# Full quality gate — mirrors CI so a tag can't ship anything main
# wouldn't accept. Build runs first: mantine's typecheck/tests resolve
Expand All @@ -73,14 +93,45 @@ jobs:
run: pnpm build

- name: Typecheck
run: pnpm -r typecheck
run: pnpm -r --include-workspace-root typecheck

# Per-package unit suites, matching the CI gate. The root `vitest run`
# additionally spins up the storybook browser project, which needs
# playwright browsers the runner doesn't have.
# Per-package unit suites, matching the CI gate.
- name: Test
run: pnpm -r test

# Full story smoke suite in headless Chromium — same coverage as CI's
# storybook-test task, so a tag pushed before that commit's CI run
# completes (or after a red one) still can't ship a story/decorator
# regression.
- name: Storybook browser tests
run: |
pnpm exec playwright install chromium --with-deps
pnpm vitest run --project storybook

# Idempotency guard: `pnpm publish -r` silently SKIPS versions that
# already exist on the registry, so without this check a tag pointing
# at new content while the version number already exists would go
# green with the OLD tarball staying live (shipping nothing). An
# existing version is acceptable only on a RE-RUN of this same
# workflow run (GITHUB_RUN_ATTEMPT > 1) — the recovery path for a
# partially failed release; a re-run always reuses the original
# commit, so identity is guaranteed. (Commit identity cannot be
# checked on the registry itself: pnpm's OIDC publish records no
# gitHead — verified empirically against our published versions.)
- name: Guard against republishing an existing version
run: |
VERSION="${GITHUB_REF_NAME#v}"
for PKG in @ai-react-markdown/core @ai-react-markdown/mantine; do
if npm view "${PKG}@${VERSION}" version >/dev/null 2>&1; then
if [ "${GITHUB_RUN_ATTEMPT:-1}" -gt 1 ]; then
echo "${PKG}@${VERSION} already on the registry — re-run recovery; publish will skip it harmlessly"
else
echo "::error::${PKG}@${VERSION} already exists on the registry — a fresh run must publish a NEW version (reusing one ships nothing). Recovering a partially failed release? Re-run the failed workflow run instead of pushing a new tag."
exit 1
fi
fi
done

# pnpm delegates the actual upload to the npm CLI, and the OIDC token
# exchange happens there — npm 10.x (bundled with Node 22) predates
# trusted publishing and dies with ENEEDAUTH.
Expand All @@ -90,17 +141,53 @@ jobs:
- name: Publish to npm (OIDC trusted publishing + provenance)
run: npx -y pnpm@^11.1.3 publish -r --access public --no-git-checks

# `pnpm publish -r` skips already-published versions without failing, so
# assert the tarballs actually landed before declaring the release done.
- name: Verify packages exist on the registry
run: |
VERSION="${GITHUB_REF_NAME#v}"
for PKG in @ai-react-markdown/core @ai-react-markdown/mantine; do
for i in 1 2 3 4 5; do
if npm view "${PKG}@${VERSION}" version >/dev/null 2>&1; then
echo "${PKG}@${VERSION} is live"
continue 2
fi
if [ "$i" -lt 5 ]; then
echo "waiting for ${PKG}@${VERSION} to appear (attempt ${i}/5)…"
sleep 15
fi
done
echo "::error::${PKG}@${VERSION} not found on the registry after publish"
exit 1
done

# Notes come from the version's section in docs/release-highlights.md —
# the hand-written changelog — falling back to GitHub's auto-generated
# notes if no section exists for this version.
#
# Format contract: headings must be `### x.y.z <anything>` (version
# followed by a space). A version may span MULTIPLE `### x.y.z …`
# sections (e.g. 1.3.0 has two); extraction keeps every same-version
# section (printing the follow-up headings as separators) and stops at
# the first heading for a different version.
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
# Idempotent for re-run recovery: `gh release create` 422s on an
# existing release, which would re-redden a re-run whose first
# attempt already created it.
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME already exists — re-run recovery; skipping creation"
exit 0
fi
VERSION="${GITHUB_REF_NAME#v}"
awk -v ver="$VERSION" '
index($0, "### " ver " ") == 1 { found = 1; next }
found && /^### / { exit }
index($0, "### " ver " ") == 1 {
if (!found) { found = 1; next }
print; next
}
found && (/^### / || /^## /) { exit }
found { print }
' docs/release-highlights.md > /tmp/release-notes.md
if [ -s /tmp/release-notes.md ]; then
Expand Down
33 changes: 33 additions & 0 deletions .storybook/ThemedDocsContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useEffect, useState } from 'react';
import { DocsContainer } from '@storybook/addon-docs/blocks';
import { themes } from 'storybook/theming';
import { addons } from 'storybook/preview-api';
import { GLOBALS_UPDATED } from 'storybook/internal/core-events';
import { getUserPreferredColorTheme } from './utils/sb-theme';

/**
* A Docs container that themes the autodocs / MDX **page chrome** (background,
* prose, doc blocks) to match the toolbar Theme switch.
*
* `addon-docs` themes the Docs page from `parameters.docs.theme`, which is
* static and defaults to the light theme — so the custom `theme` global drives
* the embedded story previews and the manager UI, but the Docs page itself
* stays light. This container closes that gap: it seeds from the persisted
* preference and then subscribes to `GLOBALS_UPDATED` on the preview channel so
* a live toggle re-themes the page immediately.
*/
export function ThemedDocsContainer(props: React.ComponentProps<typeof DocsContainer>) {
const [isDark, setIsDark] = useState(() => getUserPreferredColorTheme() === 'dark');

useEffect(() => {
const channel = addons.getChannel();
const handler = (event: { globals?: Record<string, unknown> }) => {
const theme = event?.globals?.theme;
if (typeof theme === 'string') setIsDark(theme === 'dark');
};
channel.on(GLOBALS_UPDATED, handler);
return () => channel.off(GLOBALS_UPDATED, handler);
}, []);

return <DocsContainer {...props} theme={isDark ? themes.dark : themes.light} />;
}
15 changes: 0 additions & 15 deletions .storybook/decorators/withReactScan.tsx

This file was deleted.

7 changes: 1 addition & 6 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
stories: [
'../packages/*/src/**/*.mdx',
'../packages/*/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../packages/*/stories/**/*.mdx',
'../packages/*/stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
stories: ['../packages/*/stories/**/*.mdx', '../packages/*/stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-vitest',
Expand Down
49 changes: 21 additions & 28 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import type { Preview } from '@storybook/react-vite';
import { setOptions, start } from 'react-scan';
import { getUserPreferredColorTheme } from './utils/sb-theme';
import { withReactScan } from './decorators/withReactScan';

// `scan()` short-circuits when running inside an iframe (Storybook preview is one)
// and also bails when called with both `enabled: false` and `showToolbar: false`.
// Calling `start()` directly bypasses both guards so the renderer hook is armed
// from the very first commit, regardless of toggle state.
//
// NOTE: outlines only render in Storybook's *standalone* iframe view
// (http://localhost:6006/iframe.html?id=...). In the default canvas view the
// nested manager→preview iframe layout breaks react-scan's overlay positioning.
// See https://github.com/aidenybai/react-scan/issues/419 — open as of 2025-12.
setOptions({ enabled: false, showToolbar: false, dangerouslyForceRunInProduction: true });
start();
import { ThemedDocsContainer } from './ThemedDocsContainer';

const preview: Preview = {
parameters: {
options: {
// Curate the sidebar: Introduction first, then the two packages (Core,
// Mantine) as top-level branches, each ordered capability-first via the
// nested arrays. Anything not listed falls through to the end, A→Z.
storySort: {
order: [
'Introduction',
'Core',
['AIMarkdown', 'Features', 'Theming', 'Configuration', 'Extending', 'Robustness', 'Coordination'],
'Mantine',
['MantineAIMarkdown', 'Features', 'Configuration'],
],
},
},
docs: {
// Theme the Docs page chrome (MDX / autodocs) to follow the toolbar Theme
// switch — `addon-docs`'s static `docs.theme` otherwise keeps it light.
container: ThemedDocsContainer,
},

controls: {
matchers: {
color: /(background|color)$/i,
Expand All @@ -33,7 +40,6 @@ const preview: Preview = {
},
initialGlobals: {
theme: getUserPreferredColorTheme(),
reactScan: 'off',
},
globalTypes: {
theme: {
Expand All @@ -48,20 +54,7 @@ const preview: Preview = {
dynamicTitle: true,
},
},
reactScan: {
description: 'Toggle react-scan render visualisation (outlines render only in standalone iframe view)',
toolbar: {
title: 'React Scan',
icon: 'lightning',
items: [
{ value: 'off', icon: 'eyeclose', title: 'Off' },
{ value: 'on', icon: 'eye', title: 'On (use Open Canvas in New Tab)' },
],
dynamicTitle: true,
},
},
},
decorators: [withReactScan],
};

export default preview;
Loading
Loading