diff --git a/AGENTS.md b/AGENTS.md index 8572f53..d5cd07f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,11 @@ framework-specific values such as its Tailwind stylesheet path. - `templates/astro/`: standalone Astro template and Astro-specific runtime, build, and Cloudflare static-assets configuration. +- `templates/ripple-ts/`: standalone Ripple TS small-app workspace with a + source-first reusable component package. +- `templates/ripple-ts-browser-extension/`: standalone Manifest V3 extension + template with Ripple UI, WXT entrypoints, typed messaging, and a source-first + reusable component package. - `templates/tanstack-start/`: standalone TanStack Start template and its Cloudflare Workers SSR configuration. - `shared/agent/`: agent instructions materialized into every template. @@ -128,8 +133,8 @@ bun run build ``` After changing a shared source, run `bun run shared:sync` before validation. -`bun run check` covers root checks, shared drift, and both template checks; -`bun run build` builds both templates. +`bun run check` covers root checks, shared drift, and every template check; +`bun run build` builds every template. For a framework-local investigation, run scripts from that template directory or through `bun run --cwd templates/ + + diff --git a/templates/ripple-ts-browser-extension/entrypoints/sidepanel/main.tsrx b/templates/ripple-ts-browser-extension/entrypoints/sidepanel/main.tsrx new file mode 100644 index 0000000..0dba536 --- /dev/null +++ b/templates/ripple-ts-browser-extension/entrypoints/sidepanel/main.tsrx @@ -0,0 +1,16 @@ +import { mount } from 'ripple' + +import { SidePanelApp } from './side-panel-app.tsrx' +import './style.css' + +const target = document.querySelector('#app') + +if (!target) { + throw new Error('Side panel mount target was not found') +} + +const dispose = mount(SidePanelApp, { target }) + +if (import.meta.hot) { + import.meta.hot.dispose(dispose) +} diff --git a/templates/ripple-ts-browser-extension/entrypoints/sidepanel/side-panel-app.tsrx b/templates/ripple-ts-browser-extension/entrypoints/sidepanel/side-panel-app.tsrx new file mode 100644 index 0000000..8285ce6 --- /dev/null +++ b/templates/ripple-ts-browser-extension/entrypoints/sidepanel/side-panel-app.tsrx @@ -0,0 +1,46 @@ +import { CounterPanel } from '@app/ui' +import { effect, track } from 'ripple' + +import { + sendCounterCommand, + watchCounter, +} from '../../src/extension/client' + +export function SidePanelApp() @{ + let &[count] = track(0) + let &[pending] = track(true) + + async function run(type: 'counter:get' | 'counter:increment' | 'counter:reset') { + pending = true + + try { + const state = await sendCounterCommand({ type }) + count = state.count + } finally { + pending = false + } + } + + effect(() => { + const unwatch = watchCounter((nextCount) => { + count = nextCount + }) + void run('counter:get') + return unwatch + }) + +
+ void run('counter:increment')} + onReset={() => void run('counter:reset')} + /> +

+ Open example.com to exercise + the isolated content-script UI. +

