create-ngmd 0.0.1 → 0.0.3
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 +4 -0
- package/package.json +1 -1
- package/template/README.md +1 -1
- package/template/link-guard.plugin.ts +38 -27
- package/template/package.json +1 -0
- package/template/page-meta.plugin.ts +45 -29
- package/template/sitemap.plugin.ts +36 -25
- package/template/src/app/app.ts +4 -1
- package/template/src/app/components/page-footer.ts +1 -1
- package/template/src/app/components/toc.ts +29 -1
- package/template/src/app/layout-mode.service.ts +2 -2
- package/template/src/app/pages/[...slug].page.ts +79 -0
- package/template/src/app/ui/index.ts +1 -1
- package/template/src/content/welcome.md +2 -2
- package/template/src/marked-extensions/ngmd-keywords.ts +5 -2
- package/template/src/styles.css +44 -0
- package/template/src/app/pages/[...not-found].page.ts +0 -47
- package/template/src/app/pages/welcome.page.ts +0 -18
package/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# create-ngmd
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/create-ngmd)
|
|
4
|
+
|
|
3
5
|
Scaffold a new [NgMd](https://github.com/erkamyaman/ngmd) docs project.
|
|
4
6
|
|
|
7
|
+
Live demo: [ngmd.netlify.app](https://ngmd.netlify.app)
|
|
8
|
+
|
|
5
9
|
```bash
|
|
6
10
|
pnpm create ngmd@latest my-docs
|
|
7
11
|
# or
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -11,7 +11,7 @@ pnpm dev
|
|
|
11
11
|
|
|
12
12
|
## Add a page
|
|
13
13
|
|
|
14
|
-
Drop a `.md` file under `src/
|
|
14
|
+
Drop a `.md` file under `src/content/`. The path becomes the URL: `src/content/install.md` resolves at `/install`, `src/content/guides/auth.md` at `/guides/auth`. No `.page.ts` wrapper needed; the catch-all at `src/app/pages/[...slug].page.ts` renders every prose route.
|
|
15
15
|
|
|
16
16
|
## Build
|
|
17
17
|
|
|
@@ -10,8 +10,8 @@ import type { Plugin } from 'vite';
|
|
|
10
10
|
* - `[text](/path)` — `/path` must be a known route
|
|
11
11
|
* - `[text](/path#fragment)` — both the route and the heading slug must exist
|
|
12
12
|
*
|
|
13
|
-
* Routes are discovered by
|
|
14
|
-
*
|
|
13
|
+
* Routes are discovered by walking `src/content/**\/*.md` (each markdown
|
|
14
|
+
* file's path under content/ becomes its route) and `src/app/pages/**\/*.page.ts`.
|
|
15
15
|
* External (`http(s)://`), mail (`mailto:`), and relative (`./foo`) links are
|
|
16
16
|
* skipped; the existing externalLinkGuard covers raw HTML external anchors.
|
|
17
17
|
*
|
|
@@ -19,21 +19,6 @@ import type { Plugin } from 'vite';
|
|
|
19
19
|
* rule the rendered TOC uses, so dev-time and runtime stay in sync.
|
|
20
20
|
*/
|
|
21
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
22
|
function slugify(s: string): string {
|
|
38
23
|
return s
|
|
39
24
|
.toLowerCase()
|
|
@@ -55,6 +40,32 @@ function walkPageFiles(dir: string, root: string, out: string[] = []): string[]
|
|
|
55
40
|
return out;
|
|
56
41
|
}
|
|
57
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Walk `src/content/**\/*.md` and return `[relativePath, route]` pairs.
|
|
45
|
+
* Route mirrors the path under `src/content/` with the .md stripped.
|
|
46
|
+
* Example: `src/content/concepts/theming.md` → `/concepts/theming`.
|
|
47
|
+
*/
|
|
48
|
+
function walkContentFiles(
|
|
49
|
+
dir: string,
|
|
50
|
+
root: string,
|
|
51
|
+
baseDir: string = dir,
|
|
52
|
+
out: Array<[string, string]> = [],
|
|
53
|
+
): Array<[string, string]> {
|
|
54
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
const full = join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
walkContentFiles(full, root, baseDir, out);
|
|
58
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
59
|
+
const rel = relative(root, full);
|
|
60
|
+
const fromContent = relative(baseDir, full)
|
|
61
|
+
.replace(/\\/g, '/')
|
|
62
|
+
.replace(/\.md$/, '');
|
|
63
|
+
out.push([rel, '/' + fromContent]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
function routeFromPagePath(rel: string): string {
|
|
59
70
|
const trimmed = rel
|
|
60
71
|
.replace(/^src\/app\/pages\//, '')
|
|
@@ -86,17 +97,17 @@ export function internalLinkGuard(): Plugin {
|
|
|
86
97
|
if (primed) return;
|
|
87
98
|
primed = true;
|
|
88
99
|
|
|
89
|
-
// .md → route
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
// .md → route (walk src/content/ tree)
|
|
101
|
+
const contentDir = join(root, 'src/content');
|
|
102
|
+
try {
|
|
103
|
+
statSync(contentDir);
|
|
104
|
+
for (const [rel, route] of walkContentFiles(contentDir, root)) {
|
|
105
|
+
const full = join(root, rel);
|
|
106
|
+
routes.set(route, rel);
|
|
107
|
+
headingsByRoute.set(route, extractHeadings(readFileSync(full, 'utf8')));
|
|
97
108
|
}
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
} catch {
|
|
110
|
+
// src/content missing — skip
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
// .page.ts → route (no heading scrape; just makes the route resolvable)
|
package/template/package.json
CHANGED
|
@@ -6,10 +6,11 @@ import type { Plugin } from 'vite';
|
|
|
6
6
|
/**
|
|
7
7
|
* Build-time map of page URL → { editUrl, lastUpdated }.
|
|
8
8
|
*
|
|
9
|
-
* Walks `src/app/pages` (for `.page.ts` routes) and `src/content
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `virtual:ngmd/page-meta`
|
|
9
|
+
* Walks `src/app/pages` (for `.page.ts` routes) and `src/content/**\/*.md`
|
|
10
|
+
* (each markdown file's path under content/ becomes its route, matching the
|
|
11
|
+
* `[...slug].page.ts` catch-all), pulls the latest commit date via `git log`,
|
|
12
|
+
* and emits a typed module under the virtual id `virtual:ngmd/page-meta`
|
|
13
|
+
* which the runtime imports.
|
|
13
14
|
*
|
|
14
15
|
* If the file is uncommitted, lastUpdated falls back to its mtime in ISO
|
|
15
16
|
* date form so dev iteration still shows something.
|
|
@@ -64,21 +65,31 @@ function routeFromPagePath(rel: string): string {
|
|
|
64
65
|
return '/' + trimmed;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Walk `src/content/**\/*.md` and return `[relativePath, route]` pairs.
|
|
70
|
+
* Route mirrors the path under `src/content/` with the .md stripped.
|
|
71
|
+
* Example: `src/content/concepts/theming.md` → `/concepts/theming`.
|
|
72
|
+
*/
|
|
73
|
+
function walkContentFiles(
|
|
74
|
+
dir: string,
|
|
75
|
+
root: string,
|
|
76
|
+
baseDir: string = dir,
|
|
77
|
+
out: Array<[string, string]> = [],
|
|
78
|
+
): Array<[string, string]> {
|
|
79
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
80
|
+
const full = join(dir, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
walkContentFiles(full, root, baseDir, out);
|
|
83
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
84
|
+
const rel = relative(root, full);
|
|
85
|
+
const fromContent = relative(baseDir, full)
|
|
86
|
+
.replace(/\\/g, '/')
|
|
87
|
+
.replace(/\.md$/, '');
|
|
88
|
+
out.push([rel, '/' + fromContent]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
82
93
|
|
|
83
94
|
export function pageMetaPlugin(opts: {
|
|
84
95
|
repoUrl: string;
|
|
@@ -114,16 +125,21 @@ export function pageMetaPlugin(opts: {
|
|
|
114
125
|
};
|
|
115
126
|
}
|
|
116
127
|
|
|
117
|
-
// src/content
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
// src/content/**/*.md → route (mirrors the [...slug] catch-all)
|
|
129
|
+
const contentDir = join(root, 'src/content');
|
|
130
|
+
try {
|
|
131
|
+
statSync(contentDir);
|
|
132
|
+
for (const [rel, route] of walkContentFiles(contentDir, root)) {
|
|
133
|
+
const date = gitDate(rel, root);
|
|
134
|
+
if (!date) continue;
|
|
135
|
+
// .md edit URL wins when present (more useful for prose pages)
|
|
136
|
+
map[route] = {
|
|
137
|
+
editUrl: `${opts.repoUrl}/edit/${branch}/${rel}`,
|
|
138
|
+
lastUpdated: date,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// src/content missing — skip
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
return `export const pageMeta = ${JSON.stringify(map, null, 2)};`;
|
|
@@ -7,28 +7,39 @@ import type { Plugin } from 'vite';
|
|
|
7
7
|
* Emits `sitemap.xml` and `robots.txt` into the client build output.
|
|
8
8
|
*
|
|
9
9
|
* Discovery mirrors the page-meta plugin: walks `src/app/pages/*.page.ts` and
|
|
10
|
-
* `src/content
|
|
11
|
-
* commit date via `git log -1 --format=%cs` to populate
|
|
12
|
-
* back to mtime for uncommitted files, and writes both
|
|
13
|
-
* `emitFile` so they land at the client root.
|
|
10
|
+
* `src/content/**\/*.md` (each markdown file's path becomes its route), pulls
|
|
11
|
+
* each file's last commit date via `git log -1 --format=%cs` to populate
|
|
12
|
+
* `<lastmod>`, falls back to mtime for uncommitted files, and writes both
|
|
13
|
+
* files via Rollup's `emitFile` so they land at the client root.
|
|
14
14
|
*
|
|
15
15
|
* `robots.txt` is a one-liner pointing at the sitemap.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Walk `src/content/**\/*.md` and return `[relativePath, route]` pairs.
|
|
20
|
+
* Route mirrors the path under `src/content/` with the .md stripped.
|
|
21
|
+
* Example: `src/content/concepts/theming.md` → `/concepts/theming`.
|
|
22
|
+
*/
|
|
23
|
+
function walkContentFiles(
|
|
24
|
+
dir: string,
|
|
25
|
+
root: string,
|
|
26
|
+
baseDir: string = dir,
|
|
27
|
+
out: Array<[string, string]> = [],
|
|
28
|
+
): Array<[string, string]> {
|
|
29
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
const full = join(dir, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
walkContentFiles(full, root, baseDir, out);
|
|
33
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
34
|
+
const rel = relative(root, full);
|
|
35
|
+
const fromContent = relative(baseDir, full)
|
|
36
|
+
.replace(/\\/g, '/')
|
|
37
|
+
.replace(/\.md$/, '');
|
|
38
|
+
out.push([rel, '/' + fromContent]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
32
43
|
|
|
33
44
|
function gitDate(file: string, cwd: string): string {
|
|
34
45
|
try {
|
|
@@ -107,14 +118,14 @@ export function sitemapPlugin(opts: { siteUrl: string }): Plugin {
|
|
|
107
118
|
// src/app/pages missing — fine
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
continue;
|
|
121
|
+
const contentDir = join(root, 'src/content');
|
|
122
|
+
try {
|
|
123
|
+
statSync(contentDir);
|
|
124
|
+
for (const [rel, route] of walkContentFiles(contentDir, root)) {
|
|
125
|
+
entries.set(route, gitDate(rel, root));
|
|
116
126
|
}
|
|
117
|
-
|
|
127
|
+
} catch {
|
|
128
|
+
// src/content missing — skip
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
const urls = [...entries.entries()]
|
package/template/src/app/app.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from 'lucide-angular';
|
|
15
15
|
import { ThemeService } from './theme';
|
|
16
16
|
import { LayoutMode } from './layout-mode.service';
|
|
17
|
+
import siteConfig from '../ngmd.config';
|
|
17
18
|
import { CommandPalette } from './components/command-palette';
|
|
18
19
|
import { Sidebar } from './components/sidebar';
|
|
19
20
|
import { Breadcrumb } from './components/breadcrumb';
|
|
@@ -114,7 +115,7 @@ import { MediaEnhancer } from './components/media-enhancer';
|
|
|
114
115
|
</button>
|
|
115
116
|
<span class="h-4 w-px bg-zinc-300/60 dark:bg-zinc-700/60"></span>
|
|
116
117
|
<a
|
|
117
|
-
href="
|
|
118
|
+
[href]="githubUrl"
|
|
118
119
|
target="_blank"
|
|
119
120
|
rel="noopener noreferrer"
|
|
120
121
|
class="rounded p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
|
@@ -224,6 +225,8 @@ export class App implements OnInit {
|
|
|
224
225
|
readonly moonIcon = Moon;
|
|
225
226
|
readonly autoIcon = SunMoon;
|
|
226
227
|
|
|
228
|
+
readonly githubUrl = siteConfig.site.githubUrl;
|
|
229
|
+
|
|
227
230
|
readonly drawerOpen = signal(false);
|
|
228
231
|
|
|
229
232
|
private readonly url = toSignal(
|
|
@@ -7,7 +7,7 @@ import { pageMeta } from 'virtual:ngmd/page-meta';
|
|
|
7
7
|
import { navItems } from '../../ngmd.config';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Bottom-of-page
|
|
10
|
+
* Bottom-of-page frame shown under every docs route: previous/next sibling
|
|
11
11
|
* pages derived from `ngmd.config.ts`, an "Edit on GitHub" link, and the
|
|
12
12
|
* page's last-updated date (commit cs from `git log`, baked at build time
|
|
13
13
|
* via the page-meta vite plugin).
|
|
@@ -70,6 +70,12 @@ export class Toc implements AfterViewInit {
|
|
|
70
70
|
const el = document.getElementById(id);
|
|
71
71
|
if (el) {
|
|
72
72
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
73
|
+
// Force-activate the clicked id. The IntersectionObserver uses a
|
|
74
|
+
// `rootMargin: '0px 0px -70% 0px'` so only the top 30% of viewport
|
|
75
|
+
// counts as "in view"; the LAST heading can't reach that region if
|
|
76
|
+
// there isn't enough content below it, leaving scroll-spy stuck on
|
|
77
|
+
// an earlier heading. Setting active directly here bypasses that.
|
|
78
|
+
this.active.set(id);
|
|
73
79
|
// index.html has <base href="/">, so a relative `#frag` resolves to
|
|
74
80
|
// `/#frag` and strips the path. Pass the full path explicitly.
|
|
75
81
|
history.replaceState(
|
|
@@ -82,9 +88,13 @@ export class Toc implements AfterViewInit {
|
|
|
82
88
|
|
|
83
89
|
private scanWithRetry(attempt = 0): void {
|
|
84
90
|
if (typeof document === 'undefined' || attempt > 20) return;
|
|
91
|
+
// Prefer the markdown wrappers for content-driven pages; fall back to
|
|
92
|
+
// `main article` for TS-driven pages (components.page.ts, etc.) that
|
|
93
|
+
// render Angular templates directly without analog-markdown.
|
|
85
94
|
const content =
|
|
86
95
|
document.querySelector('main analog-markdown') ??
|
|
87
|
-
document.querySelector('main analog-markdown-route')
|
|
96
|
+
document.querySelector('main analog-markdown-route') ??
|
|
97
|
+
document.querySelector('main article');
|
|
88
98
|
if (!content || content.querySelectorAll('h2, h3').length === 0) {
|
|
89
99
|
setTimeout(() => this.scanWithRetry(attempt + 1), 50);
|
|
90
100
|
return;
|
|
@@ -127,5 +137,23 @@ export class Toc implements AfterViewInit {
|
|
|
127
137
|
{ rootMargin: '0px 0px -70% 0px', threshold: 0 },
|
|
128
138
|
);
|
|
129
139
|
nodes.forEach((node) => this.observer!.observe(node));
|
|
140
|
+
|
|
141
|
+
// Bottom-of-page guard: when the user scrolls within ~100px of the
|
|
142
|
+
// bottom of the document, force-activate the last heading. The
|
|
143
|
+
// IntersectionObserver alone can't reach this state because the last
|
|
144
|
+
// heading never enters the top 30% of the viewport if there's not
|
|
145
|
+
// enough content below it.
|
|
146
|
+
const last = nodes[nodes.length - 1];
|
|
147
|
+
const onScroll = () => {
|
|
148
|
+
const scrolled = window.innerHeight + window.scrollY;
|
|
149
|
+
const fullHeight = document.documentElement.scrollHeight;
|
|
150
|
+
if (scrolled >= fullHeight - 100) {
|
|
151
|
+
this.active.set(last.id);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
155
|
+
this.destroyRef.onDestroy(() =>
|
|
156
|
+
window.removeEventListener('scroll', onScroll),
|
|
157
|
+
);
|
|
130
158
|
}
|
|
131
159
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Injectable, signal } from '@angular/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Pages that want to render without the docs
|
|
5
|
-
* can flip this signal in their constructor. The app shell reads it.
|
|
4
|
+
* Pages that want to render without the docs site frame (sidebar, breadcrumb,
|
|
5
|
+
* TOC) can flip this signal in their constructor. The app shell reads it.
|
|
6
6
|
*/
|
|
7
7
|
@Injectable({ providedIn: 'root' })
|
|
8
8
|
export class LayoutMode {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AsyncPipe } from '@angular/common';
|
|
2
|
+
import { Component, OnDestroy, computed, effect, inject } from '@angular/core';
|
|
3
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { RouterLink } from '@angular/router';
|
|
5
|
+
import { injectContent, MarkdownComponent } from '@analogjs/content';
|
|
6
|
+
import { LayoutMode } from '../layout-mode.service';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Catch-all route for every markdown page.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors the adev pattern: one shared component renders every prose route.
|
|
12
|
+
* `injectContent()` reads the `slug` route param (which the `[...slug]`
|
|
13
|
+
* convention populates with the full nested path) and looks up the matching
|
|
14
|
+
* `src/content/<slug>.md`. Pages that need bespoke layouts (the landing
|
|
15
|
+
* page, the components gallery) keep their named `.page.ts` and Angular's
|
|
16
|
+
* router prefers the more specific match.
|
|
17
|
+
*
|
|
18
|
+
* Doubles as the 404 page: when no markdown matches the requested URL,
|
|
19
|
+
* `injectContent` returns the sentinel below as the body, the docs chrome
|
|
20
|
+
* is hidden, and the 404 UI is rendered instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const NOT_FOUND = '__ngmd-not-found__';
|
|
24
|
+
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'app-doc',
|
|
27
|
+
imports: [AsyncPipe, MarkdownComponent, RouterLink],
|
|
28
|
+
template: `
|
|
29
|
+
@if (content$ | async; as doc) {
|
|
30
|
+
@if (doc.content === notFound) {
|
|
31
|
+
<section class="mx-auto max-w-2xl px-6 py-24 text-center">
|
|
32
|
+
<p class="text-sm font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500">
|
|
33
|
+
404
|
|
34
|
+
</p>
|
|
35
|
+
<h1 class="mt-3 text-4xl sm:text-5xl font-bold tracking-tight">
|
|
36
|
+
Page not found
|
|
37
|
+
</h1>
|
|
38
|
+
<p class="mt-4 text-lg text-zinc-600 dark:text-zinc-400">
|
|
39
|
+
The page you're looking for doesn't exist or has moved.
|
|
40
|
+
</p>
|
|
41
|
+
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
|
42
|
+
<a
|
|
43
|
+
routerLink="/"
|
|
44
|
+
class="rounded-md bg-zinc-900 dark:bg-zinc-50 px-5 py-2.5 text-sm font-medium text-zinc-50 dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200"
|
|
45
|
+
>
|
|
46
|
+
Go home
|
|
47
|
+
</a>
|
|
48
|
+
<a
|
|
49
|
+
routerLink="/welcome"
|
|
50
|
+
class="rounded-md border border-zinc-200 dark:border-zinc-800 px-5 py-2.5 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
|
51
|
+
>
|
|
52
|
+
Read the docs
|
|
53
|
+
</a>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
} @else {
|
|
57
|
+
<article class="max-w-3xl mx-auto p-8">
|
|
58
|
+
<analog-markdown [content]="doc.content" />
|
|
59
|
+
</article>
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`,
|
|
63
|
+
})
|
|
64
|
+
export default class DocPage implements OnDestroy {
|
|
65
|
+
private readonly layout = inject(LayoutMode);
|
|
66
|
+
protected readonly notFound = NOT_FOUND;
|
|
67
|
+
|
|
68
|
+
readonly content$ = injectContent<{ title: string }>('slug', NOT_FOUND);
|
|
69
|
+
private readonly doc = toSignal(this.content$);
|
|
70
|
+
private readonly missing = computed(() => this.doc()?.content === NOT_FOUND);
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
effect(() => this.layout.chromeHidden.set(this.missing()));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ngOnDestroy(): void {
|
|
77
|
+
this.layout.chromeHidden.set(false);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -21,7 +21,7 @@ import { NgmdTab, NgmdTabs } from './tabs';
|
|
|
21
21
|
import { NgmdVideo } from './video';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Spread into a page's `imports` to get every
|
|
24
|
+
* Spread into a page's `imports` to get every authoring component in one go:
|
|
25
25
|
* `imports: [...NgmdUi]`. For lighter pages, import only what you use.
|
|
26
26
|
*
|
|
27
27
|
* Not declared `as const`: Angular's standalone-component compiler needs
|
|
@@ -8,7 +8,7 @@ This is your first docs page, rendered from `src/content/welcome.md`.
|
|
|
8
8
|
|
|
9
9
|
## Add a page
|
|
10
10
|
|
|
11
|
-
Drop a new `.md` file under `src/content
|
|
11
|
+
Drop a new `.md` file under `src/content/`. The path becomes the URL: `src/content/install.md` resolves at `/install`, `src/content/guides/auth.md` at `/guides/auth`. The catch-all at `src/app/pages/[...slug].page.ts` renders every prose route, no wrapper required.
|
|
12
12
|
|
|
13
13
|
## Add nav
|
|
14
14
|
|
|
@@ -16,4 +16,4 @@ Edit the `nav` array in `src/ngmd.config.ts`. Sidebar, command palette, breadcru
|
|
|
16
16
|
|
|
17
17
|
## Authoring components
|
|
18
18
|
|
|
19
|
-
NgMd ships a small
|
|
19
|
+
NgMd ships a small authoring component library under `src/app/ui/`: callouts, alerts, cards, tabs (Spartan brain), pill rows, workflows, hero, and a code block with shiki highlighting. Compose them in a `.page.ts` route for pages that need bespoke layout; for prose pages, stick with markdown.
|
|
@@ -22,8 +22,11 @@ interface NgmdKeywordToken extends Tokens.Generic {
|
|
|
22
22
|
url: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// `(?!\*)` after the leading `*` prevents matching the second `*` of a
|
|
26
|
+
// `**bold**` pair. `(?!\*)` after the keyword prevents matching the inside
|
|
27
|
+
// of `**Keyword**` (which would leave one stray `*` and one stray `**`).
|
|
28
|
+
const KEYWORD_RE = /^\*(?!\*)([A-Z][a-zA-Z0-9]+)\b(?!\*)/;
|
|
29
|
+
const HINT_RE = /\*(?!\*)[A-Z]/;
|
|
27
30
|
const warned = new Set<string>();
|
|
28
31
|
|
|
29
32
|
function lookup(keyword: string): string | undefined {
|
package/template/src/styles.css
CHANGED
|
@@ -8,6 +8,50 @@
|
|
|
8
8
|
animation-duration: 150ms;
|
|
9
9
|
animation-timing-function: ease;
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/* Hero title animation. The gradient span slowly shifts horizontally so the
|
|
13
|
+
* fuchsia core appears to pulse through the text. Whole h1 fades up on
|
|
14
|
+
* first paint. Honours prefers-reduced-motion. */
|
|
15
|
+
@keyframes ngmd-hero-fade-in {
|
|
16
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
17
|
+
to { opacity: 1; transform: translateY(0); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@keyframes ngmd-hero-gradient-flow {
|
|
21
|
+
from { background-position: 200% 50%; }
|
|
22
|
+
to { background-position: -100% 50%; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ngmd-hero-fade {
|
|
26
|
+
animation: ngmd-hero-fade-in 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Hide hero words and the gradient line at first paint so SSR HTML doesn't
|
|
30
|
+
* flash before motion takes over. Motion sets opacity:1 + translateY(0)
|
|
31
|
+
* via inline styles, which override these defaults. */
|
|
32
|
+
.ngmd-hero-anim {
|
|
33
|
+
opacity: 0;
|
|
34
|
+
transform: translateY(0.5em);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@media (prefers-reduced-motion: reduce) {
|
|
38
|
+
.ngmd-hero-anim {
|
|
39
|
+
opacity: 1;
|
|
40
|
+
transform: none;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ngmd-hero-gradient {
|
|
45
|
+
background-size: 300% auto;
|
|
46
|
+
animation: ngmd-hero-gradient-flow 5s linear infinite;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@media (prefers-reduced-motion: reduce) {
|
|
50
|
+
.ngmd-hero-fade,
|
|
51
|
+
.ngmd-hero-gradient {
|
|
52
|
+
animation: none;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
11
55
|
@plugin '@tailwindcss/typography';
|
|
12
56
|
|
|
13
57
|
@variant dark (&:where(.dark, .dark *));
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
|
2
|
-
import { RouterLink } from '@angular/router';
|
|
3
|
-
import { LayoutMode } from '../layout-mode.service';
|
|
4
|
-
|
|
5
|
-
@Component({
|
|
6
|
-
selector: 'app-not-found',
|
|
7
|
-
imports: [RouterLink],
|
|
8
|
-
template: `
|
|
9
|
-
<section class="mx-auto max-w-2xl px-6 py-24 text-center">
|
|
10
|
-
<p class="text-sm font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500">
|
|
11
|
-
404
|
|
12
|
-
</p>
|
|
13
|
-
<h1 class="mt-3 text-4xl sm:text-5xl font-bold tracking-tight">
|
|
14
|
-
Page not found
|
|
15
|
-
</h1>
|
|
16
|
-
<p class="mt-4 text-lg text-zinc-600 dark:text-zinc-400">
|
|
17
|
-
The page you're looking for doesn't exist or has moved.
|
|
18
|
-
</p>
|
|
19
|
-
|
|
20
|
-
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
|
21
|
-
<a
|
|
22
|
-
routerLink="/"
|
|
23
|
-
class="rounded-md bg-zinc-900 dark:bg-zinc-50 px-5 py-2.5 text-sm font-medium text-zinc-50 dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200"
|
|
24
|
-
>
|
|
25
|
-
Go home
|
|
26
|
-
</a>
|
|
27
|
-
<a
|
|
28
|
-
routerLink="/welcome"
|
|
29
|
-
class="rounded-md border border-zinc-200 dark:border-zinc-800 px-5 py-2.5 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
|
30
|
-
>
|
|
31
|
-
Read the docs
|
|
32
|
-
</a>
|
|
33
|
-
</div>
|
|
34
|
-
</section>
|
|
35
|
-
`,
|
|
36
|
-
})
|
|
37
|
-
export default class NotFoundPage implements OnInit, OnDestroy {
|
|
38
|
-
private readonly layout = inject(LayoutMode);
|
|
39
|
-
|
|
40
|
-
ngOnInit(): void {
|
|
41
|
-
this.layout.chromeHidden.set(true);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
ngOnDestroy(): void {
|
|
45
|
-
this.layout.chromeHidden.set(false);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { AsyncPipe } from '@angular/common';
|
|
2
|
-
import { Component } from '@angular/core';
|
|
3
|
-
import { injectContent, MarkdownComponent } from '@analogjs/content';
|
|
4
|
-
|
|
5
|
-
@Component({
|
|
6
|
-
selector: 'app-welcome',
|
|
7
|
-
imports: [AsyncPipe, MarkdownComponent],
|
|
8
|
-
template: `
|
|
9
|
-
@if (content$ | async; as doc) {
|
|
10
|
-
<article class="max-w-3xl mx-auto p-8">
|
|
11
|
-
<analog-markdown [content]="doc.content" />
|
|
12
|
-
</article>
|
|
13
|
-
}
|
|
14
|
-
`,
|
|
15
|
-
})
|
|
16
|
-
export default class WelcomePage {
|
|
17
|
-
readonly content$ = injectContent<{ title: string }>({ customFilename: 'welcome' });
|
|
18
|
-
}
|