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 CHANGED
@@ -1,7 +1,11 @@
1
1
  # create-ngmd
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/create-ngmd.svg)](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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ngmd",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
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
5
  "license": "MIT",
6
6
  "author": {
@@ -11,7 +11,7 @@ pnpm dev
11
11
 
12
12
  ## Add a page
13
13
 
14
- Drop a `.md` file under `src/app/pages/` (or under `src/content/` and reference it via `injectContent`). Headings become anchors automatically.
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 scanning `src/content/*.md` (mapped via the same
14
- * CONTENT_TO_ROUTE convention page-meta uses) and `src/app/pages/**\/*.page.ts`.
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
- 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;
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
- routes.set(route, rel);
99
- headingsByRoute.set(route, extractHeadings(readFileSync(full, 'utf8')));
109
+ } catch {
110
+ // src/content missing — skip
100
111
  }
101
112
 
102
113
  // .page.ts → route (no heading scrape; just makes the route resolvable)
@@ -49,6 +49,7 @@
49
49
  "marked-highlight": "^2.2.1",
50
50
  "marked-mangle": "^1.1.10",
51
51
  "marked-shiki": "^1.2.1",
52
+ "motion": "^12.40.0",
52
53
  "postcss": "^8.5.3",
53
54
  "prismjs": "^1.29.0",
54
55
  "rxjs": "~7.8.0",
@@ -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` (for `.md`
10
- * referenced by `injectContent({ customFilename })`), pulls the latest commit
11
- * date via `git log`, and emits a typed module under the virtual id
12
- * `virtual:ngmd/page-meta` which the runtime imports.
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
- const CONTENT_TO_ROUTE: Record<string, string> = {
68
- // Maps customFilename (without .md) page route for content-driven pages.
69
- welcome: '/welcome',
70
- about: '/getting-started/about',
71
- changelog: '/getting-started/changelog',
72
- installation: '/getting-started/installation',
73
- 'quick-start': '/getting-started/quick-start',
74
- theming: '/concepts/theming',
75
- components: '/concepts/components',
76
- 'markdown-routes': '/concepts/markdown-routes',
77
- 'stack-overview': '/stack/overview',
78
- 'stack-technologies': '/stack/technologies',
79
- 'stack-installation': '/stack/installation',
80
- support: '/support',
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/<name>.md → route via CONTENT_TO_ROUTE
118
- for (const [name, route] of Object.entries(CONTENT_TO_ROUTE)) {
119
- const rel = `src/content/${name}.md`;
120
- const date = gitDate(rel, root);
121
- if (!date) continue;
122
- // .md edit URL wins when present (more useful for prose pages)
123
- map[route] = {
124
- editUrl: `${opts.repoUrl}/edit/${branch}/${rel}`,
125
- lastUpdated: date,
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/*.md` (mapped via CONTENT_TO_ROUTE), pulls each file's last
11
- * commit date via `git log -1 --format=%cs` to populate `<lastmod>`, falls
12
- * back to mtime for uncommitted files, and writes both files via Rollup's
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
- const CONTENT_TO_ROUTE: Record<string, string> = {
19
- welcome: '/welcome',
20
- about: '/getting-started/about',
21
- changelog: '/getting-started/changelog',
22
- installation: '/getting-started/installation',
23
- 'quick-start': '/getting-started/quick-start',
24
- theming: '/concepts/theming',
25
- components: '/concepts/components',
26
- 'markdown-routes': '/concepts/markdown-routes',
27
- 'stack-overview': '/stack/overview',
28
- 'stack-technologies': '/stack/technologies',
29
- 'stack-installation': '/stack/installation',
30
- support: '/support',
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
- for (const [name, route] of Object.entries(CONTENT_TO_ROUTE)) {
111
- const rel = `src/content/${name}.md`;
112
- try {
113
- statSync(join(root, rel));
114
- } catch {
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
- entries.set(route, gitDate(rel, root));
127
+ } catch {
128
+ // src/content missing — skip
118
129
  }
119
130
 
120
131
  const urls = [...entries.entries()]
@@ -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="https://github.com"
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 chrome shown under every docs route: previous/next sibling
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 chrome (sidebar, breadcrumb, TOC)
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 chrome component in one go:
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/` and load it with `injectContent({ customFilename })` in a `.page.ts` route, or put `.md` files directly under `src/app/pages/` to get a file-router route with zero TypeScript.
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 chrome 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 `.page.ts` around your markdown.
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
- const KEYWORD_RE = /^\*([A-Z][a-zA-Z0-9]+)\b/;
26
- const HINT_RE = /\*[A-Z]/;
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 {
@@ -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
- }