create-ngmd 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/index.mjs +165 -0
- package/package.json +49 -0
- package/template/README.md +26 -0
- package/template/angular.json +54 -0
- package/template/index.html +47 -0
- package/template/link-guard.plugin.ts +190 -0
- package/template/package.json +76 -0
- package/template/page-meta.plugin.ts +132 -0
- package/template/public/analog.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +46 -0
- package/template/public/logo-mark.svg +46 -0
- package/template/public/logo.svg +53 -0
- package/template/public/vite.svg +1 -0
- package/template/sitemap.plugin.ts +148 -0
- package/template/src/app/app.config.server.ts +10 -0
- package/template/src/app/app.config.ts +59 -0
- package/template/src/app/app.spec.ts +20 -0
- package/template/src/app/app.ts +260 -0
- package/template/src/app/components/breadcrumb.ts +76 -0
- package/template/src/app/components/code-copy.ts +90 -0
- package/template/src/app/components/code-group.ts +70 -0
- package/template/src/app/components/command-palette.ts +299 -0
- package/template/src/app/components/external-links.ts +55 -0
- package/template/src/app/components/heading-anchors.ts +95 -0
- package/template/src/app/components/hlm-card.ts +31 -0
- package/template/src/app/components/media-enhancer.ts +95 -0
- package/template/src/app/components/page-footer.ts +107 -0
- package/template/src/app/components/sidebar.ts +65 -0
- package/template/src/app/components/toc.ts +131 -0
- package/template/src/app/layout-mode.service.ts +10 -0
- package/template/src/app/pages/[...not-found].page.ts +47 -0
- package/template/src/app/pages/index.page.ts +28 -0
- package/template/src/app/pages/welcome.page.ts +18 -0
- package/template/src/app/theme.ts +54 -0
- package/template/src/app/title-strategy.ts +48 -0
- package/template/src/app/ui/alert.ts +46 -0
- package/template/src/app/ui/callout.ts +57 -0
- package/template/src/app/ui/card.ts +41 -0
- package/template/src/app/ui/code-block.ts +76 -0
- package/template/src/app/ui/hero.ts +42 -0
- package/template/src/app/ui/image.ts +26 -0
- package/template/src/app/ui/index.ts +45 -0
- package/template/src/app/ui/pill.ts +42 -0
- package/template/src/app/ui/tabs.ts +76 -0
- package/template/src/app/ui/video.ts +40 -0
- package/template/src/app/ui/workflow.ts +51 -0
- package/template/src/content/welcome.md +19 -0
- package/template/src/main.server.ts +7 -0
- package/template/src/main.ts +6 -0
- package/template/src/marked-extensions/index.ts +47 -0
- package/template/src/marked-extensions/ngmd-code-group.ts +126 -0
- package/template/src/marked-extensions/ngmd-code-highlight.ts +102 -0
- package/template/src/marked-extensions/ngmd-code-import.ts +118 -0
- package/template/src/marked-extensions/ngmd-image.ts +41 -0
- package/template/src/marked-extensions/ngmd-keywords.ts +75 -0
- package/template/src/marked-extensions/ngmd-video.ts +48 -0
- package/template/src/marked-extensions/shiki-shared.ts +42 -0
- package/template/src/ngmd.config.ts +98 -0
- package/template/src/server/routes/api/v1/hello.ts +3 -0
- package/template/src/styles.css +347 -0
- package/template/src/test-setup.ts +6 -0
- package/template/src/vite-env.d.ts +9 -0
- package/template/tsconfig.app.json +14 -0
- package/template/tsconfig.json +34 -0
- package/template/tsconfig.spec.json +11 -0
- package/template/vite.config.ts +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# create-ngmd
|
|
2
|
+
|
|
3
|
+
Scaffold a new [NgMd](https://github.com/erkamyaman/ngmd) docs project.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm create ngmd@latest my-docs
|
|
7
|
+
# or
|
|
8
|
+
npm create ngmd@latest my-docs
|
|
9
|
+
# or
|
|
10
|
+
yarn create ngmd my-docs
|
|
11
|
+
# or
|
|
12
|
+
bun create ngmd my-docs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cd my-docs
|
|
19
|
+
pnpm install # or npm install / yarn / bun install
|
|
20
|
+
pnpm dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Open `http://localhost:5173`.
|
|
24
|
+
|
|
25
|
+
## What you get
|
|
26
|
+
|
|
27
|
+
A working AnalogJS + Tailwind v4 + Shiki docs site with:
|
|
28
|
+
|
|
29
|
+
- File-based markdown routes — drop `.md` under `src/app/pages/`, get a route
|
|
30
|
+
- Authoring components: callout, alert, card, tabs, pill row, workflow, hero, code-block, video, image (all under `src/app/ui/`)
|
|
31
|
+
- Sticky translucent header, sidebar, breadcrumb, scroll-spy TOC, Cmd+K palette
|
|
32
|
+
- Per-page footer: prev/next navigation, edit-on-github, last-updated
|
|
33
|
+
- Heading anchor copy buttons, code-block copy buttons
|
|
34
|
+
- Build-time link guards (external + internal)
|
|
35
|
+
- Sitemap + robots.txt auto-generated
|
|
36
|
+
- Light / dark / auto theme with no-flash boot script
|
|
37
|
+
- `*Keyword` inline auto-linking
|
|
38
|
+
- ` ```ts file="src/foo.ts#L5-L20" ` code imports, group code tabs, line highlighting
|
|
39
|
+
|
|
40
|
+
See the [NgMd repo](https://github.com/erkamyaman/ngmd) for the feature list.
|
|
41
|
+
|
|
42
|
+
## Next steps
|
|
43
|
+
|
|
44
|
+
1. Edit `src/content/welcome.md` to make the first page your own.
|
|
45
|
+
2. Edit `src/ngmd.config.ts` to set brand name, navigation, and accent.
|
|
46
|
+
3. Drop more `.md` files in `src/app/pages/` or `src/content/`.
|
|
47
|
+
4. Build with `pnpm run build`, deploy `dist/` to any static host.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
`index.mjs` (Node 20+, zero npm deps) copies `template/` into the target directory and rewrites a few placeholders (`package.json` name, `ngmd.config.ts` brand, `index.html` title) so the new project matches the name you passed.
|
|
52
|
+
|
|
53
|
+
`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.
|
|
54
|
+
|
|
55
|
+
To preview a scaffolded project locally without publishing:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
node create-ngmd/build-template.mjs # populate create-ngmd/template/
|
|
59
|
+
node create-ngmd/index.mjs my-docs # scaffold ./my-docs
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Licence
|
|
63
|
+
|
|
64
|
+
MIT
|
package/index.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { stdin, stdout } from 'node:process';
|
|
7
|
+
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const TEMPLATE_DIR = join(HERE, 'template');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `create-ngmd <project-name>` — scaffolds a fresh NgMd project.
|
|
13
|
+
*
|
|
14
|
+
* Behaviour:
|
|
15
|
+
* 1. Accept project name from argv[2] or interactive prompt.
|
|
16
|
+
* 2. Validate (lowercase, hyphenated, no path traversal, dir not present).
|
|
17
|
+
* 3. Copy `template/` into ./<project-name>/.
|
|
18
|
+
* 4. Replace placeholders ({{name}}) in package.json, ngmd.config.ts, index.html.
|
|
19
|
+
* 5. Print next-step commands tailored to the detected package manager.
|
|
20
|
+
*
|
|
21
|
+
* Zero npm dependencies. Node 20+ builtins only.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const c = {
|
|
25
|
+
reset: '\x1b[0m',
|
|
26
|
+
bold: '\x1b[1m',
|
|
27
|
+
dim: '\x1b[2m',
|
|
28
|
+
cyan: '\x1b[36m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
red: '\x1b[31m',
|
|
31
|
+
yellow: '\x1b[33m',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function detectPM() {
|
|
35
|
+
const ua = process.env.npm_config_user_agent ?? '';
|
|
36
|
+
if (ua.startsWith('pnpm')) return 'pnpm';
|
|
37
|
+
if (ua.startsWith('yarn')) return 'yarn';
|
|
38
|
+
if (ua.startsWith('bun')) return 'bun';
|
|
39
|
+
return 'npm';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validName(s) {
|
|
43
|
+
return /^[a-z0-9][a-z0-9._-]*$/.test(s) && !s.includes('..');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function prompt(rl, question, defaultValue) {
|
|
47
|
+
const tail = defaultValue ? ` ${c.dim}(${defaultValue})${c.reset}` : '';
|
|
48
|
+
const answer = (await rl.question(`${question}${tail}: `)).trim();
|
|
49
|
+
return answer || defaultValue || '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function replacePlaceholders(target, name) {
|
|
53
|
+
const subs = [
|
|
54
|
+
{
|
|
55
|
+
file: 'package.json',
|
|
56
|
+
replacer: (s) => s.replace(/"name":\s*"ngmd"/, `"name": "${name}"`),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
file: 'src/ngmd.config.ts',
|
|
60
|
+
replacer: (s) =>
|
|
61
|
+
s
|
|
62
|
+
.replace(/name:\s*'NgMd'/, `name: '${name}'`)
|
|
63
|
+
.replace(
|
|
64
|
+
/githubUrl:\s*'[^']*'/,
|
|
65
|
+
`githubUrl: 'https://github.com/your-org/${name}'`,
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
file: 'index.html',
|
|
70
|
+
replacer: (s) =>
|
|
71
|
+
s
|
|
72
|
+
.replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`)
|
|
73
|
+
.replace(/og:title"\s+content="[^"]*"/, `og:title" content="${name}"`),
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
for (const { file, replacer } of subs) {
|
|
78
|
+
const path = join(target, file);
|
|
79
|
+
if (!existsSync(path)) continue;
|
|
80
|
+
writeFileSync(path, replacer(readFileSync(path, 'utf8')));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function main() {
|
|
85
|
+
const argv = process.argv.slice(2);
|
|
86
|
+
const requested = argv[0];
|
|
87
|
+
|
|
88
|
+
console.log(`\n${c.bold}${c.cyan}create-ngmd${c.reset} ${c.dim}— scaffold a new NgMd project${c.reset}\n`);
|
|
89
|
+
|
|
90
|
+
if (!existsSync(TEMPLATE_DIR)) {
|
|
91
|
+
console.error(`${c.red}error:${c.reset} template directory missing at ${TEMPLATE_DIR}`);
|
|
92
|
+
console.error('Run `node create-ngmd/build-template.mjs` from the ngmd repo first.');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let name = requested;
|
|
97
|
+
if (!name) {
|
|
98
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
99
|
+
name = await prompt(rl, 'Project name', 'my-docs');
|
|
100
|
+
rl.close();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!validName(name)) {
|
|
104
|
+
console.error(
|
|
105
|
+
`${c.red}error:${c.reset} "${name}" is not a valid project name.\n` +
|
|
106
|
+
`Use lowercase letters, digits, dots, underscores, or hyphens.`,
|
|
107
|
+
);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const target = resolve(process.cwd(), name);
|
|
112
|
+
if (existsSync(target)) {
|
|
113
|
+
const isEmpty = readdirSync(target).length === 0;
|
|
114
|
+
if (!isEmpty) {
|
|
115
|
+
console.error(`${c.red}error:${c.reset} directory "${name}" already exists and is not empty.`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
mkdirSync(target, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`${c.dim}scaffolding into${c.reset} ${target}\n`);
|
|
123
|
+
|
|
124
|
+
cpSync(TEMPLATE_DIR, target, {
|
|
125
|
+
recursive: true,
|
|
126
|
+
filter: (src) => {
|
|
127
|
+
const base = src.replace(TEMPLATE_DIR, '').replace(/^[/\\]/, '');
|
|
128
|
+
// Belt-and-braces: never copy these, even if they slip into template/.
|
|
129
|
+
return !/^(node_modules|dist|\.angular|\.vite|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb)/.test(
|
|
130
|
+
base,
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// npm rewrites .gitignore → .npmignore on publish; restore the dotfile.
|
|
136
|
+
const gitignoreFromNpm = join(target, 'gitignore');
|
|
137
|
+
if (existsSync(gitignoreFromNpm)) {
|
|
138
|
+
cpSync(gitignoreFromNpm, join(target, '.gitignore'));
|
|
139
|
+
const { rmSync } = await import('node:fs');
|
|
140
|
+
rmSync(gitignoreFromNpm);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
replacePlaceholders(target, name);
|
|
144
|
+
|
|
145
|
+
const pm = detectPM();
|
|
146
|
+
const install = pm === 'yarn' ? 'yarn' : `${pm} install`;
|
|
147
|
+
const dev = pm === 'npm' ? 'npm run dev' : `${pm} dev`;
|
|
148
|
+
|
|
149
|
+
console.log(`${c.green}✓${c.reset} ${c.bold}done${c.reset}\n`);
|
|
150
|
+
console.log(`${c.bold}Next steps:${c.reset}`);
|
|
151
|
+
console.log(` ${c.cyan}cd${c.reset} ${name}`);
|
|
152
|
+
console.log(` ${c.cyan}${install}${c.reset}`);
|
|
153
|
+
console.log(` ${c.cyan}${dev}${c.reset}\n`);
|
|
154
|
+
console.log(`${c.dim}Docs:${c.reset} https://ngmd.netlify.app`);
|
|
155
|
+
console.log(`${c.dim}Issues:${c.reset} https://github.com/erkamyaman/ngmd/issues\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main().catch((err) => {
|
|
159
|
+
console.error(`\n${c.red}aborted:${c.reset} ${err.message ?? err}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function statSafe(p) {
|
|
164
|
+
try { return statSync(p); } catch { return null; }
|
|
165
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-ngmd",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Scaffold a NgMd Angular docs site. Run via `pnpm create ngmd@latest` / `npm create ngmd@latest` / `yarn create ngmd` / `bun create ngmd`.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Kam",
|
|
8
|
+
"url": "https://github.com/erkamyaman"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/erkamyaman/ngmd.git",
|
|
13
|
+
"directory": "create-ngmd"
|
|
14
|
+
},
|
|
15
|
+
"bugs": "https://github.com/erkamyaman/ngmd/issues",
|
|
16
|
+
"homepage": "https://github.com/erkamyaman/ngmd#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"angular",
|
|
19
|
+
"angular-docs",
|
|
20
|
+
"analogjs",
|
|
21
|
+
"docs",
|
|
22
|
+
"documentation",
|
|
23
|
+
"starter",
|
|
24
|
+
"scaffolder",
|
|
25
|
+
"shiki",
|
|
26
|
+
"tailwind",
|
|
27
|
+
"vite",
|
|
28
|
+
"create"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"bin": {
|
|
32
|
+
"create-ngmd": "index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build-template": "node build-template.mjs",
|
|
36
|
+
"prepublishOnly": "node build-template.mjs"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20.19.1"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"index.mjs",
|
|
43
|
+
"README.md",
|
|
44
|
+
"template"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# NgMd starter
|
|
2
|
+
|
|
3
|
+
A docs site scaffolded with `create-ngmd`.
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm install # or npm / yarn / bun
|
|
9
|
+
pnpm dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Add a page
|
|
13
|
+
|
|
14
|
+
Drop a `.md` file under `src/app/pages/` (or under `src/content/` and reference it via `injectContent`). Headings become anchors automatically.
|
|
15
|
+
|
|
16
|
+
## Build
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Output lands in `dist/`. Sitemap and `robots.txt` emit alongside the client bundle.
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
Edit `src/ngmd.config.ts` to change brand, navigation, and public URL.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"newProjectRoot": "projects",
|
|
5
|
+
"projects": {
|
|
6
|
+
"my-app": {
|
|
7
|
+
"projectType": "application",
|
|
8
|
+
"root": ".",
|
|
9
|
+
"sourceRoot": "src",
|
|
10
|
+
"prefix": "app",
|
|
11
|
+
"architect": {
|
|
12
|
+
"build": {
|
|
13
|
+
"builder": "@analogjs/platform:vite",
|
|
14
|
+
"options": {
|
|
15
|
+
"configFile": "vite.config.ts",
|
|
16
|
+
"main": "src/main.ts",
|
|
17
|
+
"outputPath": "dist/client",
|
|
18
|
+
"tsConfig": "tsconfig.app.json"
|
|
19
|
+
},
|
|
20
|
+
"defaultConfiguration": "production",
|
|
21
|
+
"configurations": {
|
|
22
|
+
"development": {
|
|
23
|
+
"mode": "development"
|
|
24
|
+
},
|
|
25
|
+
"production": {
|
|
26
|
+
"sourcemap": false,
|
|
27
|
+
"mode": "production"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"serve": {
|
|
32
|
+
"builder": "@analogjs/platform:vite-dev-server",
|
|
33
|
+
"defaultConfiguration": "development",
|
|
34
|
+
"options": {
|
|
35
|
+
"buildTarget": "my-app:build",
|
|
36
|
+
"port": 5173
|
|
37
|
+
},
|
|
38
|
+
"configurations": {
|
|
39
|
+
"development": {
|
|
40
|
+
"buildTarget": "my-app:build:development",
|
|
41
|
+
"hmr": true
|
|
42
|
+
},
|
|
43
|
+
"production": {
|
|
44
|
+
"buildTarget": "my-app:build:production"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"test": {
|
|
49
|
+
"builder": "@analogjs/vitest-angular:test"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>NgMd</title>
|
|
6
|
+
<meta
|
|
7
|
+
name="description"
|
|
8
|
+
content="Modern Angular docs-site starter built on AnalogJS, Spartan UI, and Tailwind. Drop a markdown file, get a route."
|
|
9
|
+
/>
|
|
10
|
+
<meta property="og:title" content="NgMd" />
|
|
11
|
+
<meta
|
|
12
|
+
property="og:description"
|
|
13
|
+
content="Modern Angular docs-site starter built on AnalogJS, Spartan UI, and Tailwind. Drop a markdown file, get a route."
|
|
14
|
+
/>
|
|
15
|
+
<meta property="og:image" content="/logo.svg" />
|
|
16
|
+
<base href="/" />
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
18
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
19
|
+
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
|
20
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
21
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
22
|
+
<link
|
|
23
|
+
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@500;700&display=swap"
|
|
24
|
+
rel="stylesheet"
|
|
25
|
+
/>
|
|
26
|
+
<link rel="stylesheet" href="/src/styles.css" />
|
|
27
|
+
<script>
|
|
28
|
+
(function () {
|
|
29
|
+
try {
|
|
30
|
+
var stored = localStorage.getItem('ngmd-theme');
|
|
31
|
+
var mode = stored === 'light' || stored === 'dark' ? stored : 'auto';
|
|
32
|
+
var resolved =
|
|
33
|
+
mode === 'auto'
|
|
34
|
+
? matchMedia('(prefers-color-scheme: dark)').matches
|
|
35
|
+
? 'dark'
|
|
36
|
+
: 'light'
|
|
37
|
+
: mode;
|
|
38
|
+
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
})();
|
|
41
|
+
</script>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<app-root></app-root>
|
|
45
|
+
<script type="module" src="/src/main.ts"></script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import type { Plugin } from 'vite';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build-time guard that errors on broken internal links inside markdown files.
|
|
7
|
+
*
|
|
8
|
+
* Validates three cases:
|
|
9
|
+
* - `[text](#fragment)` — fragment must be a real heading slug in the same file
|
|
10
|
+
* - `[text](/path)` — `/path` must be a known route
|
|
11
|
+
* - `[text](/path#fragment)` — both the route and the heading slug must exist
|
|
12
|
+
*
|
|
13
|
+
* Routes are discovered by scanning `src/content/*.md` (mapped via the same
|
|
14
|
+
* CONTENT_TO_ROUTE convention page-meta uses) and `src/app/pages/**\/*.page.ts`.
|
|
15
|
+
* External (`http(s)://`), mail (`mailto:`), and relative (`./foo`) links are
|
|
16
|
+
* skipped; the existing externalLinkGuard covers raw HTML external anchors.
|
|
17
|
+
*
|
|
18
|
+
* Heading slugs are computed with the same lowercase + dash + strip-punct
|
|
19
|
+
* rule the rendered TOC uses, so dev-time and runtime stay in sync.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CONTENT_TO_ROUTE: Record<string, string> = {
|
|
23
|
+
welcome: '/welcome',
|
|
24
|
+
about: '/getting-started/about',
|
|
25
|
+
changelog: '/getting-started/changelog',
|
|
26
|
+
installation: '/getting-started/installation',
|
|
27
|
+
'quick-start': '/getting-started/quick-start',
|
|
28
|
+
theming: '/concepts/theming',
|
|
29
|
+
components: '/concepts/components',
|
|
30
|
+
'markdown-routes': '/concepts/markdown-routes',
|
|
31
|
+
'stack-overview': '/stack/overview',
|
|
32
|
+
'stack-technologies': '/stack/technologies',
|
|
33
|
+
'stack-installation': '/stack/installation',
|
|
34
|
+
support: '/support',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function slugify(s: string): string {
|
|
38
|
+
return s
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[`*_~]/g, '')
|
|
41
|
+
.replace(/[^\w\s-]/g, '')
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(/\s+/g, '-');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function walkPageFiles(dir: string, root: string, out: string[] = []): string[] {
|
|
47
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
48
|
+
const full = join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
walkPageFiles(full, root, out);
|
|
51
|
+
} else if (entry.isFile() && entry.name.endsWith('.page.ts')) {
|
|
52
|
+
out.push(relative(root, full));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function routeFromPagePath(rel: string): string {
|
|
59
|
+
const trimmed = rel
|
|
60
|
+
.replace(/^src\/app\/pages\//, '')
|
|
61
|
+
.replace(/\.page\.ts$/, '');
|
|
62
|
+
if (trimmed === 'index') return '/';
|
|
63
|
+
if (trimmed.startsWith('[')) return '';
|
|
64
|
+
return '/' + trimmed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractHeadings(markdown: string): Set<string> {
|
|
68
|
+
const slugs = new Set<string>();
|
|
69
|
+
const headingRe = /^#{1,6}\s+(.+?)\s*$/gm;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = headingRe.exec(markdown)) !== null) {
|
|
72
|
+
slugs.add(slugify(m[1]));
|
|
73
|
+
}
|
|
74
|
+
return slugs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function internalLinkGuard(): Plugin {
|
|
78
|
+
let root = process.cwd();
|
|
79
|
+
// route → headings, populated lazily on first transform() call
|
|
80
|
+
const headingsByRoute = new Map<string, Set<string>>();
|
|
81
|
+
// route → source file (relative path)
|
|
82
|
+
const routes = new Map<string, string>();
|
|
83
|
+
let primed = false;
|
|
84
|
+
|
|
85
|
+
function prime(): void {
|
|
86
|
+
if (primed) return;
|
|
87
|
+
primed = true;
|
|
88
|
+
|
|
89
|
+
// .md → route
|
|
90
|
+
for (const [name, route] of Object.entries(CONTENT_TO_ROUTE)) {
|
|
91
|
+
const rel = `src/content/${name}.md`;
|
|
92
|
+
const full = join(root, rel);
|
|
93
|
+
try {
|
|
94
|
+
statSync(full);
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
routes.set(route, rel);
|
|
99
|
+
headingsByRoute.set(route, extractHeadings(readFileSync(full, 'utf8')));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// .page.ts → route (no heading scrape; just makes the route resolvable)
|
|
103
|
+
const pagesDir = join(root, 'src/app/pages');
|
|
104
|
+
try {
|
|
105
|
+
const pageFiles = walkPageFiles(pagesDir, root);
|
|
106
|
+
for (const rel of pageFiles) {
|
|
107
|
+
const route = routeFromPagePath(rel);
|
|
108
|
+
if (!route) continue;
|
|
109
|
+
if (!routes.has(route)) routes.set(route, rel);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// src/app/pages missing — fine for non-app projects
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
name: 'ngmd-internal-link-guard',
|
|
118
|
+
enforce: 'pre',
|
|
119
|
+
configResolved(cfg) {
|
|
120
|
+
root = cfg.root;
|
|
121
|
+
},
|
|
122
|
+
transform(_code, id) {
|
|
123
|
+
// Vite may append `?import` / `?raw` query suffixes
|
|
124
|
+
const cleanId = id.split('?')[0];
|
|
125
|
+
if (!cleanId.endsWith('.md')) return null;
|
|
126
|
+
prime();
|
|
127
|
+
|
|
128
|
+
const file = cleanId;
|
|
129
|
+
const content = readFileSync(file, 'utf8');
|
|
130
|
+
const ownSlugs = extractHeadings(content);
|
|
131
|
+
const issues: string[] = [];
|
|
132
|
+
|
|
133
|
+
const validate = (href: string, label: string) => {
|
|
134
|
+
if (!href) return;
|
|
135
|
+
// external / mail / relative — skip
|
|
136
|
+
if (
|
|
137
|
+
/^(https?:|mailto:|tel:|#)/.test(href) === false &&
|
|
138
|
+
!href.startsWith('/')
|
|
139
|
+
)
|
|
140
|
+
return;
|
|
141
|
+
if (/^(https?:|mailto:|tel:)/.test(href)) return;
|
|
142
|
+
|
|
143
|
+
const [path, fragment] = href.split('#');
|
|
144
|
+
if (path === '') {
|
|
145
|
+
// in-page fragment: must exist in this file
|
|
146
|
+
if (fragment && !ownSlugs.has(fragment)) {
|
|
147
|
+
issues.push(
|
|
148
|
+
` ${label} → "#${fragment}" has no matching heading in this file`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// absolute route: must be a known route
|
|
155
|
+
if (!routes.has(path)) {
|
|
156
|
+
issues.push(` ${label} → "${path}" is not a known route`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (fragment) {
|
|
160
|
+
const targetSlugs = headingsByRoute.get(path);
|
|
161
|
+
if (targetSlugs && !targetSlugs.has(fragment)) {
|
|
162
|
+
issues.push(
|
|
163
|
+
` ${label} → "${path}#${fragment}" — fragment not found in target page`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
// if targetSlugs is undefined (e.g. .page.ts route), skip fragment check
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const mdLinkRe = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
171
|
+
const htmlAnchorRe = /<a\s[^>]*href=["']([^"']+)["']/g;
|
|
172
|
+
let m: RegExpExecArray | null;
|
|
173
|
+
while ((m = mdLinkRe.exec(content)) !== null) {
|
|
174
|
+
validate(m[2], `[${m[1]}](${m[2]})`);
|
|
175
|
+
}
|
|
176
|
+
while ((m = htmlAnchorRe.exec(content)) !== null) {
|
|
177
|
+
validate(m[1], `<a href="${m[1]}">`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (issues.length > 0) {
|
|
181
|
+
this.error(
|
|
182
|
+
`[ngmd] Broken internal links in ${relative(root, file)}:\n${issues.join('\n')}\n` +
|
|
183
|
+
`Fix the link target, or update the heading slug it points to.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ngmd",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "NgMd docs site",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"angular",
|
|
8
|
+
"analogjs",
|
|
9
|
+
"spartan-ui",
|
|
10
|
+
"tailwind",
|
|
11
|
+
"docs",
|
|
12
|
+
"documentation",
|
|
13
|
+
"starter",
|
|
14
|
+
"shiki",
|
|
15
|
+
"markdown"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.19.1"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"ng": "ng",
|
|
23
|
+
"dev": "vite",
|
|
24
|
+
"start": "pnpm run dev",
|
|
25
|
+
"build": "vite build",
|
|
26
|
+
"watch": "vite build --watch",
|
|
27
|
+
"test": "vitest",
|
|
28
|
+
"preview": "node dist/analog/server/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@analogjs/content": "^2.5.1",
|
|
32
|
+
"@analogjs/router": "^2.5.1",
|
|
33
|
+
"@angular/animations": "^21.0.0",
|
|
34
|
+
"@angular/common": "^21.0.0",
|
|
35
|
+
"@angular/compiler": "^21.0.0",
|
|
36
|
+
"@angular/core": "^21.0.0",
|
|
37
|
+
"@angular/forms": "^21.0.0",
|
|
38
|
+
"@angular/platform-browser": "^21.0.0",
|
|
39
|
+
"@angular/platform-server": "^21.0.0",
|
|
40
|
+
"@angular/router": "^21.0.0",
|
|
41
|
+
"@spartan-ng/brain": "0.0.1-alpha.694",
|
|
42
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
43
|
+
"@tailwindcss/vite": "^4.1.4",
|
|
44
|
+
"front-matter": "^4.0.2",
|
|
45
|
+
"h3": "^1.13.0",
|
|
46
|
+
"lucide-angular": "^0.577.0",
|
|
47
|
+
"marked": "^15.0.7",
|
|
48
|
+
"marked-gfm-heading-id": "^4.1.1",
|
|
49
|
+
"marked-highlight": "^2.2.1",
|
|
50
|
+
"marked-mangle": "^1.1.10",
|
|
51
|
+
"marked-shiki": "^1.2.1",
|
|
52
|
+
"postcss": "^8.5.3",
|
|
53
|
+
"prismjs": "^1.29.0",
|
|
54
|
+
"rxjs": "~7.8.0",
|
|
55
|
+
"shiki": "^1.29.2",
|
|
56
|
+
"tailwindcss": "^4.1.4",
|
|
57
|
+
"tslib": "^2.3.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@analogjs/platform": "^2.5.1",
|
|
61
|
+
"@analogjs/vite-plugin-angular": "^2.5.1",
|
|
62
|
+
"@analogjs/vitest-angular": "^2.5.1",
|
|
63
|
+
"@angular/build": "^21.0.0",
|
|
64
|
+
"@angular/cli": "^21.0.0",
|
|
65
|
+
"@angular/compiler-cli": "^21.0.0",
|
|
66
|
+
"jsdom": "^22.0.0",
|
|
67
|
+
"typescript": "~5.9.0",
|
|
68
|
+
"vite": "^8.0.0",
|
|
69
|
+
"vite-tsconfig-paths": "^4.2.0",
|
|
70
|
+
"vitest": "^4.1.0"
|
|
71
|
+
},
|
|
72
|
+
"overrides": {
|
|
73
|
+
"vite": "$vite",
|
|
74
|
+
"vitest": "$vitest"
|
|
75
|
+
}
|
|
76
|
+
}
|