diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e3d99f0583..4abe89e7b3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -380,8 +380,7 @@ jobs: run: yarn - name: Test markdown links - run: | - find . -name \*.md -not -name CHANGELOG.md -not -path '*/node_modules/*' -print0 | xargs -0 -n1 yarn test-links -c $(pwd)/.github/markdown-link-check-config.json + run: yarn lage test-links --verbose --grouped pr: name: PR diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b793e9a53..cedd4aa560 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,13 +152,13 @@ If you are creating a new component from scratch, you have the most leeway to de To add a native Windows module: -1. Follow [these instructions](https://microsoft.github.io/react-native-windows/docs/native-modules-setup#creating-a-new-native-module-library-project) for creating a new C++/WinRT native windows module library. Complete all steps through the end of [Making your module ready for consumption in an app](https://microsoft.github.io/react-native-windows/docs/native-modules-setup#making-your-module-ready-for-consumption-in-an-app). +1. Follow [these instructions](https://microsoft.github.io/react-native-windows/docs/native-platform-getting-started#create-a-new-react-native-library-project) for creating a new C++/WinRT native windows module library. Complete all steps through the end of [Making your module ready for consumption in an app](https://microsoft.github.io/react-native-windows/docs/native-platform-getting-started#initialize-the-react-native-for-windows-native-code-and-projects). - When creating Views and ViewManagers for your module, Windows components such as Expander and Windows modules outside of the FluentUI React Native repository such as the [`datetimepicker`](https://github.com/react-native-datetimepicker/datetimepicker/tree/master/windows/DateTimePickerWindows) are helpful resources 2. Follow the steps for [creating a new component](#creating-a-new-component) in FluentUI React Native. - Other Windows components such as the Expander will be helpful with this step. 3. Copy the `windows` folder from the local native component library created in Step 1 into the root of the new componenet's directory. 4. Testing the component locally - 1. Follow steps for Option 1 of [testing the module](https://microsoft.github.io/react-native-windows/docs/native-modules-setup#testing-the-module-before-it-gets-published) + 1. Follow steps for Option 1 of [testing the module](https://microsoft.github.io/react-native-windows/docs/native-platform-getting-started#running-the-react-native-for-windows-example-app) - Be sure to run the autolinking command from the component's root directory (`npx react-native autolink-windows`) 2. Check that the NuGet packages for the test application and component line up. i.e. If the component uses WinUI 2.6, the test application should as well. - Right-click on the solution within VS. Select `Manage NuGet Packages for Solution…`. Look at differences under the consolidate tab. diff --git a/lage.config.mjs b/lage.config.mjs index 1c3cc712b1..1d1ed2d5aa 100644 --- a/lage.config.mjs +++ b/lage.config.mjs @@ -59,6 +59,16 @@ const config = { buildci: ['lint-repo', 'check-publishing', 'build-all', 'test', 'lint'], // ── Worker tasks ─────────────────────────────────────────────────────── + 'test-links': { + // Runs markdown-link-check once per package directory; lage parallelizes + // across packages. The worker stops at nested package boundaries so the + // root target does not recurse into workspace directories. + type: 'worker', + options: { + worker: 'scripts/src/worker/test-links.mts', + }, + cache: false, + }, pack: { dependsOn: ['build-all', '^pack'], type: 'worker', diff --git a/scripts/src/worker/test-links.mts b/scripts/src/worker/test-links.mts new file mode 100644 index 0000000000..027693d1cd --- /dev/null +++ b/scripts/src/worker/test-links.mts @@ -0,0 +1,106 @@ +import type { WorkerRunnerFunction } from 'lage'; + +import { $, fs, glob } from 'zx'; +import { dirname, join, relative, resolve } from 'node:path'; + +/** + * Lage worker that runs `markdown-link-check` for a single package directory. + * + * Lage invokes this worker once per workspace package, so each invocation only + * scans the markdown files that belong to that package. Because lage runs the + * targets concurrently, the repo's markdown is link-checked directory by + * directory in parallel instead of via a single serial `find | xargs` pass. + * + * Crucially, the walk stops descending at any nested workspace directory — + * that package gets its own worker invocation. This is what keeps the root + * package's invocation from recursing into `apps/*`, `packages/**`, and + * `scripts`; the root only checks its own top-level markdown plus non-workspace + * folders like `docs/`, `.github/`, and `.changeset/`. + * + * Note that the boundary is the set of *workspace* directories, not merely any + * directory containing a `package.json`. Some folders (e.g. `docs/`, the + * component template) carry a `package.json` but are not workspaces, so lage + * never spawns a target for them — their markdown must be covered by the + * nearest enclosing workspace (usually the root) rather than pruned away. + */ + +const CONFIG_PATH = '.github/markdown-link-check-config.json'; + +// Directories that never contain checkable markdown and would only slow the walk. +const SKIP_DIRS = new Set(['node_modules', 'lib', 'lib-commonjs', 'dist', '.git']); + +/** + * Resolve the set of workspace directories from the root package.json's + * `workspaces` globs, matching yarn's own resolution (a glob match that + * contains a `package.json`). Cached per worker process since it never changes + * across the targets a single worker handles. + */ +let workspaceDirsPromise: Promise> | undefined; +function getWorkspaceDirs(repoRoot: string): Promise> { + workspaceDirsPromise ??= (async () => { + const rootPkg = await fs.readJson(join(repoRoot, 'package.json')); + const patterns: string[] = rootPkg.workspaces ?? []; + const manifestGlobs = patterns.map((pattern) => (pattern === '.' ? 'package.json' : `${pattern}/package.json`)); + const manifests = await glob(manifestGlobs, { cwd: repoRoot, ignore: ['**/node_modules/**'] }); + return new Set(manifests.map((manifest) => resolve(repoRoot, dirname(manifest)))); + })(); + return workspaceDirsPromise; +} + +/** + * Collect markdown files under `startDir`, stopping at nested workspace + * boundaries (each of which gets its own worker invocation). CHANGELOG.md is + * excluded to match the repo's link-check convention. + */ +async function findMarkdownFiles(startDir: string, workspaceDirs: Set): Promise { + const results: string[] = []; + + async function walk(current: string): Promise { + // Another workspace owns this subtree via its own worker invocation — don't + // recurse into it. (The starting directory is never pruned.) + if (current !== startDir && workspaceDirs.has(current)) { + return; + } + + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) { + continue; + } + await walk(full); + } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'CHANGELOG.md') { + results.push(full); + } + } + } + + await walk(startDir); + return results; +} + +export const run: WorkerRunnerFunction = async ({ target }) => { + // Lage runs from the repo root, so the config and binary resolve from here. + const repoRoot = process.cwd(); + const configPath = resolve(repoRoot, CONFIG_PATH); + + const workspaceDirs = await getWorkspaceDirs(repoRoot); + const files = await findMarkdownFiles(resolve(target.cwd), workspaceDirs); + if (files.length === 0) { + return; + } + + const failures: string[] = []; + for (const file of files) { + const relPath = relative(repoRoot, file); + const result = await $({ cwd: repoRoot, nothrow: true, verbose: true })`yarn markdown-link-check -c ${configPath} ${file}`; + if (result.exitCode !== 0) { + failures.push(relPath); + } + } + + if (failures.length > 0) { + throw new Error(`Dead links found in:\n${failures.map((f) => ` - ${f}`).join('\n')}`); + } +};