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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { RouterLink, RouterLinkActive } from '@angular/router';
|
|
3
|
+
import { LucideAngularModule, ChevronDown } from 'lucide-angular';
|
|
4
|
+
import config from '../../ngmd.config';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
selector: 'app-sidebar',
|
|
8
|
+
imports: [RouterLink, RouterLinkActive, LucideAngularModule],
|
|
9
|
+
template: `
|
|
10
|
+
<nav class="flex flex-col gap-4 text-sm">
|
|
11
|
+
@for (section of sections; track section.label) {
|
|
12
|
+
<div>
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
(click)="toggle(section.label)"
|
|
16
|
+
class="flex w-full items-center justify-between rounded px-3 py-1.5 text-sm font-bold text-zinc-900 dark:text-zinc-100 hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
17
|
+
[attr.aria-expanded]="isOpen(section.label)"
|
|
18
|
+
>
|
|
19
|
+
{{ section.label }}
|
|
20
|
+
<i-lucide
|
|
21
|
+
[img]="chevron"
|
|
22
|
+
class="size-4 transition-transform"
|
|
23
|
+
[class.-rotate-90]="!isOpen(section.label)"
|
|
24
|
+
></i-lucide>
|
|
25
|
+
</button>
|
|
26
|
+
@if (isOpen(section.label)) {
|
|
27
|
+
<ul class="mt-1 flex flex-col gap-1">
|
|
28
|
+
@for (item of section.items; track item.href) {
|
|
29
|
+
<li>
|
|
30
|
+
<a
|
|
31
|
+
[routerLink]="item.href"
|
|
32
|
+
routerLinkActive="bg-fuchsia-500/10! text-fuchsia-700! dark:text-fuchsia-300! font-medium"
|
|
33
|
+
class="block rounded-md px-3 py-1.5 text-zinc-700 dark:text-zinc-300 hover:bg-fuchsia-500/10 hover:text-fuchsia-700 dark:hover:text-fuchsia-300 focus:outline-none focus-visible:outline-none"
|
|
34
|
+
>
|
|
35
|
+
{{ item.label }}
|
|
36
|
+
</a>
|
|
37
|
+
</li>
|
|
38
|
+
}
|
|
39
|
+
</ul>
|
|
40
|
+
}
|
|
41
|
+
</div>
|
|
42
|
+
}
|
|
43
|
+
</nav>
|
|
44
|
+
`,
|
|
45
|
+
})
|
|
46
|
+
export class Sidebar {
|
|
47
|
+
readonly sections = config.nav;
|
|
48
|
+
readonly chevron = ChevronDown;
|
|
49
|
+
private readonly openSections = signal<Set<string>>(
|
|
50
|
+
new Set(config.nav.map((s) => s.label)),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
isOpen(label: string): boolean {
|
|
54
|
+
return this.openSections().has(label);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
toggle(label: string): void {
|
|
58
|
+
this.openSections.update((set) => {
|
|
59
|
+
const next = new Set(set);
|
|
60
|
+
if (next.has(label)) next.delete(label);
|
|
61
|
+
else next.add(label);
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewInit,
|
|
3
|
+
Component,
|
|
4
|
+
DestroyRef,
|
|
5
|
+
inject,
|
|
6
|
+
input,
|
|
7
|
+
signal,
|
|
8
|
+
} from '@angular/core';
|
|
9
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
10
|
+
import { NavigationEnd, Router } from '@angular/router';
|
|
11
|
+
import { filter } from 'rxjs';
|
|
12
|
+
|
|
13
|
+
interface Heading {
|
|
14
|
+
id: string;
|
|
15
|
+
text: string;
|
|
16
|
+
level: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Component({
|
|
20
|
+
selector: 'app-toc',
|
|
21
|
+
template: `
|
|
22
|
+
@if (headings().length > 0) {
|
|
23
|
+
<nav class="text-sm">
|
|
24
|
+
<ul class="flex flex-col gap-2">
|
|
25
|
+
@for (h of headings(); track h.id) {
|
|
26
|
+
<li [style.padding-left.rem]="(h.level - 2) * 0.75">
|
|
27
|
+
<a
|
|
28
|
+
[href]="'#' + h.id"
|
|
29
|
+
(click)="scrollToHeading(h.id, $event)"
|
|
30
|
+
class="block rounded px-2 -mx-2 py-0.5 text-zinc-500 hover:bg-fuchsia-100 dark:hover:bg-fuchsia-500/10 hover:text-fuchsia-700 dark:hover:text-fuchsia-300 focus:outline-none focus-visible:outline-none"
|
|
31
|
+
[class]="isActive(h.id) ? 'bg-fuchsia-100 dark:bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300 font-medium' : ''"
|
|
32
|
+
>
|
|
33
|
+
{{ h.text }}
|
|
34
|
+
</a>
|
|
35
|
+
</li>
|
|
36
|
+
}
|
|
37
|
+
</ul>
|
|
38
|
+
</nav>
|
|
39
|
+
}
|
|
40
|
+
`,
|
|
41
|
+
})
|
|
42
|
+
export class Toc implements AfterViewInit {
|
|
43
|
+
private readonly router = inject(Router);
|
|
44
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
45
|
+
|
|
46
|
+
readonly showActive = input<boolean>(true);
|
|
47
|
+
readonly headings = signal<Heading[]>([]);
|
|
48
|
+
readonly active = signal<string>('');
|
|
49
|
+
private observer?: IntersectionObserver;
|
|
50
|
+
|
|
51
|
+
isActive(id: string): boolean {
|
|
52
|
+
return this.showActive() && this.active() === id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ngAfterViewInit(): void {
|
|
56
|
+
this.scanWithRetry();
|
|
57
|
+
this.router.events
|
|
58
|
+
.pipe(
|
|
59
|
+
filter((e) => e instanceof NavigationEnd),
|
|
60
|
+
takeUntilDestroyed(this.destroyRef),
|
|
61
|
+
)
|
|
62
|
+
.subscribe(() => {
|
|
63
|
+
this.headings.set([]);
|
|
64
|
+
this.scanWithRetry();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
scrollToHeading(id: string, event: MouseEvent): void {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
const el = document.getElementById(id);
|
|
71
|
+
if (el) {
|
|
72
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
73
|
+
// index.html has <base href="/">, so a relative `#frag` resolves to
|
|
74
|
+
// `/#frag` and strips the path. Pass the full path explicitly.
|
|
75
|
+
history.replaceState(
|
|
76
|
+
null,
|
|
77
|
+
'',
|
|
78
|
+
`${location.pathname}${location.search}#${id}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private scanWithRetry(attempt = 0): void {
|
|
84
|
+
if (typeof document === 'undefined' || attempt > 20) return;
|
|
85
|
+
const content =
|
|
86
|
+
document.querySelector('main analog-markdown') ??
|
|
87
|
+
document.querySelector('main analog-markdown-route');
|
|
88
|
+
if (!content || content.querySelectorAll('h2, h3').length === 0) {
|
|
89
|
+
setTimeout(() => this.scanWithRetry(attempt + 1), 50);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.scan(content);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private scan(content: Element): void {
|
|
96
|
+
const nodes = Array.from(content.querySelectorAll('h2, h3'));
|
|
97
|
+
const result: Heading[] = nodes.map((node) => {
|
|
98
|
+
const text = node.textContent?.trim() ?? '';
|
|
99
|
+
// Always overwrite the id with a clean slug so palette deep-links match.
|
|
100
|
+
const id = text
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
103
|
+
.replace(/^-|-$/g, '');
|
|
104
|
+
node.id = id;
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
text,
|
|
108
|
+
level: parseInt(node.tagName.substring(1), 10),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
this.headings.set(result);
|
|
112
|
+
this.setupObserver(nodes as HTMLElement[]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private setupObserver(nodes: HTMLElement[]): void {
|
|
116
|
+
this.observer?.disconnect();
|
|
117
|
+
if (nodes.length === 0) return;
|
|
118
|
+
this.observer = new IntersectionObserver(
|
|
119
|
+
(entries) => {
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.isIntersecting) {
|
|
122
|
+
this.active.set(entry.target.id);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
{ rootMargin: '0px 0px -70% 0px', threshold: 0 },
|
|
128
|
+
);
|
|
129
|
+
nodes.forEach((node) => this.observer!.observe(node));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Injectable, signal } from '@angular/core';
|
|
2
|
+
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
@Injectable({ providedIn: 'root' })
|
|
8
|
+
export class LayoutMode {
|
|
9
|
+
readonly chromeHidden = signal(false);
|
|
10
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Component, inject } from '@angular/core';
|
|
2
|
+
import { RouterLink } from '@angular/router';
|
|
3
|
+
import { LayoutMode } from '../layout-mode.service';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'app-home',
|
|
7
|
+
imports: [RouterLink],
|
|
8
|
+
template: `
|
|
9
|
+
<section class="mx-auto max-w-2xl px-6 py-24 text-center">
|
|
10
|
+
<h1 class="text-4xl font-bold tracking-tight">Your docs</h1>
|
|
11
|
+
<p class="mt-4 text-zinc-600 dark:text-zinc-400">
|
|
12
|
+
Scaffolded with <code>create-ngmd</code>. Edit
|
|
13
|
+
<code>src/content/welcome.md</code> to make this your own.
|
|
14
|
+
</p>
|
|
15
|
+
<a
|
|
16
|
+
routerLink="/welcome"
|
|
17
|
+
class="mt-8 inline-flex items-center rounded-md border border-zinc-200 dark:border-zinc-800 px-4 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
|
18
|
+
>
|
|
19
|
+
Read the docs →
|
|
20
|
+
</a>
|
|
21
|
+
</section>
|
|
22
|
+
`,
|
|
23
|
+
})
|
|
24
|
+
export default class HomePage {
|
|
25
|
+
constructor() {
|
|
26
|
+
inject(LayoutMode).chromeHidden.set(true);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
|
2
|
+
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
type Mode = 'light' | 'dark' | 'auto';
|
|
5
|
+
const STORAGE_KEY = 'ngmd-theme';
|
|
6
|
+
|
|
7
|
+
@Injectable({ providedIn: 'root' })
|
|
8
|
+
export class ThemeService {
|
|
9
|
+
private readonly document = inject(DOCUMENT);
|
|
10
|
+
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
11
|
+
|
|
12
|
+
readonly mode = signal<Mode>(this.read());
|
|
13
|
+
|
|
14
|
+
cycle() {
|
|
15
|
+
const next: Mode =
|
|
16
|
+
this.mode() === 'light' ? 'dark' : this.mode() === 'dark' ? 'auto' : 'light';
|
|
17
|
+
this.mode.set(next);
|
|
18
|
+
this.apply(next);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Legacy alias kept so existing callers compile. */
|
|
22
|
+
toggle() {
|
|
23
|
+
this.cycle();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
initFromStorage() {
|
|
27
|
+
this.apply(this.mode());
|
|
28
|
+
if (this.isBrowser) {
|
|
29
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
30
|
+
if (this.mode() === 'auto') this.apply('auto');
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private read(): Mode {
|
|
36
|
+
if (!this.isBrowser) return 'auto';
|
|
37
|
+
const stored = localStorage.getItem(STORAGE_KEY) as Mode | null;
|
|
38
|
+
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored;
|
|
39
|
+
return 'auto';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private apply(mode: Mode) {
|
|
43
|
+
if (!this.isBrowser) return;
|
|
44
|
+
const root = this.document.documentElement;
|
|
45
|
+
const resolved =
|
|
46
|
+
mode === 'auto'
|
|
47
|
+
? matchMedia('(prefers-color-scheme: dark)').matches
|
|
48
|
+
? 'dark'
|
|
49
|
+
: 'light'
|
|
50
|
+
: mode;
|
|
51
|
+
root.classList.toggle('dark', resolved === 'dark');
|
|
52
|
+
localStorage.setItem(STORAGE_KEY, mode);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { Title } from '@angular/platform-browser';
|
|
3
|
+
import { TitleStrategy, type RouterStateSnapshot } from '@angular/router';
|
|
4
|
+
import siteConfig, { navLabels } from '../ngmd.config';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Custom title strategy. Every page renders as `NgMd | <Title>`, where
|
|
8
|
+
* `<Title>` resolves in this order:
|
|
9
|
+
*
|
|
10
|
+
* 1. The route's own `title` (set by Analog from markdown frontmatter or
|
|
11
|
+
* by a `.page.ts` component via `routeMeta`).
|
|
12
|
+
* 2. The matching entry in `ngmd.config.ts > navLabels`.
|
|
13
|
+
* 3. A prettified last URL segment ("markdown-routes" → "Markdown Routes").
|
|
14
|
+
* 4. `siteConfig.site.tagline` for the homepage (no `|` separator there).
|
|
15
|
+
*
|
|
16
|
+
* Replaces Angular's `DefaultTitleStrategy`, which would otherwise overwrite
|
|
17
|
+
* our format with just the raw frontmatter title.
|
|
18
|
+
*/
|
|
19
|
+
@Injectable({ providedIn: 'root' })
|
|
20
|
+
export class NgmdTitleStrategy extends TitleStrategy {
|
|
21
|
+
constructor(private readonly title: Title) {
|
|
22
|
+
super();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override updateTitle(snapshot: RouterStateSnapshot): void {
|
|
26
|
+
const name = siteConfig.site.name;
|
|
27
|
+
const url = snapshot.url.split('?')[0].split('#')[0];
|
|
28
|
+
|
|
29
|
+
// Homepage uses the tagline, no pipe separator.
|
|
30
|
+
if (url === '/' || url === '') {
|
|
31
|
+
const tagline = siteConfig.site.tagline;
|
|
32
|
+
this.title.setTitle(tagline ? `${name} | ${tagline}` : name);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let pageTitle = this.buildTitle(snapshot);
|
|
37
|
+
if (!pageTitle) {
|
|
38
|
+
const last = url.split('/').filter(Boolean).pop() ?? '';
|
|
39
|
+
pageTitle =
|
|
40
|
+
navLabels[last] ??
|
|
41
|
+
last
|
|
42
|
+
.split('-')
|
|
43
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
44
|
+
.join(' ');
|
|
45
|
+
}
|
|
46
|
+
this.title.setTitle(pageTitle ? `${name} | ${pageTitle}` : name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
|
|
4
|
+
type AlertSeverity = 'info' | 'warning' | 'critical' | 'helpful' | 'important';
|
|
5
|
+
|
|
6
|
+
const BOX = 'my-5 px-4 py-3 rounded-r-md border-l-[3px] bg-zinc-50 dark:bg-zinc-900 text-sm text-zinc-700 dark:text-zinc-300 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0';
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'ngmd-alert',
|
|
10
|
+
imports: [NgTemplateOutlet],
|
|
11
|
+
template: `
|
|
12
|
+
@switch (severity()) {
|
|
13
|
+
@case ('warning') {
|
|
14
|
+
<div [class]="box + ' border-l-amber-500'">
|
|
15
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
16
|
+
</div>
|
|
17
|
+
}
|
|
18
|
+
@case ('critical') {
|
|
19
|
+
<div [class]="box + ' border-l-red-500'">
|
|
20
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
@case ('helpful') {
|
|
24
|
+
<div [class]="box + ' border-l-teal-500'">
|
|
25
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
26
|
+
</div>
|
|
27
|
+
}
|
|
28
|
+
@case ('important') {
|
|
29
|
+
<div [class]="box + ' border-l-purple-500'">
|
|
30
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
31
|
+
</div>
|
|
32
|
+
}
|
|
33
|
+
@default {
|
|
34
|
+
<div [class]="box + ' border-l-blue-500'">
|
|
35
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
36
|
+
</div>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
<ng-template #body><ng-content></ng-content></ng-template>
|
|
41
|
+
`,
|
|
42
|
+
})
|
|
43
|
+
export class NgmdAlert {
|
|
44
|
+
readonly severity = input<AlertSeverity>('info');
|
|
45
|
+
protected readonly box = BOX;
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
|
|
4
|
+
type CalloutType = 'info' | 'tip' | 'success' | 'warning' | 'danger';
|
|
5
|
+
|
|
6
|
+
// Per-side borders below: avoids Tailwind's `border-color` shorthand
|
|
7
|
+
// (set by `border + border-zinc-200`) competing with `border-l-*` and
|
|
8
|
+
// wiping the stripe colour under the `dark:` cascade.
|
|
9
|
+
const BOX = 'my-6 px-5 py-4 rounded-lg border-y border-r border-t-zinc-200 border-r-zinc-200 border-b-zinc-200 dark:border-t-zinc-800 dark:border-r-zinc-800 dark:border-b-zinc-800 bg-zinc-50 dark:bg-zinc-900 border-l-[3px]';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'ngmd-callout',
|
|
13
|
+
imports: [NgTemplateOutlet],
|
|
14
|
+
template: `
|
|
15
|
+
@switch (type()) {
|
|
16
|
+
@case ('tip') {
|
|
17
|
+
<div [class]="box + ' border-l-teal-500'">
|
|
18
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
19
|
+
</div>
|
|
20
|
+
}
|
|
21
|
+
@case ('success') {
|
|
22
|
+
<div [class]="box + ' border-l-emerald-500'">
|
|
23
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
24
|
+
</div>
|
|
25
|
+
}
|
|
26
|
+
@case ('warning') {
|
|
27
|
+
<div [class]="box + ' border-l-amber-500'">
|
|
28
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
29
|
+
</div>
|
|
30
|
+
}
|
|
31
|
+
@case ('danger') {
|
|
32
|
+
<div [class]="box + ' border-l-red-500'">
|
|
33
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
34
|
+
</div>
|
|
35
|
+
}
|
|
36
|
+
@default {
|
|
37
|
+
<div [class]="box + ' border-l-blue-500'">
|
|
38
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
39
|
+
</div>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
<ng-template #body>
|
|
44
|
+
@if (title()) {
|
|
45
|
+
<p class="font-semibold text-sm mb-2 text-zinc-900 dark:text-zinc-100">{{ title() }}</p>
|
|
46
|
+
}
|
|
47
|
+
<div class="text-sm text-zinc-700 dark:text-zinc-300 [&>*:last-child]:mb-0 [&>*:first-child]:mt-0">
|
|
48
|
+
<ng-content></ng-content>
|
|
49
|
+
</div>
|
|
50
|
+
</ng-template>
|
|
51
|
+
`,
|
|
52
|
+
})
|
|
53
|
+
export class NgmdCallout {
|
|
54
|
+
readonly type = input<CalloutType>('info');
|
|
55
|
+
readonly title = input<string>('');
|
|
56
|
+
protected readonly box = BOX;
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
import { RouterLink } from '@angular/router';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'ngmd-card',
|
|
7
|
+
imports: [NgTemplateOutlet, RouterLink],
|
|
8
|
+
template: `
|
|
9
|
+
@if (link()) {
|
|
10
|
+
<a
|
|
11
|
+
[routerLink]="link()"
|
|
12
|
+
class="block rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-5 my-4 text-inherit no-underline transition-colors hover:border-zinc-400 dark:hover:border-zinc-600"
|
|
13
|
+
>
|
|
14
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
15
|
+
</a>
|
|
16
|
+
} @else {
|
|
17
|
+
<div
|
|
18
|
+
class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-5 my-4"
|
|
19
|
+
>
|
|
20
|
+
<ng-container *ngTemplateOutlet="body"></ng-container>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
<ng-template #body>
|
|
25
|
+
@if (title()) {
|
|
26
|
+
<h3 class="text-base font-semibold mb-2 text-zinc-900 dark:text-zinc-100">{{ title() }}</h3>
|
|
27
|
+
}
|
|
28
|
+
<div class="text-sm text-zinc-600 dark:text-zinc-400">
|
|
29
|
+
<ng-content></ng-content>
|
|
30
|
+
</div>
|
|
31
|
+
@if (cta()) {
|
|
32
|
+
<span class="mt-3 inline-block text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ cta() }} →</span>
|
|
33
|
+
}
|
|
34
|
+
</ng-template>
|
|
35
|
+
`,
|
|
36
|
+
})
|
|
37
|
+
export class NgmdCard {
|
|
38
|
+
readonly title = input<string>('');
|
|
39
|
+
readonly link = input<string>('');
|
|
40
|
+
readonly cta = input<string>('');
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
DestroyRef,
|
|
4
|
+
effect,
|
|
5
|
+
inject,
|
|
6
|
+
input,
|
|
7
|
+
signal,
|
|
8
|
+
} from '@angular/core';
|
|
9
|
+
import { DomSanitizer, type SafeHtml } from '@angular/platform-browser';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Code block with a header bar and shiki syntax highlighting at runtime.
|
|
13
|
+
* Author passes raw `code` + a `language`. Shiki is lazy-loaded on first
|
|
14
|
+
* render so it does not weigh on the initial bundle.
|
|
15
|
+
*/
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'ngmd-code-block',
|
|
18
|
+
template: `
|
|
19
|
+
<div class="my-6 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden bg-white dark:bg-zinc-950">
|
|
20
|
+
@if (header()) {
|
|
21
|
+
<div
|
|
22
|
+
class="px-4 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 font-mono text-xs text-zinc-500 dark:text-zinc-400"
|
|
23
|
+
>
|
|
24
|
+
{{ header() }}
|
|
25
|
+
</div>
|
|
26
|
+
}
|
|
27
|
+
@if (highlighted(); as html) {
|
|
28
|
+
<div
|
|
29
|
+
class="[&_pre]:m-0 [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:leading-relaxed [&_pre]:border-0 [&_pre]:rounded-none"
|
|
30
|
+
[innerHTML]="html"
|
|
31
|
+
></div>
|
|
32
|
+
} @else {
|
|
33
|
+
<pre class="m-0 p-4 overflow-x-auto text-sm leading-relaxed bg-transparent border-0 rounded-none"><code [class]="codeClass()">{{ code() }}</code></pre>
|
|
34
|
+
}
|
|
35
|
+
</div>
|
|
36
|
+
`,
|
|
37
|
+
})
|
|
38
|
+
export class NgmdCodeBlock {
|
|
39
|
+
private readonly sanitizer = inject(DomSanitizer);
|
|
40
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
41
|
+
|
|
42
|
+
readonly header = input<string>('');
|
|
43
|
+
readonly language = input<string>('');
|
|
44
|
+
readonly code = input<string>('');
|
|
45
|
+
|
|
46
|
+
protected readonly highlighted = signal<SafeHtml | null>(null);
|
|
47
|
+
|
|
48
|
+
protected readonly codeClass = () =>
|
|
49
|
+
this.language() ? `language-${this.language()}` : '';
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
this.destroyRef.onDestroy(() => (cancelled = true));
|
|
54
|
+
|
|
55
|
+
effect(async () => {
|
|
56
|
+
const code = this.code();
|
|
57
|
+
const lang = this.language();
|
|
58
|
+
if (!code || typeof window === 'undefined') return;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const { codeToHtml } = await import('shiki');
|
|
62
|
+
if (cancelled) return;
|
|
63
|
+
const html = await codeToHtml(code, {
|
|
64
|
+
lang: lang || 'text',
|
|
65
|
+
themes: { light: 'github-light', dark: 'github-dark' },
|
|
66
|
+
defaultColor: false,
|
|
67
|
+
});
|
|
68
|
+
if (!cancelled) {
|
|
69
|
+
this.highlighted.set(this.sanitizer.bypassSecurityTrustHtml(html));
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Lang not bundled or shiki failed: fall through to the unstyled <pre>.
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Component, computed, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Component({
|
|
4
|
+
selector: 'ngmd-hero',
|
|
5
|
+
template: `
|
|
6
|
+
<header
|
|
7
|
+
class="my-8 rounded-2xl px-6 py-10 sm:px-10 sm:py-14"
|
|
8
|
+
[class]="bgClass()"
|
|
9
|
+
>
|
|
10
|
+
<h1
|
|
11
|
+
class="text-3xl sm:text-4xl font-bold tracking-tight m-0 mb-3"
|
|
12
|
+
[class]="titleClass()"
|
|
13
|
+
>
|
|
14
|
+
{{ title() }}
|
|
15
|
+
</h1>
|
|
16
|
+
<div
|
|
17
|
+
class="text-base sm:text-lg leading-relaxed max-w-prose [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
|
18
|
+
[class]="bodyClass()"
|
|
19
|
+
>
|
|
20
|
+
<ng-content></ng-content>
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
`,
|
|
24
|
+
})
|
|
25
|
+
export class NgmdHero {
|
|
26
|
+
readonly title = input.required<string>();
|
|
27
|
+
readonly gradient = input(false, { transform: (v: boolean | string) => v === '' || v === true || v === 'true' });
|
|
28
|
+
|
|
29
|
+
readonly bgClass = computed(() =>
|
|
30
|
+
this.gradient()
|
|
31
|
+
? 'bg-gradient-to-br from-rose-500/10 via-fuchsia-500/10 to-purple-500/10 border border-zinc-200 dark:border-zinc-800'
|
|
32
|
+
: 'bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800',
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
readonly titleClass = computed(() =>
|
|
36
|
+
this.gradient()
|
|
37
|
+
? 'bg-gradient-to-r from-rose-500 via-fuchsia-500 to-purple-500 bg-clip-text text-transparent'
|
|
38
|
+
: 'text-zinc-900 dark:text-zinc-100',
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
readonly bodyClass = computed(() => 'text-zinc-700 dark:text-zinc-300');
|
|
42
|
+
}
|