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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`, 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 <dir>` 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.
Expand Down
11 changes: 11 additions & 0 deletions build-extensions.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion create-ngmd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
97 changes: 89 additions & 8 deletions create-ngmd/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project-name>` — scaffolds a fresh NgMd project.
* `create-ngmd <project-name> [--nx | --no-nx] [--directory <dir>]` — 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 ./<project-name>/.
* 3. Copy `template/` into ./<project-name>/, or into <dir> (default
* apps/<project-name>) 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.
*/
Expand Down Expand Up @@ -118,9 +122,38 @@ 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') {
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}`);
}
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`,
Expand Down Expand Up @@ -149,12 +182,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);
}
Expand Down Expand Up @@ -189,6 +248,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}`);
Expand Down
Loading
Loading