+
+} diff --git a/templates/ripple-ts-browser-extension/entrypoints/sidepanel/style.css b/templates/ripple-ts-browser-extension/entrypoints/sidepanel/style.css new file mode 100644 index 0000000..abc8a19 --- /dev/null +++ b/templates/ripple-ts-browser-extension/entrypoints/sidepanel/style.css @@ -0,0 +1,40 @@ +:root { + color: #eef0f8; + background: #0c0f16; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 280px; + margin: 0; +} + +main { + display: grid; + gap: 16px; + padding: 16px; +} + +.hint { + margin: 0; + color: #9299ac; + font-size: 12px; + line-height: 1.5; +} + +a { + color: #bcb3ff; + text-underline-offset: 2px; +} diff --git a/templates/ripple-ts-browser-extension/package.json b/templates/ripple-ts-browser-extension/package.json new file mode 100644 index 0000000..def747f --- /dev/null +++ b/templates/ripple-ts-browser-extension/package.json @@ -0,0 +1,49 @@ +{ + "name": "template-ripple-ts-browser-extension", + "version": "0.0.0", + "private": true, + "workspaces": [ + "packages/*" + ], + "type": "module", + "scripts": { + "dev": "wxt --browser chrome", + "postinstall": "wxt prepare", + "types:prepare": "wxt prepare", + "build": "wxt build --browser chrome && bun scripts/verify-build.ts", + "zip": "wxt zip --browser chrome && bun scripts/verify-build.ts", + "check": "vp check && tsrx-tsc --noEmit && bun run test && bun run pack:check", + "test": "vp test --run", + "test:e2e": "node tests/e2e/system-chrome.ts", + "chrome": "bun scripts/system-chrome.ts .output/chrome-mv3", + "chrome:dev": "bun scripts/system-chrome.ts .output/chrome-mv3-dev", + "pack:check": "bun run --cwd packages/ui pack:check" + }, + "dependencies": { + "@app/ui": "workspace:*", + "ripple": "^0.3.110" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@ripple-ts/language-server": "^0.3.110", + "@ripple-ts/vite-plugin": "^0.3.110", + "@tsrx/typescript-plugin": "^0.3.110", + "@types/bun": "^1.3.14", + "@types/chrome": "^0.2.2", + "@wxt-dev/auto-icons": "^1.1.1", + "typescript": "^5.9.3", + "vite-plus": "^0.2.6", + "vitest": "^4.1.10", + "wxt": "^0.20.27" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "1.3.14", + "onFail": "download" + } + }, + "engines": { + "node": ">=22.18" + } +} diff --git a/templates/ripple-ts-browser-extension/packages/ui/AGENTS.md b/templates/ripple-ts-browser-extension/packages/ui/AGENTS.md new file mode 100644 index 0000000..0d68bfd --- /dev/null +++ b/templates/ripple-ts-browser-extension/packages/ui/AGENTS.md @@ -0,0 +1,12 @@ +# Reusable Ripple components + +- Export ordinary named `.tsrx` components through `src/index.tsrx`. +- Keep public props explicit and independent of Chrome, WXT, storage, and + message APIs. +- The package is source-first: the consuming Ripple Vite graph owns TSRX + compilation. Do not add a parallel precompiled runtime without a real + consumer requirement. +- Keep extension lifecycle, permissions, application state, and cross-context + communication outside this package. +- Run the root check and build plus `bun run pack:check` after changing the + public package boundary. diff --git a/templates/ripple-ts-browser-extension/packages/ui/CLAUDE.md b/templates/ripple-ts-browser-extension/packages/ui/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/templates/ripple-ts-browser-extension/packages/ui/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/templates/ripple-ts-browser-extension/packages/ui/package.json b/templates/ripple-ts-browser-extension/packages/ui/package.json new file mode 100644 index 0000000..d39e1e1 --- /dev/null +++ b/templates/ripple-ts-browser-extension/packages/ui/package.json @@ -0,0 +1,20 @@ +{ + "name": "@app/ui", + "version": "0.0.0", + "files": [ + "src" + ], + "type": "module", + "exports": { + ".": "./src/index.tsrx" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "pack:check": "bun pm pack --dry-run" + }, + "peerDependencies": { + "ripple": "^0.3.110" + } +} diff --git a/templates/ripple-ts-browser-extension/packages/ui/src/counter-panel.tsrx b/templates/ripple-ts-browser-extension/packages/ui/src/counter-panel.tsrx new file mode 100644 index 0000000..e5cf4ed --- /dev/null +++ b/templates/ripple-ts-browser-extension/packages/ui/src/counter-panel.tsrx @@ -0,0 +1,168 @@ +export type CounterPanelProps = Readonly<{ + count: number + description: string + disabled?: boolean + onIncrement: () => void + onReset?: () => void + title: string +}> + +export function CounterPanel(&{ + count, + description, + disabled = false, + onIncrement, + onReset, + title, +}: CounterPanelProps) @{ +
+
+

Ripple TS ยท MV3

+

{title}

+

{description}

+
+ +
+ Persistent count + {count} +
+ +
+ + @if (onReset) { + + } +
+ + +
+} diff --git a/templates/ripple-ts-browser-extension/packages/ui/src/index.tsrx b/templates/ripple-ts-browser-extension/packages/ui/src/index.tsrx new file mode 100644 index 0000000..ddbe3a0 --- /dev/null +++ b/templates/ripple-ts-browser-extension/packages/ui/src/index.tsrx @@ -0,0 +1,2 @@ +export { CounterPanel } from './counter-panel.tsrx' +export type { CounterPanelProps } from './counter-panel.tsrx' diff --git a/templates/ripple-ts-browser-extension/scripts/system-chrome.ts b/templates/ripple-ts-browser-extension/scripts/system-chrome.ts new file mode 100644 index 0000000..e3d229c --- /dev/null +++ b/templates/ripple-ts-browser-extension/scripts/system-chrome.ts @@ -0,0 +1,176 @@ +import { mkdir } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' + +const requestedOutput = process.argv[2] ?? '.output/chrome-mv3' +const cdpPort = process.env.CHROME_CDP_PORT ?? '9222' +const outputDirectory = isAbsolute(requestedOutput) + ? requestedOutput + : resolve(process.cwd(), requestedOutput) +const cdpEndpoint = `http://127.0.0.1:${cdpPort}` +const manifest = Bun.file(resolve(outputDirectory, 'manifest.json')) + +if (!(await manifest.exists())) { + throw new Error( + `Build output was not found at ${outputDirectory}. Run the matching build or dev command first.`, + ) +} + +const profileDirectory = resolve(process.cwd(), '.chrome-profile') +await mkdir(profileDirectory, { recursive: true }) + +const chromeBinary = await findSystemChrome() + +console.log(`Opening system Google Chrome with profile: ${profileDirectory}`) + +const chrome = Bun.spawn({ + cmd: [ + chromeBinary, + `--remote-debugging-port=${cdpPort}`, + '--remote-allow-origins=*', + `--user-data-dir=${profileDirectory}`, + '--no-first-run', + '--no-default-browser-check', + 'https://example.com', + ], + stdout: 'inherit', + stderr: 'inherit', +}) + +try { + const webSocketDebuggerUrl = await waitForCdp(cdpEndpoint) + // Chrome blocks command-line extension sideloading. Its CDP API preserves + // system-Chrome coverage without downloading a separate browser binary. + // https://chromedevtools.github.io/devtools-protocol/tot/Extensions/#method-loadUnpacked + const extensionId = await loadUnpacked(webSocketDebuggerUrl, outputDirectory) + console.log( + `Loaded unpacked extension ${extensionId} from: ${outputDirectory}`, + ) + console.log(`Chrome DevTools endpoint: ${cdpEndpoint}`) +} catch (error) { + chrome.kill() + throw error +} + +process.exitCode = await chrome.exited + +async function waitForCdp(endpoint: string): Promise { + const deadline = Date.now() + 10_000 + + while (Date.now() < deadline) { + try { + const response = await fetch(`${endpoint}/json/version`) + + if (response.ok) { + const metadata = (await response.json()) as { + webSocketDebuggerUrl?: string + } + + if (metadata.webSocketDebuggerUrl) { + return metadata.webSocketDebuggerUrl + } + } + } catch { + // Chrome exposes CDP shortly after the application process starts. + } + + await new Promise((resolveWait) => setTimeout(resolveWait, 50)) + } + + throw new Error( + `System Chrome did not expose DevTools at ${endpoint}. Set CHROME_CDP_PORT to an unused port and retry.`, + ) +} + +async function loadUnpacked( + webSocketDebuggerUrl: string, + path: string, +): Promise { + return await new Promise((resolveExtension, rejectExtension) => { + const socket = new WebSocket(webSocketDebuggerUrl) + const timeout = setTimeout(() => { + socket.close() + rejectExtension( + new Error('Timed out while loading the unpacked extension through CDP'), + ) + }, 10_000) + + function reject(error: unknown) { + clearTimeout(timeout) + socket.close() + rejectExtension(error) + } + + socket.addEventListener( + 'open', + () => { + socket.send( + JSON.stringify({ + id: 1, + method: 'Extensions.loadUnpacked', + params: { path }, + }), + ) + }, + { once: true }, + ) + socket.addEventListener( + 'error', + () => + reject(new Error('Could not connect to the Chrome DevTools socket')), + { once: true }, + ) + socket.addEventListener('message', (event) => { + const response = JSON.parse(String(event.data)) as { + error?: { message?: string } + id?: number + result?: { id?: string } + } + + if (response.id !== 1) { + return + } + + if (response.error) { + reject( + new Error( + response.error.message ?? 'Chrome rejected Extensions.loadUnpacked', + ), + ) + return + } + + if (!response.result?.id) { + reject(new Error('Chrome did not return an extension ID')) + return + } + + clearTimeout(timeout) + socket.close() + resolveExtension(response.result.id) + }) + }) +} + +async function findSystemChrome(): Promise { + const candidates = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + process.env.PROGRAMFILES + ? resolve( + process.env.PROGRAMFILES, + 'Google/Chrome/Application/chrome.exe', + ) + : '', + ].filter(Boolean) + + for (const candidate of candidates) { + if (await Bun.file(candidate).exists()) { + return candidate + } + } + + throw new Error( + 'System Google Chrome was not found. This template does not download a fallback browser.', + ) +} diff --git a/templates/ripple-ts-browser-extension/scripts/verify-build.ts b/templates/ripple-ts-browser-extension/scripts/verify-build.ts new file mode 100644 index 0000000..42606c1 --- /dev/null +++ b/templates/ripple-ts-browser-extension/scripts/verify-build.ts @@ -0,0 +1,62 @@ +import { resolve } from 'node:path' + +type Manifest = { + action?: unknown + background?: { + service_worker?: string + } + content_scripts?: Array<{ + matches?: string[] + }> + host_permissions?: string[] + manifest_version?: number + permissions?: string[] + side_panel?: { + default_path?: string + } +} + +const outputDirectory = resolve(import.meta.dir, '../.output/chrome-mv3') +const manifestPath = resolve(outputDirectory, 'manifest.json') +const manifest = (await Bun.file(manifestPath).json()) as Manifest + +assert(manifest.manifest_version === 3, 'Expected a Manifest V3 build') +assert( + manifest.background?.service_worker === 'background.js', + 'Expected an MV3 background service worker', +) +assert( + manifest.side_panel?.default_path === 'sidepanel.html', + 'Expected the side panel entrypoint', +) +assert( + manifest.content_scripts?.some((script) => + script.matches?.includes('https://example.com/*'), + ) ?? false, + 'Expected the example.com content script', +) +assert( + sameMembers(manifest.permissions ?? [], ['sidePanel', 'storage']), + 'Expected only storage and sidePanel permissions', +) +assert( + (manifest.host_permissions?.length ?? 0) === 0, + 'Host permissions must stay empty until a feature requires them', +) + +console.log( + 'Verified MV3 manifest, entrypoints, and least-privilege permissions', +) + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) { + throw new Error(message) + } +} + +function sameMembers(actual: string[], expected: string[]): boolean { + return ( + actual.length === expected.length && + actual.every((value) => expected.includes(value)) + ) +} diff --git a/templates/ripple-ts-browser-extension/src/extension/client.ts b/templates/ripple-ts-browser-extension/src/extension/client.ts new file mode 100644 index 0000000..4e77476 --- /dev/null +++ b/templates/ripple-ts-browser-extension/src/extension/client.ts @@ -0,0 +1,30 @@ +import { browser } from '#imports' + +import type { CounterCommand, CounterState } from './protocol' + +import { counterItem } from './counter-store' + +export async function sendCounterCommand( + command: CounterCommand, +): Promise { + const response: unknown = await browser.runtime.sendMessage(command) + + if ( + typeof response !== 'object' || + response === null || + !('type' in response) || + response.type !== 'counter:state' || + !('count' in response) || + typeof response.count !== 'number' + ) { + throw new Error('Background returned an invalid counter response') + } + + return response as CounterState +} + +export function watchCounter(listener: (count: number) => void): () => void { + return counterItem.watch((count) => { + listener(count) + }) +} diff --git a/templates/ripple-ts-browser-extension/src/extension/counter-service.test.ts b/templates/ripple-ts-browser-extension/src/extension/counter-service.test.ts new file mode 100644 index 0000000..36f45d1 --- /dev/null +++ b/templates/ripple-ts-browser-extension/src/extension/counter-service.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { fakeBrowser } from 'wxt/testing/fake-browser' + +import { handleCounterCommand } from './counter-service' +import { isCounterCommand } from './protocol' + +describe('counter service', () => { + beforeEach(() => { + fakeBrowser.reset() + }) + + it('persists state outside the service worker lifecycle', async () => { + expect(await handleCounterCommand({ type: 'counter:get' })).toEqual({ + count: 0, + type: 'counter:state', + }) + expect(await handleCounterCommand({ type: 'counter:increment' })).toEqual({ + count: 1, + type: 'counter:state', + }) + expect(await handleCounterCommand({ type: 'counter:get' })).toEqual({ + count: 1, + type: 'counter:state', + }) + }) + + it('resets persisted state', async () => { + await handleCounterCommand({ type: 'counter:increment' }) + + expect(await handleCounterCommand({ type: 'counter:reset' })).toEqual({ + count: 0, + type: 'counter:state', + }) + }) + + it('serializes concurrent increments', async () => { + const states = await Promise.all([ + handleCounterCommand({ type: 'counter:increment' }), + handleCounterCommand({ type: 'counter:increment' }), + ]) + + expect(states.map((state) => state.count)).toEqual([1, 2]) + expect(await handleCounterCommand({ type: 'counter:get' })).toEqual({ + count: 2, + type: 'counter:state', + }) + }) + + it('rejects messages outside the public protocol', () => { + expect(isCounterCommand({ type: 'counter:increment' })).toBe(true) + expect(isCounterCommand({ type: 'counter:delete' })).toBe(false) + expect(isCounterCommand(null)).toBe(false) + }) +}) diff --git a/templates/ripple-ts-browser-extension/src/extension/counter-service.ts b/templates/ripple-ts-browser-extension/src/extension/counter-service.ts new file mode 100644 index 0000000..2143d94 --- /dev/null +++ b/templates/ripple-ts-browser-extension/src/extension/counter-service.ts @@ -0,0 +1,41 @@ +import type { CounterCommand, CounterState } from './protocol' + +import { getCounter, incrementCounter, resetCounter } from './counter-store' + +let operation = Promise.resolve() + +export function handleCounterCommand( + command: CounterCommand, +): Promise { + // storage.local has no atomic increment primitive. Serialize commands inside + // one worker lifetime so concurrent UI events cannot overwrite each other. + const result = operation.then(() => executeCounterCommand(command)) + operation = result.then( + () => undefined, + () => undefined, + ) + return result +} + +async function executeCounterCommand( + command: CounterCommand, +): Promise { + let count: number + + switch (command.type) { + case 'counter:get': + count = await getCounter() + break + case 'counter:increment': + count = await incrementCounter() + break + case 'counter:reset': + count = await resetCounter() + break + } + + return { + count, + type: 'counter:state', + } +} diff --git a/templates/ripple-ts-browser-extension/src/extension/counter-store.ts b/templates/ripple-ts-browser-extension/src/extension/counter-store.ts new file mode 100644 index 0000000..ee158e6 --- /dev/null +++ b/templates/ripple-ts-browser-extension/src/extension/counter-store.ts @@ -0,0 +1,21 @@ +import { storage } from '#imports' + +export const counterItem = storage.defineItem('local:counter', { + fallback: 0, + version: 1, +}) + +export async function getCounter(): Promise { + return counterItem.getValue() +} + +export async function incrementCounter(): Promise { + const count = (await counterItem.getValue()) + 1 + await counterItem.setValue(count) + return count +} + +export async function resetCounter(): Promise { + await counterItem.setValue(0) + return 0 +} diff --git a/templates/ripple-ts-browser-extension/src/extension/protocol.ts b/templates/ripple-ts-browser-extension/src/extension/protocol.ts new file mode 100644 index 0000000..355e046 --- /dev/null +++ b/templates/ripple-ts-browser-extension/src/extension/protocol.ts @@ -0,0 +1,22 @@ +export type CounterCommand = + | Readonly<{ type: 'counter:get' }> + | Readonly<{ type: 'counter:increment' }> + | Readonly<{ type: 'counter:reset' }> + +export type CounterState = Readonly<{ + count: number + type: 'counter:state' +}> + +export function isCounterCommand(value: unknown): value is CounterCommand { + if (typeof value !== 'object' || value === null || !('type' in value)) { + return false + } + + const { type } = value + return ( + type === 'counter:get' || + type === 'counter:increment' || + type === 'counter:reset' + ) +} diff --git a/templates/ripple-ts-browser-extension/src/tsrx.d.ts b/templates/ripple-ts-browser-extension/src/tsrx.d.ts new file mode 100644 index 0000000..cdfd206 --- /dev/null +++ b/templates/ripple-ts-browser-extension/src/tsrx.d.ts @@ -0,0 +1,5 @@ +declare module '*.tsrx' { + import type { Component } from 'ripple' + + export const ContentWidget: Component +} diff --git a/templates/ripple-ts-browser-extension/tests/e2e/system-chrome.ts b/templates/ripple-ts-browser-extension/tests/e2e/system-chrome.ts new file mode 100644 index 0000000..1b0a207 --- /dev/null +++ b/templates/ripple-ts-browser-extension/tests/e2e/system-chrome.ts @@ -0,0 +1,89 @@ +import { + chromium, + type Browser, + type BrowserContext, + type Locator, +} from '@playwright/test' + +const endpoint = + process.env.CHROME_CDP_ENDPOINT ?? + `http://127.0.0.1:${process.env.CHROME_CDP_PORT ?? '9222'}` +let browser: Browser | undefined + +try { + browser = await chromium.connectOverCDP(endpoint) +} catch (cause) { + throw new Error( + `System Chrome is not available at ${endpoint}. Run \`bun run build\`, then \`bun run chrome\`.`, + { cause }, + ) +} + +try { + const context = getContext(browser) + const page = await context.newPage() + await page.goto('https://example.com') + + const counter = page.getByTestId('counter-value') + await counter.waitFor() + await page.getByTestId('reset').click() + await expectText(counter, '0') + await page.getByTestId('increment').click() + await expectText(counter, '1') + + const extensionId = await findExtensionId(context) + const sidePanel = await context.newPage() + await sidePanel.goto(`chrome-extension://${extensionId}/sidepanel.html`) + await expectText(sidePanel.getByTestId('counter-value'), '1') + + await sidePanel.getByTestId('reset').click() + await expectText(counter, '0') + console.log( + 'Verified content UI, side panel, typed messaging, and persisted storage in system Chrome', + ) +} finally { + await browser.close() +} + +function getContext(connectedBrowser: Browser): BrowserContext { + const [context] = connectedBrowser.contexts() + + if (!context) { + throw new Error('System Chrome did not expose a browser context') + } + + return context +} + +async function findExtensionId(context: BrowserContext): Promise { + const worker = + context + .serviceWorkers() + .find((candidate) => candidate.url().startsWith('chrome-extension://')) ?? + (await context.waitForEvent('serviceworker', { + predicate: (candidate) => + candidate.url().startsWith('chrome-extension://'), + timeout: 10_000, + })) + const [, , extensionId] = worker.url().split('/') + + if (!extensionId) { + throw new Error('Could not derive the extension ID from its service worker') + } + + return extensionId +} + +async function expectText(locator: Locator, expected: string): Promise { + await locator.waitFor() + const deadline = Date.now() + 5_000 + + while (Date.now() < deadline) { + if ((await locator.textContent())?.trim() === expected) { + return + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + throw new Error(`Expected visible counter text to become ${expected}`) +} diff --git a/templates/ripple-ts-browser-extension/tooling/oxfmt.ts b/templates/ripple-ts-browser-extension/tooling/oxfmt.ts new file mode 100644 index 0000000..cdda103 --- /dev/null +++ b/templates/ripple-ts-browser-extension/tooling/oxfmt.ts @@ -0,0 +1,31 @@ +import type { OxfmtConfig } from 'vite-plus/fmt' + +// Vite+ expects Oxfmt options in vite.config.ts, so this module keeps the +// organization baseline reusable without shipping a competing .oxfmtrc file. +// Sources: https://viteplus.dev/guide/fmt and https://oxc.rs/docs/guide/usage/formatter +export const oxfmtConfig = { + printWidth: 80, + singleQuote: true, + semi: false, + useTabs: true, + jsdoc: true, + sortImports: { + groups: [ + ['type-builtin', 'type-external'], + ['builtin', 'external'], + ['type-internal', 'type-subpath', 'internal', 'subpath'], + ['type-parent', 'type-sibling', 'type-index'], + ['parent', 'sibling', 'index'], + ['side_effect', 'side_effect_style', 'style'], + ], + }, + ignorePatterns: [ + 'node_modules', + 'externals', + 'dist', + '*.lock', + '**/*.gen.ts', + '**/worker-configuration.d.ts', + 'convex/_generated', + ], +} satisfies OxfmtConfig diff --git a/templates/ripple-ts-browser-extension/tsconfig.json b/templates/ripple-ts-browser-extension/tsconfig.json new file mode 100644 index 0000000..1b27098 --- /dev/null +++ b/templates/ripple-ts-browser-extension/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowArbitraryExtensions": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "preserve", + "jsxImportSource": "ripple", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "strict": true, + "types": ["bun", "chrome"], + "plugins": [ + { + "name": "@tsrx/typescript-plugin" + } + ] + }, + "include": [ + ".wxt/wxt.d.ts", + "entrypoints", + "packages", + "scripts", + "src", + "tests", + "vite.config.ts", + "vitest.config.ts", + "wxt.config.ts" + ] +} diff --git a/templates/ripple-ts-browser-extension/vite.config.ts b/templates/ripple-ts-browser-extension/vite.config.ts new file mode 100644 index 0000000..f2626c4 --- /dev/null +++ b/templates/ripple-ts-browser-extension/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite-plus' + +import { oxfmtConfig } from './tooling/oxfmt' + +const config = defineConfig({ + staged: { + '*': 'vp check --fix', + }, + fmt: { + ...oxfmtConfig, + // TSRX owns its parser; tsrx-tsc is the authoritative source check. + ignorePatterns: [...oxfmtConfig.ignorePatterns, '**/*.tsrx'], + }, + lint: { + ignorePatterns: ['.output/**', '.wxt/**', '**/*.tsrx'], + options: { + typeAware: true, + typeCheck: true, + }, + }, +}) + +export default config diff --git a/templates/ripple-ts-browser-extension/vitest.config.ts b/templates/ripple-ts-browser-extension/vitest.config.ts new file mode 100644 index 0000000..c1eeaaa --- /dev/null +++ b/templates/ripple-ts-browser-extension/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' +import { WxtVitest } from 'wxt/testing/vitest-plugin' + +export default defineConfig({ + plugins: [WxtVitest()], + test: { + clearMocks: true, + }, +}) diff --git a/templates/ripple-ts-browser-extension/wxt.config.ts b/templates/ripple-ts-browser-extension/wxt.config.ts new file mode 100644 index 0000000..97a71c2 --- /dev/null +++ b/templates/ripple-ts-browser-extension/wxt.config.ts @@ -0,0 +1,27 @@ +import { ripple } from '@ripple-ts/vite-plugin' +import { defineConfig } from 'wxt' + +export default defineConfig({ + modules: ['@wxt-dev/auto-icons'], + autoIcons: { + baseIconPath: 'assets/icon.svg', + }, + manifest: { + name: 'Ripple Extension', + description: + 'A production-shaped MV3 extension template built with Ripple TS and WXT.', + permissions: ['storage'], + action: { + default_title: 'Open Ripple Extension', + }, + }, + // Branded Chrome removed command-line extension sideloading. WXT still owns + // compilation and HMR; the repository-owned Chrome launcher opens a stable + // system profile where developers load the unpacked output once. + webExt: { + disabled: true, + }, + vite: () => ({ + plugins: [ripple()], + }), +}) diff --git a/templates/ripple-ts/AGENTS.md b/templates/ripple-ts/AGENTS.md index 0b22a42..081cfaf 100644 --- a/templates/ripple-ts/AGENTS.md +++ b/templates/ripple-ts/AGENTS.md @@ -26,6 +26,9 @@ to the user. language server and TSRX TypeScript plugin both require that peer range. Do not upgrade TypeScript until both packages declare support for the newer line and `bun run check && bun run build` pass. +- Do not use ordinary JavaScript destructuring in `.tsrx` files. Reactive state + uses TSRX-specific destructuring syntax; learn the correct form from the + indexes below before inspecting or editing code. - Before inspecting or editing application or component code, read https://ripple-ts.com/llms.txt in full. - When changing TSRX syntax, compiler or language tooling, or behavior shared