From e76cc2cbf4ee383733cffb94d54e0e75f6aef499 Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 2 Oct 2026 00:43:09 +0300 Subject: [PATCH 1/2] feat: scaffold into Nx workspaces with create-ngmd create-ngmd now finds nx.json above the current folder and adds the site as an Nx app in apps/ (or --directory), with a project.json that uses the same AnalogJS executors as the Analog generator, a typecheck target, a tsconfig that extends the workspace base, and its dependencies merged into the root package.json. --no-nx keeps the standalone output. Code imports and markdown vars now resolve against the site folder instead of process.cwd(), since Nx runs Vite from the workspace root. Adds an Nx smoke test to CI and a Nx Monorepos docs page. Closes #12 --- .github/workflows/ci.yml | 3 + README.md | 12 ++ build-extensions.spec.ts | 11 ++ create-ngmd/README.md | 15 +- create-ngmd/index.mjs | 93 +++++++++- create-ngmd/nx.mjs | 196 ++++++++++++++++++++++ create-ngmd/nx.spec.ts | 122 ++++++++++++++ create-ngmd/package.json | 2 + create-ngmd/smoke-test.mjs | 61 ++++++- plugin-utils.ts | 2 + skills/ngmd-new-site/SKILL.md | 2 + src/content/stack/installation.md | 4 + src/content/stack/nx.md | 80 +++++++++ src/marked-extensions/ngmd-code-import.ts | 4 +- src/ngmd.config.ts | 2 + vars.plugin.ts | 3 +- 16 files changed, 592 insertions(+), 20 deletions(-) create mode 100644 create-ngmd/nx.mjs create mode 100644 create-ngmd/nx.spec.ts create mode 100644 src/content/stack/nx.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd4a538..3d1aac2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,6 @@ jobs: - name: Scaffold, install, build and test a generated site run: pnpm --dir create-ngmd smoke + + - name: Scaffold, build and test a site inside an Nx workspace + run: pnpm --dir create-ngmd smoke:nx diff --git a/README.md b/README.md index 037675b..4964123 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,18 @@ pnpm run dev `npm create ngmd@latest`, `yarn create ngmd`, and `bun create ngmd` all work too. +### Inside an Nx workspace + +Run the same command anywhere inside an Nx workspace. `create-ngmd` finds `nx.json` and adds the site as an Nx app in `apps/`, with a `project.json` that uses the AnalogJS executors and its dependencies added to the root `package.json`: + +```bash +pnpm create ngmd@latest docs # writes apps/docs +pnpm install +pnpm nx serve docs +``` + +Use `--directory ` for another folder and `--no-nx` for a standalone project. Details in [Nx Monorepos](https://ngmd.netlify.app/stack/nx). + ## Authoring model Two patterns, pick per page. **Prose pages** are markdown files under `src/content/`. Drop `theming.md` under `src/content/concepts/` and `/concepts/theming` resolves to it; one catch-all route handles every prose page. Sidebar, TOC, prev/next, and edit-on-github derive from `ngmd.config.ts` and `git log`. **Component pages** compose Angular components in `.page.ts` directly using NgMd's seventeen authoring components (callout, alert, card, card-grid, tabs, tab, pill-row, pill, workflow, step, hero, code-block, accordion, accordion-item, badge, video, image). Sixteen of them also render inline in markdown via Custom Elements; code-block uses fenced ` ``` ` blocks instead. diff --git a/build-extensions.spec.ts b/build-extensions.spec.ts index e8ab16d..5f5127f 100644 --- a/build-extensions.spec.ts +++ b/build-extensions.spec.ts @@ -1,3 +1,4 @@ +import {tmpdir} from 'node:os'; import {Marked, type MarkedExtension} from 'marked'; import {getBuildExtensions} from './src/marked-extensions/index'; import {ngmdCodeGroupExtension} from './src/marked-extensions/ngmd-code-group'; @@ -57,6 +58,16 @@ describe('build-time fence extensions', () => { warn.mockRestore(); }); + it('resolves imported files from the site root, not the working directory', async () => { + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmpdir()); + const html = await preprocess( + ngmdCodeImportExtension, + '```ts file="src/marked-extensions/escape-html.ts#L1"\n```\n', + ); + expect(html).toContain('escapeHtml'); + cwd.mockRestore(); + }); + it('substitutes vars before the fence extensions run', async () => { const extensions = await getBuildExtensions(); const html = await new Marked({async: true}, ...extensions).parse( diff --git a/create-ngmd/README.md b/create-ngmd/README.md index 3ea6e0e..ce98700 100644 --- a/create-ngmd/README.md +++ b/create-ngmd/README.md @@ -51,11 +51,24 @@ See the [NgMd repo](https://github.com/erkamyaman/ngmd) for the feature list. 3. Drop more `.md` files in `src/content/`, or `.page.ts` components in `src/app/pages/`. 4. Build with `pnpm run build` and deploy `dist/analog/public/` to any static host (with a fallback to `/index.html`), or run `dist/analog/server/` on Node. +## Nx workspaces + +Run the command anywhere inside an Nx workspace and the site becomes an Nx app instead of a standalone project: + +```bash +pnpm create ngmd@latest docs # apps/docs, Nx project "docs" +pnpm create ngmd@latest docs --directory sites/docs # another folder +pnpm install +pnpm nx serve docs +``` + +The app gets a `project.json` with `build`, `serve`, `test` and `typecheck` targets (the same AnalogJS executors as `nx g @analogjs/platform:application`), and its dependencies go to the workspace root `package.json`. Versions the workspace already has are kept and listed. Pass `--no-nx` to get a standalone project anyway, or `--nx` to fail when no `nx.json` is found. See [Nx Monorepos](https://ngmd.netlify.app/stack/nx). + Inside a monorepo, keep the scaffolded `.prettierrc.json` and `.prettierignore` in the NgMd folder. Otherwise the root Prettier config reformats NgMd's files (bracket spacing, for example) and they drift from upstream. ## How it works -`index.mjs` (zero npm deps; warns if your Node is below the template's `engines.node` floor) copies `template/` into the target directory and rewrites a few placeholders (`package.json` name, `ngmd.config.ts` brand and GitHub URL, `index.html` title, `README.md` heading) so the new project matches the name you passed. It refuses a non-empty target directory, and does not install dependencies or run `git init`. +`index.mjs` (zero npm deps; warns if your Node is below the template's `engines.node` floor) copies `template/` into the target directory and rewrites a few placeholders (`package.json` name, `ngmd.config.ts` brand and GitHub URL, `index.html` title, `README.md` heading) so the new project matches the name you passed. It refuses a non-empty target directory, and does not install dependencies or run `git init`. Inside an Nx workspace, `nx.mjs` then turns the copy into an Nx project. `template/` is generated from the parent ngmd repo by `build-template.mjs` and is git-ignored. The `prepublishOnly` script regenerates it before every publish, so the npm artifact always carries an up-to-date starter. diff --git a/create-ngmd/index.mjs b/create-ngmd/index.mjs index 4af4e37..2d72dd7 100644 --- a/create-ngmd/index.mjs +++ b/create-ngmd/index.mjs @@ -12,19 +12,23 @@ import {dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {createInterface} from 'node:readline/promises'; import {stdin, stdout} from 'node:process'; +import {convertToNxProject, findNxWorkspace, toPosix} from './nx.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = join(HERE, 'template'); /** - * `create-ngmd ` — scaffolds a fresh NgMd project. + * `create-ngmd [--nx | --no-nx] [--directory ]` — scaffolds + * a fresh NgMd project. * * Behaviour: - * 1. Accept project name from argv[2] or interactive prompt. + * 1. Accept project name from the first positional argument or a prompt. * 2. Validate (lowercase, hyphenated, no path traversal, dir not present). - * 3. Copy `template/` into .//. + * 3. Copy `template/` into .//, or into (default + * apps/) of the enclosing Nx workspace when there is one. * 4. Replace placeholders ({{name}}) in package.json, ngmd.config.ts, index.html. - * 5. Print next-step commands tailored to the detected package manager. + * 5. In an Nx workspace, turn the copy into an Nx project (see nx.mjs). + * 6. Print next-step commands tailored to the detected package manager. * * Zero npm dependencies. Node builtins only. */ @@ -118,9 +122,34 @@ function replacePlaceholders(target, name) { } } +function parseArgs(argv) { + const args = {name: undefined, nx: undefined, directory: undefined}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--nx') args.nx = true; + else if (a === '--no-nx') args.nx = false; + else if (a === '--directory' || a === '--dir') args.directory = argv[++i]; + else if (a.startsWith('--directory=')) args.directory = a.slice('--directory='.length); + else if (a.startsWith('-')) throw new Error(`unknown option ${a}`); + else if (!args.name) args.name = a; + else throw new Error(`unexpected argument ${a}`); + } + return args; +} + +function validDirectory(dir) { + const parts = toPosix(dir).split('/').filter(Boolean); + return ( + parts.length > 0 && + !dir.startsWith('/') && + !/^[a-zA-Z]:/.test(dir) && + parts.every((p) => p !== '..' && p !== '.' && /^[a-zA-Z0-9._@-]+$/.test(p)) + ); +} + async function main() { - const argv = process.argv.slice(2); - const requested = argv[0]; + const args = parseArgs(process.argv.slice(2)); + const requested = args.name; console.log( `\n${c.bold}${c.cyan}create-ngmd${c.reset} ${c.dim}— scaffold a new NgMd project${c.reset}\n`, @@ -149,12 +178,38 @@ async function main() { process.exit(1); } - const target = resolve(process.cwd(), name); + const workspaceRoot = args.nx === false ? null : findNxWorkspace(process.cwd()); + if (args.nx && !workspaceRoot) { + console.error( + `${c.red}error:${c.reset} --nx was passed but no nx.json was found in this directory or above it.`, + ); + process.exit(1); + } + if (args.directory !== undefined && !workspaceRoot) { + console.error(`${c.red}error:${c.reset} --directory only applies inside an Nx workspace.`); + process.exit(1); + } + if (args.directory !== undefined && !validDirectory(args.directory)) { + console.error( + `${c.red}error:${c.reset} "${args.directory}" is not a valid directory. ` + + `Use a path relative to the workspace root, like apps/docs.`, + ); + process.exit(1); + } + + const projectRoot = workspaceRoot + ? toPosix(args.directory ?? `apps/${name}`).replace(/^\/+|\/+$/g, '') + : null; + const target = workspaceRoot ? join(workspaceRoot, projectRoot) : resolve(process.cwd(), name); + const shown = workspaceRoot ? projectRoot : name; + if (workspaceRoot) { + console.log(`${c.dim}Nx workspace found at${c.reset} ${workspaceRoot}`); + } if (existsSync(target)) { const isEmpty = readdirSync(target).length === 0; if (!isEmpty) { console.error( - `${c.red}error:${c.reset} directory "${name}" already exists and is not empty.`, + `${c.red}error:${c.reset} directory "${shown}" already exists and is not empty.`, ); process.exit(1); } @@ -189,6 +244,28 @@ async function main() { const install = pm === 'yarn' ? 'yarn' : `${pm} install`; const dev = pm === 'npm' ? 'npm run dev' : `${pm} dev`; + if (workspaceRoot) { + const {added, kept} = convertToNxProject({workspaceRoot, target, projectRoot, name}); + const nx = {npm: 'npx nx', pnpm: 'pnpm nx', yarn: 'yarn nx', bun: 'bunx nx'}[pm]; + console.log(`${c.green}✓${c.reset} ${c.bold}done${c.reset}\n`); + console.log( + `${c.dim}Added${c.reset} ${added.length} ${c.dim}dependencies to the workspace package.json.${c.reset}`, + ); + if (kept.length) { + console.log( + `${c.yellow}Kept your versions of these, which are a different major than NgMd uses:${c.reset}`, + ); + for (const k of kept) console.log(` ${k}`); + } + console.log(`\n${c.bold}Next steps:${c.reset}`); + if (process.cwd() !== workspaceRoot) console.log(` ${c.cyan}cd${c.reset} ${workspaceRoot}`); + console.log(` ${c.cyan}${install}${c.reset}`); + console.log(` ${c.cyan}${nx} serve ${name}${c.reset}\n`); + console.log(`${c.dim}Docs:${c.reset} https://ngmd.netlify.app/stack/nx`); + console.log(`${c.dim}Issues:${c.reset} https://github.com/erkamyaman/ngmd/issues\n`); + return; + } + console.log(`${c.green}✓${c.reset} ${c.bold}done${c.reset}\n`); console.log(`${c.bold}Next steps:${c.reset}`); console.log(` ${c.cyan}cd${c.reset} ${name}`); diff --git a/create-ngmd/nx.mjs b/create-ngmd/nx.mjs new file mode 100644 index 0000000..832a999 --- /dev/null +++ b/create-ngmd/nx.mjs @@ -0,0 +1,196 @@ +import {existsSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {dirname, join, relative} from 'node:path'; + +export function findNxWorkspace(start) { + let dir = start; + while (true) { + if (existsSync(join(dir, 'nx.json'))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export function toPosix(p) { + return p.split('\\').join('/'); +} + +export function offsetFromRoot(projectRoot) { + return projectRoot + .split('/') + .filter(Boolean) + .map(() => '../') + .join(''); +} + +export function projectJson(name, projectRoot) { + return { + name, + $schema: `${offsetFromRoot(projectRoot)}node_modules/nx/schemas/project-schema.json`, + projectType: 'application', + sourceRoot: `${projectRoot}/src`, + tags: [], + targets: { + build: { + executor: '@analogjs/platform:vite', + outputs: [ + '{options.outputPath}', + `{workspaceRoot}/dist/${projectRoot}/.nitro`, + `{workspaceRoot}/dist/${projectRoot}/ssr`, + `{workspaceRoot}/dist/${projectRoot}/analog`, + ], + options: { + main: `${projectRoot}/src/main.ts`, + configFile: `${projectRoot}/vite.config.ts`, + outputPath: `dist/${projectRoot}/client`, + tsConfig: `${projectRoot}/tsconfig.app.json`, + }, + defaultConfiguration: 'production', + configurations: { + development: {mode: 'development'}, + production: {sourcemap: false, mode: 'production'}, + }, + }, + serve: { + executor: '@analogjs/platform:vite-dev-server', + defaultConfiguration: 'development', + options: {buildTarget: `${name}:build`, port: 5173}, + configurations: { + development: {buildTarget: `${name}:build:development`, hmr: true}, + production: {buildTarget: `${name}:build:production`}, + }, + }, + test: { + executor: '@analogjs/vitest-angular:test', + outputs: ['{projectRoot}/coverage'], + }, + typecheck: { + executor: 'nx:run-commands', + options: { + cwd: projectRoot, + command: 'tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.spec.json', + }, + }, + }, + }; +} + +const VITE_ANCHOR = 'export default defineConfig(async () => ({\n build: {\n'; + +export function nxViteConfig(src, projectRoot) { + if (!src.includes(VITE_ANCHOR)) { + throw new Error('vite.config.ts does not match the template layout, cannot add Nx paths'); + } + const offset = offsetFromRoot(projectRoot); + return src + .replace( + VITE_ANCHOR, + 'export default defineConfig(async () => ({\n' + + ' root: import.meta.dirname,\n' + + ` cacheDir: '${offset}node_modules/.vite',\n` + + ' build: {\n' + + ` outDir: '${offset}dist/${projectRoot}/client',\n`, + ) + .replace("mainFields: ['module'],", "mainFields: ['module'],\n tsconfigPaths: true,"); +} + +export function nxTsconfig(tsconfig, projectRoot, baseConfig) { + if (!baseConfig) return tsconfig; + return { + extends: `${offsetFromRoot(projectRoot)}${baseConfig}`, + ...tsconfig, + compilerOptions: { + ...tsconfig.compilerOptions, + outDir: `${offsetFromRoot(projectRoot)}dist/out-tsc`, + composite: false, + declarationMap: false, + emitDeclarationOnly: false, + noUnusedLocals: false, + noUnusedParameters: false, + }, + }; +} + +export function mergeDependencies(rootPkg, templatePkg, extraDev = {}) { + const added = []; + const kept = []; + const out = {...rootPkg}; + for (const field of ['dependencies', 'devDependencies']) { + const wanted = {...templatePkg[field], ...(field === 'devDependencies' ? extraDev : {})}; + const before = added.length; + for (const [dep, range] of Object.entries(wanted)) { + const existing = rootPkg.dependencies?.[dep] ?? rootPkg.devDependencies?.[dep]; + if (existing) { + if (major(existing) !== major(range)) { + kept.push(`${dep}@${existing} (NgMd uses ${range})`); + } + continue; + } + out[field] = {...out[field], [dep]: range}; + added.push(dep); + } + if (added.length > before) out[field] = sortKeys(out[field]); + } + return {pkg: out, added, kept}; +} + +function major(range) { + return range.match(/\d+/)?.[0] ?? range; +} + +function sortKeys(obj) { + return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function writeJson(path, value) { + writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); +} + +export function convertToNxProject({workspaceRoot, target, projectRoot, name}) { + const templatePkg = readJson(join(target, 'package.json')); + + writeJson(join(target, 'package.json'), { + name, + version: templatePkg.version, + private: true, + type: 'module', + }); + rmSync(join(target, 'angular.json'), {force: true}); + rmSync(join(target, '.gitignore'), {force: true}); + writeJson(join(target, 'project.json'), projectJson(name, projectRoot)); + + const vitePath = join(target, 'vite.config.ts'); + writeFileSync(vitePath, nxViteConfig(readFileSync(vitePath, 'utf8'), projectRoot)); + + const baseConfig = ['tsconfig.base.json', 'tsconfig.json'].find((f) => + existsSync(join(workspaceRoot, f)), + ); + const tsPath = join(target, 'tsconfig.json'); + writeJson(tsPath, nxTsconfig(readJson(tsPath), projectRoot, baseConfig)); + + const cfgPath = join(target, 'src/ngmd.config.ts'); + if (existsSync(cfgPath)) { + writeFileSync( + cfgPath, + readFileSync(cfgPath, 'utf8').replace( + /(\n(\s*)githubUrl:\s*'[^']*',)/, + `$1\n$2githubDir: '${projectRoot}',`, + ), + ); + } + + const rootPkgPath = join(workspaceRoot, 'package.json'); + const rootPkg = existsSync(rootPkgPath) + ? readJson(rootPkgPath) + : {name: 'workspace', private: true}; + const nxVersion = rootPkg.devDependencies?.nx ?? rootPkg.dependencies?.nx; + const extraDev = nxVersion ? {'@nx/vite': nxVersion} : {}; + const {pkg, added, kept} = mergeDependencies(rootPkg, templatePkg, extraDev); + writeJson(rootPkgPath, pkg); + + return {added, kept, relTarget: toPosix(relative(workspaceRoot, target))}; +} diff --git a/create-ngmd/nx.spec.ts b/create-ngmd/nx.spec.ts new file mode 100644 index 0000000..6ffe575 --- /dev/null +++ b/create-ngmd/nx.spec.ts @@ -0,0 +1,122 @@ +import {cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import { + convertToNxProject, + findNxWorkspace, + mergeDependencies, + nxTsconfig, + nxViteConfig, + offsetFromRoot, + projectJson, +} from './nx.mjs'; + +const REPO = join(__dirname, '..'); +const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8')); + +describe('create-ngmd Nx support', () => { + let work: string; + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'ngmd-nx-spec-')); + }); + afterEach(() => rmSync(work, {recursive: true, force: true})); + + it('finds nx.json in the start folder or above it', () => { + writeFileSync(join(work, 'nx.json'), '{}'); + mkdirSync(join(work, 'apps/a'), {recursive: true}); + expect(findNxWorkspace(join(work, 'apps/a'))).toBe(work); + expect(findNxWorkspace(tmpdir())).toBeNull(); + }); + + it('builds the Analog project.json layout for the project root', () => { + expect(offsetFromRoot('apps/docs')).toBe('../../'); + expect(offsetFromRoot('docs')).toBe('../'); + const p = projectJson('docs', 'apps/docs'); + expect(p.targets.build.executor).toBe('@analogjs/platform:vite'); + expect(p.targets.build.options).toEqual({ + main: 'apps/docs/src/main.ts', + configFile: 'apps/docs/vite.config.ts', + outputPath: 'dist/apps/docs/client', + tsConfig: 'apps/docs/tsconfig.app.json', + }); + expect(p.targets.serve.executor).toBe('@analogjs/platform:vite-dev-server'); + expect(p.targets.serve.configurations.development.buildTarget).toBe('docs:build:development'); + expect(p.targets.test.executor).toBe('@analogjs/vitest-angular:test'); + expect(p.targets.typecheck.options.cwd).toBe('apps/docs'); + }); + + it('pins the vite config to its own folder and the workspace dist', () => { + const out = nxViteConfig(readFileSync(join(REPO, 'vite.config.ts'), 'utf8'), 'apps/docs'); + expect(out).toContain(' root: import.meta.dirname,\n'); + expect(out).toContain(" cacheDir: '../../node_modules/.vite',\n"); + expect(out).toContain(" outDir: '../../dist/apps/docs/client',\n"); + expect(out).toContain(' tsconfigPaths: true,\n'); + expect(() => nxViteConfig('export default {}', 'apps/docs')).toThrow(/template layout/); + }); + + it('extends the workspace base tsconfig and turns off project-build options', () => { + const ts = readJson(join(REPO, 'tsconfig.json')); + expect(nxTsconfig(ts, 'apps/docs', undefined)).toBe(ts); + const out = nxTsconfig(ts, 'apps/docs', 'tsconfig.base.json'); + expect(out.extends).toBe('../../tsconfig.base.json'); + expect(out.compilerOptions.composite).toBe(false); + expect(out.compilerOptions.emitDeclarationOnly).toBe(false); + expect(out.compilerOptions.moduleResolution).toBe('bundler'); + expect(out.angularCompilerOptions).toEqual(ts.angularCompilerOptions); + }); + + it('adds missing dependencies and keeps the versions the workspace already has', () => { + const {pkg, added, kept} = mergeDependencies( + {name: 'ws', devDependencies: {typescript: '~5.9.0', nx: '23.2.0', vite: '^8.0.0'}}, + {dependencies: {marked: '^15.0.7'}, devDependencies: {typescript: '~6.0.3', vite: '^8.3.1'}}, + {'@nx/vite': '23.2.0'}, + ); + expect(pkg.dependencies).toEqual({marked: '^15.0.7'}); + expect(pkg.devDependencies).toEqual({ + '@nx/vite': '23.2.0', + nx: '23.2.0', + typescript: '~5.9.0', + vite: '^8.0.0', + }); + expect(added).toEqual(['marked', '@nx/vite']); + expect(kept).toEqual(['typescript@~5.9.0 (NgMd uses ~6.0.3)']); + }); + + it('turns a copied template into an Nx project', () => { + writeFileSync(join(work, 'nx.json'), '{}'); + writeFileSync(join(work, 'tsconfig.base.json'), '{}'); + writeFileSync( + join(work, 'package.json'), + JSON.stringify({name: 'ws', devDependencies: {nx: '23.2.0'}}), + ); + const target = join(work, 'apps/docs'); + mkdirSync(join(target, 'src'), {recursive: true}); + for (const f of ['vite.config.ts', 'tsconfig.json', 'angular.json', 'package.json']) { + cpSync(join(REPO, f), join(target, f)); + } + cpSync(join(REPO, 'src/ngmd.config.ts'), join(target, 'src/ngmd.config.ts')); + writeFileSync(join(target, '.gitignore'), '/dist\n'); + + const {added, relTarget} = convertToNxProject({ + workspaceRoot: work, + target, + projectRoot: 'apps/docs', + name: 'docs', + }); + + expect(relTarget).toBe('apps/docs'); + expect(readJson(join(target, 'package.json'))).toMatchObject({name: 'docs', type: 'module'}); + expect(readJson(join(target, 'project.json')).name).toBe('docs'); + expect(() => readFileSync(join(target, 'angular.json'))).toThrow(); + expect(() => readFileSync(join(target, '.gitignore'))).toThrow(); + expect(readJson(join(target, 'tsconfig.json')).extends).toBe('../../tsconfig.base.json'); + expect(readFileSync(join(target, 'src/ngmd.config.ts'), 'utf8')).toContain( + "githubDir: 'apps/docs',", + ); + const root = readJson(join(work, 'package.json')); + expect(root.dependencies['@analogjs/content']).toBeDefined(); + expect(root.devDependencies['@analogjs/platform']).toBeDefined(); + expect(root.devDependencies['@nx/vite']).toBe('23.2.0'); + expect(added).toContain('@nx/vite'); + }); +}); diff --git a/create-ngmd/package.json b/create-ngmd/package.json index 210e601..4b96174 100644 --- a/create-ngmd/package.json +++ b/create-ngmd/package.json @@ -34,6 +34,7 @@ "scripts": { "build-template": "node build-template.mjs", "smoke": "node smoke-test.mjs", + "smoke:nx": "node smoke-test.mjs --nx", "prepublishOnly": "node build-template.mjs" }, "engines": { @@ -41,6 +42,7 @@ }, "files": [ "index.mjs", + "nx.mjs", "README.md", "template" ], diff --git a/create-ngmd/smoke-test.mjs b/create-ngmd/smoke-test.mjs index bf38911..e5dd1c0 100644 --- a/create-ngmd/smoke-test.mjs +++ b/create-ngmd/smoke-test.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import {execFileSync} from 'node:child_process'; -import {existsSync, mkdtempSync, rmSync} from 'node:fs'; +import {existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -10,25 +10,37 @@ import {fileURLToPath} from 'node:url'; * project with `index.mjs` into a temp directory, then installs, builds and * runs the tests there. Removes the generated template and the temp project * afterwards (pass `--keep` to leave the temp project for inspection). + * With `--nx`, scaffolds into a minimal Nx workspace instead and runs the + * project's Nx build, test and typecheck targets. * - * node create-ngmd/smoke-test.mjs [--keep] + * node create-ngmd/smoke-test.mjs [--nx] [--keep] */ const HERE = dirname(fileURLToPath(import.meta.url)); const TEMPLATE = join(HERE, 'template'); const keep = process.argv.includes('--keep'); +const nx = process.argv.includes('--nx'); +const NX_VERSION = '^23.2.0'; const templateExisted = existsSync(TEMPLATE); function run(cmd, args, cwd) { console.log(`\n$ ${cmd} ${args.join(' ')} (${cwd})`); - execFileSync(cmd, args, {cwd, stdio: 'inherit', env: {...process.env, CI: 'true'}}); + execFileSync(cmd, args, { + cwd, + stdio: 'inherit', + env: {...process.env, CI: 'true', NX_DAEMON: 'false', NX_NO_CLOUD: 'true'}, + }); } const work = mkdtempSync(join(tmpdir(), 'create-ngmd-smoke-')); -const project = join(work, 'ngmd-smoke'); -let ok = false; -try { - run(process.execPath, [join(HERE, 'build-template.mjs')], HERE); +const workspace = join(work, 'nx-smoke'); +const project = nx ? join(workspace, 'apps/ngmd-smoke') : join(work, 'ngmd-smoke'); + +function writeJson(path, value) { + writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); +} + +function smokeStandalone() { run(process.execPath, [join(HERE, 'index.mjs'), 'ngmd-smoke'], work); for (const file of ['.gitignore', '.prettierrc.json', '.prettierignore']) { if (!existsSync(join(project, file))) throw new Error(`scaffolded project is missing ${file}`); @@ -36,10 +48,43 @@ try { run('pnpm', ['install'], project); run('pnpm', ['build'], project); run('pnpm', ['exec', 'vitest', 'run'], project); +} + +function smokeNx() { + mkdirSync(workspace); + writeJson(join(workspace, 'nx.json'), {}); + writeJson(join(workspace, 'tsconfig.base.json'), {compilerOptions: {paths: {}}}); + writeJson(join(workspace, 'package.json'), { + name: 'nx-smoke', + private: true, + devDependencies: {nx: NX_VERSION}, + }); + run(process.execPath, [join(HERE, 'index.mjs'), 'ngmd-smoke'], workspace); + for (const file of ['project.json', 'vite.config.ts', '.prettierrc.json']) { + if (!existsSync(join(project, file))) throw new Error(`Nx project is missing ${file}`); + } + for (const file of ['angular.json', '.gitignore', 'pnpm-lock.yaml']) { + if (existsSync(join(project, file))) throw new Error(`Nx project should not have ${file}`); + } + run('pnpm', ['install'], workspace); + run('pnpm', ['nx', 'build', 'ngmd-smoke'], workspace); + if (!existsSync(join(workspace, 'dist/apps/ngmd-smoke/analog/public/index.html'))) { + throw new Error('Nx build did not write dist/apps/ngmd-smoke/analog/public/index.html'); + } + run('pnpm', ['nx', 'test', 'ngmd-smoke'], workspace); + run('pnpm', ['nx', 'typecheck', 'ngmd-smoke'], workspace); +} + +let ok = false; +try { + run(process.execPath, [join(HERE, 'build-template.mjs')], HERE); + if (nx) smokeNx(); + else smokeStandalone(); ok = true; } finally { if (!templateExisted) rmSync(TEMPLATE, {recursive: true, force: true}); if (keep) console.log(`\nkept scaffolded project at ${project}`); else rmSync(work, {recursive: true, force: true}); } -console.log(ok ? '\ncreate-ngmd smoke test passed.' : '\ncreate-ngmd smoke test failed.'); +const label = nx ? 'create-ngmd Nx smoke test' : 'create-ngmd smoke test'; +console.log(ok ? `\n${label} passed.` : `\n${label} failed.`); diff --git a/plugin-utils.ts b/plugin-utils.ts index 2f499b0..f5559e8 100644 --- a/plugin-utils.ts +++ b/plugin-utils.ts @@ -154,6 +154,8 @@ export function withoutCode(markdown: string): string { .replace(/(`+)[^\n]*?\1/g, ' '); } +export const siteRoot = import.meta.dirname; + /** * Resolve `path` against `root`, following symlinks, and throw when the * real target lies outside the real root. diff --git a/skills/ngmd-new-site/SKILL.md b/skills/ngmd-new-site/SKILL.md index 8d8a361..d022c81 100644 --- a/skills/ngmd-new-site/SKILL.md +++ b/skills/ngmd-new-site/SKILL.md @@ -46,6 +46,8 @@ yarn create ngmd bun create ngmd ``` +Inside an Nx workspace (an `nx.json` in the current folder or above it), the same command adds the site as an Nx app in `apps/` instead: it writes a `project.json` with `build`, `serve`, `test` and `typecheck` targets, adds the dependencies to the workspace root `package.json` and keeps any versions already there. Install from the workspace root and run `pnpm nx serve `. Pass `--directory ` for another folder, or `--no-nx` for a standalone project. + The scaffolder is a single Node script with no dependencies. It copies a slim template into `/`, prints a quick-start, and exits. It never runs `git init` or `pnpm install` for the user. ## 4. Install and start the dev server diff --git a/src/content/stack/installation.md b/src/content/stack/installation.md index 0495c1e..e5c41d0 100644 --- a/src/content/stack/installation.md +++ b/src/content/stack/installation.md @@ -36,6 +36,10 @@ pnpm run dev Open `http://localhost:5173`. Done. + + Run the same command inside an Nx workspace and the site becomes an Nx app in apps/my-docs. See Nx Monorepos. + + The scaffolder copies a slim template that owns its dependencies. You don't depend on create-ngmd after install. It never appears in your package.json. diff --git a/src/content/stack/nx.md b/src/content/stack/nx.md new file mode 100644 index 0000000..8b04455 --- /dev/null +++ b/src/content/stack/nx.md @@ -0,0 +1,80 @@ +--- +title: Nx Monorepos +description: Add an NgMd docs app to an existing Nx workspace with create-ngmd. +--- + + + Run create-ngmd inside an Nx workspace and it adds the docs site as an Nx app, laid out like an AnalogJS app from the Analog generator. + + +# Nx Monorepos + +`create-ngmd` notices when it runs inside an *Nx workspace (a folder with `nx.json`, or a folder below one). Instead of a standalone project, it writes an Nx app with a `project.json`, so you build and serve it with `nx` like any other app in the repo. + +## Add a docs app + +Run the scaffolder from anywhere in the workspace: + +```bash group="nx" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active +pnpm create ngmd@latest docs +``` + +```bash group="nx" name="npm" image="https://cdn.simpleicons.org/npm/CB3837" +npm create ngmd@latest docs +``` + +```bash group="nx" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB" +yarn create ngmd docs +``` + +```bash group="nx" name="bun" image="https://bun.sh/logo.svg" +bun create ngmd docs +``` + +The app lands in `apps/docs`, and `docs` is its Nx project name. Then, from the workspace root: + +```bash +pnpm install +pnpm nx serve docs +``` + +Open `http://localhost:5173`. `pnpm nx build docs` writes the static site to `dist/apps/docs/analog/public` and the Node server to `dist/apps/docs/analog/server`. + +## Options + +| Option | What it does | +| ------------------- | ------------------------------------------------------------------------------------------------ | +| `--directory ` | Puts the app somewhere other than `apps/`, relative to the workspace root. Example: `--directory docs/site`. | +| `--nx` | Fails instead of creating a standalone project when no `nx.json` is found. | +| `--no-nx` | Creates a standalone project even inside an Nx workspace. | + +## What changes compared to a standalone project + +The app has the same `src/`, `public/`, build plugins and `vite.config.ts` as a standalone NgMd site. The differences are what an Nx app needs: + +- **`project.json`** has `build`, `serve` and `test` targets that use the same executors as the Analog generator (`@analogjs/platform:vite`, `@analogjs/platform:vite-dev-server` and `@analogjs/vitest-angular:test`), plus a `typecheck` target that runs `tsc --noEmit`. +- **Dependencies go to the workspace root `package.json`.** The scaffolder adds the ones the workspace doesn't have yet, including `@nx/vite` at your `nx` version (the Analog executors need it). It never changes a version you already have, and it lists any version that differs from NgMd's, so you can check those. +- **`vite.config.ts`** sets `root` to its own folder, builds into `dist/`, shares the workspace Vite cache and resolves the `paths` from your tsconfig, so imports from workspace libraries work. +- **`tsconfig.json`** extends the workspace `tsconfig.base.json`. +- **`site.githubDir`** in `src/ngmd.config.ts` is set to the app folder, so edit and source links point at the right files. +- **No `.gitignore`, lockfile or `angular.json`** inside the app, and no `git init`. The workspace already owns those. + + + NgMd needs Angular 22 and Vite 8. If the workspace pins older versions, the scaffolder keeps them and lists them, and the build will most likely fail until you update them (nx migrate handles Angular). + + +## Code imports in a monorepo + +Paths in ` ```ts file="..." ` blocks are relative to the app folder, the same as in a standalone site, even though Nx runs Vite from the workspace root. + +## Start from an empty Analog app + +If you already ran `nx g @analogjs/platform:application` and haven't changed the app yet, it's simpler to replace it than to merge NgMd into it. Remove the app folder, then run the scaffolder with the same name and folder: + +```bash +rm -rf apps/analog-app +pnpm create ngmd@latest analog-app --directory apps/analog-app +pnpm install +``` + +To add NgMd to an Analog app that already has your own code, follow the manual path on the [Installation](/stack/installation) page. diff --git a/src/marked-extensions/ngmd-code-import.ts b/src/marked-extensions/ngmd-code-import.ts index 39c7e31..0397a41 100644 --- a/src/marked-extensions/ngmd-code-import.ts +++ b/src/marked-extensions/ngmd-code-import.ts @@ -3,7 +3,7 @@ import type {MarkedExtension} from 'marked'; import {highlightCode} from './shiki-shared.ts'; import {escapeHtml} from './escape-html.ts'; import {findFences, getAttr, replaceFences} from './fences.ts'; -import {resolveInside} from '../../plugin-utils.ts'; +import {resolveInside, siteRoot} from '../../plugin-utils.ts'; import config from '../ngmd.config.ts'; /** @@ -30,7 +30,7 @@ const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; function loadFile(spec: string): {code: string; rangeFragment: string} { const [path, range] = spec.split('#'); - let content = readFileSync(resolveInside(process.cwd(), path), 'utf8').replace(/\r\n?/g, '\n'); + let content = readFileSync(resolveInside(siteRoot, path), 'utf8').replace(/\r\n?/g, '\n'); let rangeFragment = ''; if (range) { diff --git a/src/ngmd.config.ts b/src/ngmd.config.ts index 98d22d1..3c5230d 100644 --- a/src/ngmd.config.ts +++ b/src/ngmd.config.ts @@ -157,6 +157,7 @@ const config: NgmdConfig = { Starlight: 'https://starlight.astro.build', Docusaurus: 'https://docusaurus.io', Nextra: 'https://nextra.site', + Nx: 'https://nx.dev', }, nav: [ @@ -197,6 +198,7 @@ const config: NgmdConfig = { {label: 'Overview', href: '/stack/overview'}, {label: 'Technologies', href: '/stack/technologies'}, {label: 'Installation', href: '/stack/installation'}, + {label: 'Nx Monorepos', href: '/stack/nx', status: 'new'}, ], }, ], diff --git a/vars.plugin.ts b/vars.plugin.ts index 491f1bf..6ac101b 100644 --- a/vars.plugin.ts +++ b/vars.plugin.ts @@ -1,6 +1,7 @@ import {readFileSync} from 'node:fs'; import {join} from 'node:path'; import type {Plugin} from 'vite'; +import {siteRoot} from './plugin-utils.ts'; /** * Single source of truth for "the current published version" in markdown @@ -41,7 +42,7 @@ function readVars(root: string): Record { * are left in place so an unrecognised marker survives to the rendered * page rather than silently disappearing. */ -export function substituteMdVars(body: string, root = process.cwd()): string { +export function substituteMdVars(body: string, root = siteRoot): string { const vars = readVars(root); return body.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (match, key) => { return key in vars ? vars[key] : match; From c48d4a138fb8b165fa16b65a71307182d90e6c44 Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 2 Oct 2026 00:56:54 +0300 Subject: [PATCH 2/2] fix: reject a missing --directory value create-ngmd took the next argument as the folder even when it was missing or another flag, so --directory --nx would scaffold into a folder named --nx. It now stops with a message showing the expected form. --- create-ngmd/index.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/create-ngmd/index.mjs b/create-ngmd/index.mjs index 2d72dd7..e670929 100644 --- a/create-ngmd/index.mjs +++ b/create-ngmd/index.mjs @@ -128,8 +128,12 @@ function parseArgs(argv) { const a = argv[i]; if (a === '--nx') args.nx = true; else if (a === '--no-nx') args.nx = false; - else if (a === '--directory' || a === '--dir') args.directory = argv[++i]; - else if (a.startsWith('--directory=')) args.directory = a.slice('--directory='.length); + else if (a === '--directory' || a === '--dir') { + const value = argv[++i]; + if (!value || value.startsWith('-')) + throw new Error(`${a} needs a folder, like ${a} apps/docs`); + args.directory = value; + } else if (a.startsWith('--directory=')) args.directory = a.slice('--directory='.length); else if (a.startsWith('-')) throw new Error(`unknown option ${a}`); else if (!args.name) args.name = a; else throw new Error(`unexpected argument ${a}`);