diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ccdd085..3280365 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,20 +21,22 @@ jobs: - name: Set up FVM + Flutter + Dart uses: ./.github/actions/fvm-flutter-dart-setup - - name: Activate Melos - run: dart pub global activate melos + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" - name: Bootstrap - run: melos bootstrap + run: node tool/flutter-mono.mjs exec -- flutter pub get - name: Check formatting - run: melos run format.ci + run: node tool/flutter-mono.mjs run format.ci - name: Analyze - run: melos run analyze.ci + run: node tool/flutter-mono.mjs run analyze.ci - name: Test - run: melos run test.ci + run: node tool/flutter-mono.mjs run test typescript: name: TypeScript diff --git a/README.md b/README.md index 9779c37..0d229c6 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ flutter_multi_view_web_embedding/ ## Getting started -**Prerequisites:** [Flutter](https://docs.flutter.dev/get-started/install) (3.27.4 — managed via [FVM](https://fvm.app)), [Melos](https://melos.codes), Node.js. +**Prerequisites:** [Flutter](https://docs.flutter.dev/get-started/install) (managed via [FVM](https://fvm.app)), Node.js 18+. ```sh # 1. Install Dart/Flutter package dependencies across all packages and apps -melos bootstrap +node tool/flutter-mono.mjs run bootstrap # 2. Install Node dependencies for the host page cd apps/widgets_preview diff --git a/apps/color_mixer_web_component/melos_color_mixer_web_component.iml b/apps/color_mixer_web_component/melos_color_mixer_web_component.iml deleted file mode 100644 index 26a45e7..0000000 --- a/apps/color_mixer_web_component/melos_color_mixer_web_component.iml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/tap_burst_web_component/melos_tap_burst_web_component.iml b/apps/tap_burst_web_component/melos_tap_burst_web_component.iml deleted file mode 100644 index 26a45e7..0000000 --- a/apps/tap_burst_web_component/melos_tap_burst_web_component.iml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_mono.yaml b/flutter_mono.yaml new file mode 100644 index 0000000..1609bf2 --- /dev/null +++ b/flutter_mono.yaml @@ -0,0 +1,40 @@ +name: flutter_multi_view_web_embedding + +# Glob patterns for directories to scan for pubspec.yaml files. +packages: + - packages/** + - apps/** + +# Patterns to exclude from discovery (prefix-based). +ignore: [] + +scripts: + bootstrap: + description: Get dependencies for all packages + run: flutter pub get + + clean: + description: Deep clean-up (gitignored files only; preserves .fvm and .dart_tool) + run: git clean -dfX -e ".fvm" -e ".dart_tool" . + + format: + description: Format Dart code + run: dart format . + + format.ci: + description: Check Dart code formatting + run: dart format --set-exit-if-changed . + + analyze: + description: Analyze Dart code + run: dart analyze . + + analyze.ci: + description: Analyze Dart code and fail on any issue + run: dart analyze --fatal-infos --fatal-warnings . + + test: + description: Run Flutter tests + run: flutter test + filter: + dirExists: test diff --git a/melos.yaml b/melos.yaml deleted file mode 100644 index 5fb5fb0..0000000 --- a/melos.yaml +++ /dev/null @@ -1,66 +0,0 @@ -name: flutter_multi_view_web_embedding - -packages: - - packages/** - - apps/** - -sdkPath: .fvm/flutter_sdk - -command: - bootstrap: - environment: - sdk: ">=3.6.0 <4.0.0" - flutter: ">=3.6.0 <4.0.0" - dev_dependencies: - very_good_analysis: ^7.0.0 - clean: - hooks: - pre: - run: melos run .clean - -scripts: - # Internal: deep clean via git - .clean: - private: true - description: Deep clean-up - run: > - git clean - -dfX - MELOS_PACKAGE_PATH - packageFilters: {} - - format: - description: Format codebase - run: > - dart format . - format.ci: - description: Format codebase and check for formatting errors - run: > - dart format --set-exit-if-changed . - - analyze: - description: Analyze codebase - run: > - dart analyze . - analyze.ci: - description: Analyze codebase and check for analysis errors - run: > - dart analyze --fatal-infos --fatal-warnings . - - test: - description: Run tests - run: > - flutter test - exec: - concurrency: 1 - packageFilters: - dirExists: - - test - test.all: - description: Run tests for all packages - run: > - melos run --no-select test - test.ci: - description: Run tests and fail on any test error - run: > - melos exec -- flutter test diff --git a/melos_flutter_multi_view_web_embedding.iml b/melos_flutter_multi_view_web_embedding.iml deleted file mode 100644 index 9681559..0000000 --- a/melos_flutter_multi_view_web_embedding.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/packages/color_mixer/melos_color_mixer.iml b/packages/color_mixer/melos_color_mixer.iml deleted file mode 100644 index 9fc8ce7..0000000 --- a/packages/color_mixer/melos_color_mixer.iml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/flutter_bootstrap/melos_flutter_bootstrap.iml b/packages/flutter_bootstrap/melos_flutter_bootstrap.iml deleted file mode 100644 index 26a45e7..0000000 --- a/packages/flutter_bootstrap/melos_flutter_bootstrap.iml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/multi_view_app/melos_multi_view_app.iml b/packages/multi_view_app/melos_multi_view_app.iml deleted file mode 100644 index 9fc8ce7..0000000 --- a/packages/multi_view_app/melos_multi_view_app.iml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/tap_burst/melos_tap_burst.iml b/packages/tap_burst/melos_tap_burst.iml deleted file mode 100644 index 9fc8ce7..0000000 --- a/packages/tap_burst/melos_tap_burst.iml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index 7d3dc19..0000000 --- a/pubspec.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: flutter_multi_view_web_embedding - -environment: - sdk: ">=3.6.0 <4.0.0" - -dev_dependencies: - melos: ^6.3.2 diff --git a/tool/build-flutter-packages.mjs b/tool/build-flutter-packages.mjs index c83511d..8840344 100644 --- a/tool/build-flutter-packages.mjs +++ b/tool/build-flutter-packages.mjs @@ -1,292 +1,130 @@ #!/usr/bin/env node /** - * Builds every Flutter package that has a `web/package.json` file, using the - * lower bound of the Flutter SDK constraint in each package's `pubspec.yaml` - * (`environment.flutter`). + * Builds every Flutter package that opts in by placing a `web/package.json` + * alongside its `pubspec.yaml`, then packs each build output as an npm tarball. * - * Verification: pinned versions are checked against `fvm list` before - * building. If a version is missing, the tool exits with an actionable error. - * - * After a successful build, writes `build/web/package.json` by merging: - * - name, description, version → derived from pubspec.yaml - * - all other fields → carried over from web/package.json - * - * A package opts in to this tool by having both `pubspec.yaml` and - * `web/package.json` inside its directory. + * Package discovery and per-package Flutter SDK resolution are fully delegated + * to flutter-mono (tool/flutter-mono.mjs). This script only handles the + * post-build pipeline: merging package metadata, running `npm pack`, and + * renaming the resulting tarball. * * Usage: node tool/build-flutter-packages.mjs */ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); - -// ─── pubspec.yaml parser ──────────────────────────────────────────────────── -// -// Extracts only the fields this tool needs: -// name, description, version (top-level scalars / block scalars) -// environment.flutter (nested scalar) - -function parsePubspec(pubspecPath) { - const lines = readFileSync(pubspecPath, 'utf8').split('\n'); - const result = {}; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trimStart(); - const indent = line.length - trimmed.length; - - // Only process non-empty, non-comment, top-level lines. - if (indent !== 0 || !trimmed || trimmed.startsWith('#')) { - i++; - continue; - } - - const colonIdx = trimmed.indexOf(':'); - if (colonIdx === -1) { i++; continue; } - - const key = trimmed.slice(0, colonIdx).trim(); - const afterColon = trimmed.slice(colonIdx + 1).trim(); - - if (['name', 'description', 'version'].includes(key)) { - // Block scalar (">-", ">", "|-", "|"): collect indented continuation lines. - if (afterColon === '>-' || afterColon === '>' || afterColon === '|-' || afterColon === '|') { - const folded = afterColon.startsWith('>'); - const blockLines = []; - i++; - while (i < lines.length) { - const bl = lines[i]; - // A non-empty line with no leading whitespace means we're back at top-level. - if (bl.trimStart() === bl && bl.trim() !== '') break; - if (bl.trim()) blockLines.push(bl.trim()); - i++; - } - result[key] = folded ? blockLines.join(' ') : blockLines.join('\n'); - continue; - } - // Simple / quoted scalar. - result[key] = afterColon.replace(/^["']|["']$/g, ''); - } else if (key === 'environment') { - // Parse the nested environment mapping. - i++; - while (i < lines.length) { - const el = lines[i]; - const eTrimmed = el.trimStart(); - const eIndent = el.length - eTrimmed.length; - // A non-empty, non-comment line at indent 0 means we left the block. - if (eIndent === 0 && eTrimmed && !eTrimmed.startsWith('#')) break; - if (eTrimmed.startsWith('flutter:')) { - result.flutterConstraint = eTrimmed - .slice('flutter:'.length) - .trim() - .replace(/^["']|["']$/g, ''); - } - i++; - } - continue; - } - - i++; - } - - return result; -} - -// ─── FVM helpers ──────────────────────────────────────────────────────────── +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptDir, '..'); +const flutterMono = join(scriptDir, 'flutter-mono.mjs'); -/** Returns the set of Flutter versions installed locally according to FVM. */ -function getInstalledFvmVersions() { - const result = spawnSync('fvm', ['list'], { encoding: 'utf8' }); - if (result.status !== 0 || result.error) { - console.error('✗ `fvm list` failed — is FVM installed and on PATH?'); - process.exit(1); - } - // Strip ANSI escape codes before parsing. - const plain = result.stdout.replace(/\x1b\[[0-9;]*m/g, ''); - const versions = new Set(); - for (const line of plain.split('\n')) { - // Table data rows are delimited by │; the version is the first cell. - if (!line.includes('│')) continue; - const firstCell = line.split('│')[1]?.trim() ?? ''; - if (/^\d+\.\d+\.\d+$/.test(firstCell)) versions.add(firstCell); - } - return versions; -} - -/** - * Extracts the lower bound version from a Flutter SDK constraint string. - * - * Examples: - * "3.41.1" → "3.41.1" (exact pin is its own lower bound) - * ">=3.6.0 <4.0.0" → "3.6.0" - * ">=3.6.0" → "3.6.0" - * - * Returns null when no lower bound can be determined (absent, malformed, or - * uses an exclusive operator — see extractLowerBound callers for the error). - */ -function extractLowerBound(constraint) { - if (!constraint) return null; - // Exact pin — no operators. - if (/^\d+\.\d+\.\d+$/.test(constraint)) return constraint; - // Inclusive lower bound only: ">=x.y.z ..." → x.y.z. - // Exclusive lower bounds (">x.y.z") are intentionally not matched because - // x.y.z itself would not satisfy the constraint. - const m = constraint.match(/>=\s*(\d+\.\d+\.\d+)/); - return m ? m[1] : null; +function die(msg) { + console.error(`✗ ${msg}`); + process.exit(1); } -/** - * Resolves the path to the `flutter` binary for a given FVM-installed version. - * Returns null if the binary cannot be found on disk. - */ -function resolveFvmFlutterBin(version) { - const fvmHome = process.env.FVM_HOME ?? join(homedir(), 'fvm'); - const candidate = join(fvmHome, 'versions', version, 'bin', 'flutter'); - return existsSync(candidate) ? candidate : null; -} +// ─── Package discovery ─────────────────────────────────────────────────────── -// ─── Package discovery ────────────────────────────────────────────────────── +function discoverWebPackages() { + const result = spawnSync('node', [flutterMono, 'list', '--json'], { + encoding: 'utf8', + cwd: repoRoot, + }); -/** - * Returns all workspace packages that have a `web/` directory, sourced from - * `melos list --dir-exists=web --json` so discovery matches the workspace globs - * in melos.yaml exactly. - */ -function discoverPackages() { - const result = spawnSync('melos', ['list', '--dir-exists=web', '--json'], { encoding: 'utf8', cwd: repoRoot }); if (result.error || result.status !== 0) { - console.error('✗ `melos list --dir-exists=web --json` failed — is Melos installed and on PATH?'); - if (result.error) console.error(` ${result.error.message}`); - process.exit(result.status ?? 1); + if (result.stderr) process.stderr.write(result.stderr); + die('`flutter-mono list` failed — run it directly for details.'); } - let packages; + let all; try { - packages = JSON.parse(result.stdout); + all = JSON.parse(result.stdout); } catch { - console.error('✗ Failed to parse `melos list --dir-exists=web --json` output.'); - process.exit(1); + die('Failed to parse package list from flutter-mono.'); } - return packages.map(({ location }) => ({ - dir: location, - pubspecPath: join(location, 'pubspec.yaml'), - webPkgPath: join(location, 'web', 'package.json'), - })); + return all + .filter(({ location }) => existsSync(join(location, 'web', 'package.json'))) + .map(({ name, description, version, location }) => ({ + name, + description, + version, + dir: location, + webPkgPath: join(location, 'web', 'package.json'), + })); } -// ─── Conversion helpers ───────────────────────────────────────────────────── +// ─── Helpers ───────────────────────────────────────────────────────────────── -//"tap_burst_web_component" → "tap-burst-web-component" +// "tap_burst_web_component" → "tap-burst-web-component" function toNpmName(n) { return n.replaceAll('_', '-'); } -// ─── Main ─────────────────────────────────────────────────────────────────── +// ─── Main ──────────────────────────────────────────────────────────────────── -const installedFvmVersions = getInstalledFvmVersions(); -if (installedFvmVersions.size === 0) { - console.error('✗ No Flutter versions installed in FVM.'); - process.exit(1); -} - -const packages = discoverPackages(); +const packages = discoverWebPackages(); if (packages.length === 0) { - console.error( - '✗ No packages found.\n' + + die( + 'No packages found.\n' + ' Add a web/package.json to a Flutter package directory to opt in.', ); - process.exit(1); } console.log(`Found ${packages.length} package(s) to build.\n`); -for (const pkg of packages) { - const { name, description, version, flutterConstraint } = parsePubspec(pkg.pubspecPath); - - if (!name || !version) { - console.error(`✗ ${pkg.dir}: pubspec.yaml is missing required fields (name, version).`); - process.exit(1); - } - - console.log(`▶ Building ${name}…`); - - // Resolve the Flutter executable to use for this package. - let flutterCmd; - - const targetVersion = extractLowerBound(flutterConstraint); +// Delegate clean + build to flutter-mono so each package gets the correct +// FVM-managed Flutter SDK version automatically. +const cleanResult = spawnSync( + 'node', + [flutterMono, 'exec', '--dir-exists=web', '--', 'flutter', 'clean'], + { cwd: repoRoot, stdio: 'inherit' }, +); - if (!targetVersion) { - console.error(`✗ ${name}: could not determine a Flutter version from constraint: "${flutterConstraint ?? '(none)'}".`); - if (flutterConstraint && /^>\s*\d/.test(flutterConstraint) && !/^>=/.test(flutterConstraint)) { - console.error(' Exclusive lower bounds (">x.y.z") are not supported. Use ">=" instead.'); - } - process.exit(1); - } +if (cleanResult.status !== 0) { + die(`Flutter clean failed (exit code ${cleanResult.status ?? '?'}).`); +} - if (!installedFvmVersions.has(targetVersion)) { - console.error(`✗ Flutter ${targetVersion} is not installed in FVM.`); - console.error(` Run: fvm install ${targetVersion}`); - process.exit(1); - } +console.log(''); - const bin = resolveFvmFlutterBin(targetVersion); - if (!bin) { - console.error( - `✗ Flutter ${targetVersion} is listed by FVM but the binary was not found on disk.`, - ); - process.exit(1); - } +const buildResult = spawnSync( + 'node', + [flutterMono, 'exec', '--dir-exists=web', '--', 'flutter', 'build', 'web', '--profile'], + { cwd: repoRoot, stdio: 'inherit' }, +); - flutterCmd = bin; - - // Print the actual Flutter version before building. - const versionResult = spawnSync(flutterCmd, ['--version'], { encoding: 'utf8' }); - if (versionResult.error || versionResult.status !== 0) { - console.error(`✗ Failed to run \`flutter --version\` for ${name} (${flutterCmd}).`); - if (versionResult.error) console.error(` ${versionResult.error.message}`); - process.exit(versionResult.status ?? 1); - } - process.stdout.write(`\x1b[32m${versionResult.stdout}\x1b[0m`); +if (buildResult.status !== 0) { + die(`Flutter build(s) failed (exit code ${buildResult.status ?? '?'}).`); +} - // Run: build web - const buildResult = spawnSync(flutterCmd, ['build', 'web', '--profile'], { - cwd: pkg.dir, - stdio: 'inherit', - }); - if (buildResult.status !== 0) { - console.error(`✗ Flutter build failed for ${name} (exit code ${buildResult.status ?? '?'}).`); - process.exit(buildResult.status ?? 1); +// Pack each built output as an npm tarball. +console.log(''); +for (const { name, description, version, dir, webPkgPath } of packages) { + if (!name || !version) { + die(`${dir}: pubspec.yaml is missing required fields (name, version).`); } - // Write build/web/package.json - const webPkg = JSON.parse(readFileSync(pkg.webPkgPath, 'utf8')); - const outputPkg = { - ...webPkg, - name: toNpmName(name), - description: description ?? '', - version, - }; - - const outDir = join(pkg.dir, 'build', 'web'); - const outPath = join(outDir, 'package.json'); - writeFileSync(outPath, JSON.stringify(outputPkg, null, 2) + '\n', 'utf8'); + const npmName = toNpmName(name); + console.log(`▶ Packing ${name}…`); + + // Write build/web/package.json by merging pubspec fields into web/package.json. + const outDir = join(dir, 'build', 'web'); + const webPkg = JSON.parse(readFileSync(webPkgPath, 'utf8')); + writeFileSync( + join(outDir, 'package.json'), + JSON.stringify({ ...webPkg, name: npmName, description: description ?? '', version }, null, 2) + '\n', + 'utf8', + ); - // Pack the build output into a tarball. const packResult = spawnSync('npm', ['pack'], { cwd: outDir, stdio: 'inherit' }); if (packResult.status !== 0) { - console.error(`✗ npm pack failed for ${name} (exit code ${packResult.status ?? '?'}).`); - process.exit(packResult.status ?? 1); + die(`npm pack failed for ${name} (exit code ${packResult.status ?? '?'}).`); } // Rename -.tgz → .tgz (drop the version suffix). - const npmName = toNpmName(name); renameSync( join(outDir, `${npmName}-${version}.tgz`), join(outDir, `${npmName}.tgz`),