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,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
computed,
|
|
4
|
+
effect,
|
|
5
|
+
ElementRef,
|
|
6
|
+
HostListener,
|
|
7
|
+
inject,
|
|
8
|
+
signal,
|
|
9
|
+
viewChild,
|
|
10
|
+
} from '@angular/core';
|
|
11
|
+
import { Router } from '@angular/router';
|
|
12
|
+
import { injectContentFiles } from '@analogjs/content';
|
|
13
|
+
import {
|
|
14
|
+
LucideAngularModule,
|
|
15
|
+
Search,
|
|
16
|
+
ArrowRight,
|
|
17
|
+
Hash,
|
|
18
|
+
FileText,
|
|
19
|
+
} from 'lucide-angular';
|
|
20
|
+
import { navItems } from '../../ngmd.config';
|
|
21
|
+
|
|
22
|
+
type ItemKind = 'page' | 'heading' | 'snippet';
|
|
23
|
+
|
|
24
|
+
interface PaletteItem {
|
|
25
|
+
kind: ItemKind;
|
|
26
|
+
label: string;
|
|
27
|
+
subtitle: string;
|
|
28
|
+
href: string;
|
|
29
|
+
hash?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface IndexedFile {
|
|
33
|
+
slug: string;
|
|
34
|
+
pageLabel: string;
|
|
35
|
+
href: string;
|
|
36
|
+
body: string;
|
|
37
|
+
bodyLower: string;
|
|
38
|
+
headings: { text: string; slug: string }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function slugify(text: string): string {
|
|
42
|
+
return text
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.trim()
|
|
45
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
46
|
+
.replace(/^-|-$/g, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Strip markdown syntax for cleaner snippet previews. */
|
|
50
|
+
function stripMarkdown(s: string): string {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
53
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
54
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
55
|
+
.replace(/[*_#>]/g, '')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Component({
|
|
61
|
+
selector: 'app-command-palette',
|
|
62
|
+
imports: [LucideAngularModule],
|
|
63
|
+
template: `
|
|
64
|
+
@if (open()) {
|
|
65
|
+
<div
|
|
66
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
|
67
|
+
(click)="close()"
|
|
68
|
+
>
|
|
69
|
+
<div
|
|
70
|
+
class="w-full max-w-2xl rounded-2xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950 overflow-hidden"
|
|
71
|
+
(click)="$event.stopPropagation()"
|
|
72
|
+
>
|
|
73
|
+
<div class="flex items-center gap-3 px-5 py-4">
|
|
74
|
+
<i-lucide [img]="searchIcon" class="size-6 text-zinc-400"></i-lucide>
|
|
75
|
+
<input
|
|
76
|
+
#input
|
|
77
|
+
type="text"
|
|
78
|
+
placeholder="Type a command or search..."
|
|
79
|
+
class="flex-1 bg-transparent text-lg outline-none placeholder:text-zinc-400"
|
|
80
|
+
[value]="query()"
|
|
81
|
+
(input)="onInput($event)"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="border-t border-zinc-200 dark:border-zinc-800 h-96 overflow-y-auto p-3">
|
|
86
|
+
@if (filtered().length === 0) {
|
|
87
|
+
<div class="py-12 text-center text-base text-zinc-500">No results.</div>
|
|
88
|
+
} @else {
|
|
89
|
+
@for (item of filtered(); track $index; let i = $index) {
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
class="flex w-full cursor-pointer items-center gap-4 rounded-lg px-4 py-3 text-left"
|
|
93
|
+
[class]="i === active() ? 'bg-fuchsia-500/10' : ''"
|
|
94
|
+
(mouseenter)="active.set(i)"
|
|
95
|
+
(click)="select()"
|
|
96
|
+
>
|
|
97
|
+
<i-lucide [img]="iconFor(item)" class="size-5 text-zinc-400"></i-lucide>
|
|
98
|
+
<div class="flex-1 min-w-0">
|
|
99
|
+
<div class="text-base font-semibold truncate">{{ item.label }}</div>
|
|
100
|
+
<div class="text-sm text-zinc-500 truncate">{{ item.subtitle }}</div>
|
|
101
|
+
</div>
|
|
102
|
+
</button>
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="flex items-center justify-end border-t border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs text-zinc-500">
|
|
108
|
+
esc to close
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
}
|
|
113
|
+
`,
|
|
114
|
+
})
|
|
115
|
+
export class CommandPalette {
|
|
116
|
+
private readonly router = inject(Router);
|
|
117
|
+
private readonly input = viewChild<ElementRef<HTMLInputElement>>('input');
|
|
118
|
+
private readonly contentFiles = injectContentFiles<{ title?: string }>();
|
|
119
|
+
|
|
120
|
+
readonly searchIcon = Search;
|
|
121
|
+
readonly arrowIcon = ArrowRight;
|
|
122
|
+
readonly hashIcon = Hash;
|
|
123
|
+
readonly fileIcon = FileText;
|
|
124
|
+
|
|
125
|
+
readonly open = signal(false);
|
|
126
|
+
readonly query = signal('');
|
|
127
|
+
readonly active = signal(0);
|
|
128
|
+
|
|
129
|
+
constructor() {
|
|
130
|
+
effect(() => {
|
|
131
|
+
if (typeof document === 'undefined') return;
|
|
132
|
+
document.body.style.overflow = this.open() ? 'hidden' : '';
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private readonly pageItems: PaletteItem[] = navItems.map((item) => ({
|
|
137
|
+
kind: 'page',
|
|
138
|
+
label: item.label,
|
|
139
|
+
subtitle: item.section,
|
|
140
|
+
href: item.href,
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
private readonly index: IndexedFile[] = this.buildIndex();
|
|
144
|
+
|
|
145
|
+
readonly filtered = computed(() => {
|
|
146
|
+
const q = this.query().toLowerCase().trim();
|
|
147
|
+
if (!q) return this.pageItems;
|
|
148
|
+
|
|
149
|
+
const results: PaletteItem[] = [];
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
const push = (item: PaletteItem) => {
|
|
152
|
+
const key = `${item.kind}:${item.href}#${item.hash ?? ''}:${item.label}`;
|
|
153
|
+
if (seen.has(key)) return;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
results.push(item);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// 1. Pages by label
|
|
159
|
+
for (const p of this.pageItems) {
|
|
160
|
+
if (p.label.toLowerCase().includes(q)) push(p);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Headings by text
|
|
164
|
+
for (const file of this.index) {
|
|
165
|
+
for (const h of file.headings) {
|
|
166
|
+
if (h.text.toLowerCase().includes(q)) {
|
|
167
|
+
push({
|
|
168
|
+
kind: 'heading',
|
|
169
|
+
label: h.text,
|
|
170
|
+
subtitle: file.pageLabel,
|
|
171
|
+
href: file.href,
|
|
172
|
+
hash: h.slug,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. Body matches with snippet
|
|
179
|
+
for (const file of this.index) {
|
|
180
|
+
const idx = file.bodyLower.indexOf(q);
|
|
181
|
+
if (idx === -1) continue;
|
|
182
|
+
const start = Math.max(0, idx - 40);
|
|
183
|
+
const end = Math.min(file.body.length, idx + q.length + 60);
|
|
184
|
+
const snippet =
|
|
185
|
+
(start > 0 ? '…' : '') +
|
|
186
|
+
file.body.slice(start, end).trim() +
|
|
187
|
+
(end < file.body.length ? '…' : '');
|
|
188
|
+
push({
|
|
189
|
+
kind: 'snippet',
|
|
190
|
+
label: snippet,
|
|
191
|
+
subtitle: file.pageLabel,
|
|
192
|
+
href: file.href,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return results.slice(0, 30);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
iconFor(item: PaletteItem) {
|
|
200
|
+
if (item.kind === 'heading') return this.hashIcon;
|
|
201
|
+
if (item.kind === 'snippet') return this.fileIcon;
|
|
202
|
+
return this.arrowIcon;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@HostListener('document:keydown', ['$event'])
|
|
206
|
+
onKeydown(event: KeyboardEvent) {
|
|
207
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
this.toggle();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (event.key === 'Escape' && this.open()) {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
this.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
toggle() {
|
|
219
|
+
this.open.update((v) => !v);
|
|
220
|
+
if (this.open()) {
|
|
221
|
+
this.query.set('');
|
|
222
|
+
this.active.set(0);
|
|
223
|
+
queueMicrotask(() => this.input()?.nativeElement.focus());
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
close() {
|
|
228
|
+
this.open.set(false);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
onInput(event: Event) {
|
|
232
|
+
this.query.set((event.target as HTMLInputElement).value);
|
|
233
|
+
this.active.set(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
move(delta: number) {
|
|
237
|
+
const max = this.filtered().length - 1;
|
|
238
|
+
if (max < 0) return;
|
|
239
|
+
const next = (this.active() + delta + max + 1) % (max + 1);
|
|
240
|
+
this.active.set(next);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
select() {
|
|
244
|
+
const item = this.filtered()[this.active()];
|
|
245
|
+
if (!item) return;
|
|
246
|
+
this.close();
|
|
247
|
+
if (!item.hash) {
|
|
248
|
+
this.router.navigateByUrl(item.href);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Navigate first, then poll for the heading to appear (markdown loads async).
|
|
252
|
+
this.router.navigateByUrl(item.href).then(() => {
|
|
253
|
+
this.scrollToWhenReady(item.hash!);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private scrollToWhenReady(slug: string, attempt = 0): void {
|
|
258
|
+
if (typeof document === 'undefined' || attempt > 30) return;
|
|
259
|
+
const el = document.getElementById(slug);
|
|
260
|
+
if (!el) {
|
|
261
|
+
setTimeout(() => this.scrollToWhenReady(slug, attempt + 1), 50);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
265
|
+
history.replaceState(null, '', `${location.pathname}#${slug}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private buildIndex(): IndexedFile[] {
|
|
269
|
+
const slugToNav = new Map(
|
|
270
|
+
navItems.map((item) => [item.href.split('/').pop() ?? '', item]),
|
|
271
|
+
);
|
|
272
|
+
const out: IndexedFile[] = [];
|
|
273
|
+
|
|
274
|
+
for (const file of this.contentFiles) {
|
|
275
|
+
const navItem = slugToNav.get(file.slug);
|
|
276
|
+
if (!navItem) continue;
|
|
277
|
+
const raw = typeof file.content === 'string' ? file.content : '';
|
|
278
|
+
if (!raw) continue;
|
|
279
|
+
|
|
280
|
+
const headings: { text: string; slug: string }[] = [];
|
|
281
|
+
for (const m of raw.matchAll(/^(##+)\s+(.+)$/gm)) {
|
|
282
|
+
const text = m[2].trim();
|
|
283
|
+
headings.push({ text, slug: slugify(text) });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const body = stripMarkdown(raw);
|
|
287
|
+
out.push({
|
|
288
|
+
slug: file.slug,
|
|
289
|
+
pageLabel: navItem.label,
|
|
290
|
+
href: navItem.href,
|
|
291
|
+
body,
|
|
292
|
+
bodyLower: body.toLowerCase(),
|
|
293
|
+
headings,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewInit,
|
|
3
|
+
Component,
|
|
4
|
+
DestroyRef,
|
|
5
|
+
inject,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
8
|
+
import { NavigationEnd, Router } from '@angular/router';
|
|
9
|
+
import { filter } from 'rxjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds `target="_blank" rel="noopener noreferrer"` to external anchors in
|
|
13
|
+
* rendered markdown after each route change.
|
|
14
|
+
*/
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'app-external-links',
|
|
17
|
+
template: '',
|
|
18
|
+
styles: `
|
|
19
|
+
:host { display: none; }
|
|
20
|
+
`,
|
|
21
|
+
})
|
|
22
|
+
export class ExternalLinks implements AfterViewInit {
|
|
23
|
+
private readonly router = inject(Router);
|
|
24
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
25
|
+
|
|
26
|
+
ngAfterViewInit(): void {
|
|
27
|
+
this.enhanceWithRetry();
|
|
28
|
+
this.router.events
|
|
29
|
+
.pipe(
|
|
30
|
+
filter((e) => e instanceof NavigationEnd),
|
|
31
|
+
takeUntilDestroyed(this.destroyRef),
|
|
32
|
+
)
|
|
33
|
+
.subscribe(() => this.enhanceWithRetry());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private enhanceWithRetry(attempt = 0): void {
|
|
37
|
+
if (typeof document === 'undefined' || attempt > 20) return;
|
|
38
|
+
const container =
|
|
39
|
+
document.querySelector('main analog-markdown') ??
|
|
40
|
+
document.querySelector('main analog-markdown-route');
|
|
41
|
+
const anchors = container?.querySelectorAll('a[href^="http"]:not([data-external-enhanced])');
|
|
42
|
+
if (!anchors || anchors.length === 0) {
|
|
43
|
+
setTimeout(() => this.enhanceWithRetry(attempt + 1), 50);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const origin = window.location.origin;
|
|
47
|
+
anchors.forEach((node) => {
|
|
48
|
+
const a = node as HTMLAnchorElement;
|
|
49
|
+
a.setAttribute('data-external-enhanced', 'true');
|
|
50
|
+
if (a.href.startsWith(origin)) return;
|
|
51
|
+
a.setAttribute('target', '_blank');
|
|
52
|
+
a.setAttribute('rel', 'noopener noreferrer');
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { NavigationEnd, Router } from '@angular/router';
|
|
4
|
+
import { filter } from 'rxjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scans rendered docs pages for h2/h3 with an id and appends a copy-link
|
|
8
|
+
* button that writes the absolute URL with `#fragment` to the clipboard.
|
|
9
|
+
* Runs on mount and after every route change, same pattern as CodeCopy.
|
|
10
|
+
*/
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'app-heading-anchors',
|
|
13
|
+
template: '',
|
|
14
|
+
styles: `
|
|
15
|
+
:host { display: none; }
|
|
16
|
+
`,
|
|
17
|
+
})
|
|
18
|
+
export class HeadingAnchors implements AfterViewInit {
|
|
19
|
+
private readonly router = inject(Router);
|
|
20
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
21
|
+
|
|
22
|
+
ngAfterViewInit(): void {
|
|
23
|
+
this.enhanceWithRetry();
|
|
24
|
+
this.router.events
|
|
25
|
+
.pipe(
|
|
26
|
+
filter((e) => e instanceof NavigationEnd),
|
|
27
|
+
takeUntilDestroyed(this.destroyRef),
|
|
28
|
+
)
|
|
29
|
+
.subscribe(() => this.enhanceWithRetry());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private enhanceWithRetry(attempt = 0): void {
|
|
33
|
+
if (typeof document === 'undefined' || attempt > 20) return;
|
|
34
|
+
const headings = document.querySelectorAll<HTMLElement>(
|
|
35
|
+
'main h1[id]:not([data-anchor-enhanced]), main h2[id]:not([data-anchor-enhanced]), main h3[id]:not([data-anchor-enhanced])',
|
|
36
|
+
);
|
|
37
|
+
if (headings.length === 0) {
|
|
38
|
+
setTimeout(() => this.enhanceWithRetry(attempt + 1), 50);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
headings.forEach((h) => this.enhance(h));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private enhance(heading: HTMLElement): void {
|
|
45
|
+
heading.setAttribute('data-anchor-enhanced', 'true');
|
|
46
|
+
heading.style.scrollMarginTop = heading.style.scrollMarginTop || '6rem';
|
|
47
|
+
|
|
48
|
+
const button = document.createElement('button');
|
|
49
|
+
button.type = 'button';
|
|
50
|
+
button.setAttribute('aria-label', `Copy link to ${heading.id}`);
|
|
51
|
+
button.className =
|
|
52
|
+
'ml-2 inline-flex items-center justify-center size-5 align-middle relative -top-[2px] rounded text-zinc-400 hover:text-fuchsia-600 dark:hover:text-fuchsia-400 opacity-0 transition-opacity focus:opacity-100';
|
|
53
|
+
button.innerHTML = this.linkIcon();
|
|
54
|
+
|
|
55
|
+
heading.addEventListener('mouseenter', () => (button.style.opacity = '1'));
|
|
56
|
+
heading.addEventListener('mouseleave', () => (button.style.opacity = '0'));
|
|
57
|
+
|
|
58
|
+
// h1 is the page itself; copying #h1-slug duplicates the path in the URL.
|
|
59
|
+
// For h1, copy + show the bare page URL with no fragment.
|
|
60
|
+
const isH1 = heading.tagName === 'H1';
|
|
61
|
+
|
|
62
|
+
button.addEventListener('click', async (e) => {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
const base = `${location.origin}${location.pathname}`;
|
|
66
|
+
const url = isH1 ? base : `${base}#${heading.id}`;
|
|
67
|
+
try {
|
|
68
|
+
await navigator.clipboard.writeText(url);
|
|
69
|
+
button.innerHTML = this.checkIcon();
|
|
70
|
+
setTimeout(() => (button.innerHTML = this.linkIcon()), 1500);
|
|
71
|
+
} catch {
|
|
72
|
+
// clipboard API unavailable, no-op
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
heading.appendChild(button);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private linkIcon(): string {
|
|
80
|
+
return `
|
|
81
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
82
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
83
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
84
|
+
</svg>
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private checkIcon(): string {
|
|
89
|
+
return `
|
|
90
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
91
|
+
<polyline points="20 6 9 17 4 12"/>
|
|
92
|
+
</svg>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tailwind-styled card mirroring Spartan UI's `hlm-card` shape.
|
|
5
|
+
* Replace with the official spartan helm card later when wiring the full CLI.
|
|
6
|
+
*/
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'hlm-card',
|
|
9
|
+
template: `
|
|
10
|
+
<div
|
|
11
|
+
class="rounded-xl border border-zinc-200 bg-white text-zinc-950 shadow-sm
|
|
12
|
+
dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50"
|
|
13
|
+
>
|
|
14
|
+
@if (title()) {
|
|
15
|
+
<div class="px-6 pt-6">
|
|
16
|
+
<h3 class="text-lg font-semibold leading-none tracking-tight">{{ title() }}</h3>
|
|
17
|
+
@if (description()) {
|
|
18
|
+
<p class="mt-1.5 text-sm text-zinc-500 dark:text-zinc-400">{{ description() }}</p>
|
|
19
|
+
}
|
|
20
|
+
</div>
|
|
21
|
+
}
|
|
22
|
+
<div class="p-6">
|
|
23
|
+
<ng-content></ng-content>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
`,
|
|
27
|
+
})
|
|
28
|
+
export class HlmCard {
|
|
29
|
+
readonly title = input<string>('');
|
|
30
|
+
readonly description = input<string>('');
|
|
31
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewInit,
|
|
3
|
+
Component,
|
|
4
|
+
DestroyRef,
|
|
5
|
+
inject,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
8
|
+
import { NavigationEnd, Router } from '@angular/router';
|
|
9
|
+
import { filter } from 'rxjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hydrates the placeholder divs emitted by the ngmd-video and ngmd-image
|
|
13
|
+
* marked extensions. The marked extensions output plain `<div data-...>`
|
|
14
|
+
* elements; this enhancer creates the real <iframe> and <figure><img>
|
|
15
|
+
* structures programmatically — bypassing any Angular [innerHTML]
|
|
16
|
+
* sanitisation that may strip iframes in some configurations.
|
|
17
|
+
*/
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'app-media-enhancer',
|
|
20
|
+
template: '',
|
|
21
|
+
styles: `:host { display: none; }`,
|
|
22
|
+
})
|
|
23
|
+
export class MediaEnhancer implements AfterViewInit {
|
|
24
|
+
private readonly router = inject(Router);
|
|
25
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
26
|
+
|
|
27
|
+
ngAfterViewInit(): void {
|
|
28
|
+
this.enhanceWithRetry();
|
|
29
|
+
this.router.events
|
|
30
|
+
.pipe(
|
|
31
|
+
filter((e) => e instanceof NavigationEnd),
|
|
32
|
+
takeUntilDestroyed(this.destroyRef),
|
|
33
|
+
)
|
|
34
|
+
.subscribe(() => this.enhanceWithRetry());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private enhanceWithRetry(attempt = 0): void {
|
|
38
|
+
if (typeof document === 'undefined' || attempt > 20) return;
|
|
39
|
+
const videos = document.querySelectorAll<HTMLElement>(
|
|
40
|
+
'.ngmd-video[data-video-src]:not([data-enhanced])',
|
|
41
|
+
);
|
|
42
|
+
const images = document.querySelectorAll<HTMLElement>(
|
|
43
|
+
'.ngmd-image[data-image-src]:not([data-enhanced])',
|
|
44
|
+
);
|
|
45
|
+
if (videos.length === 0 && images.length === 0) {
|
|
46
|
+
setTimeout(() => this.enhanceWithRetry(attempt + 1), 50);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
videos.forEach((el) => this.enhanceVideo(el));
|
|
50
|
+
images.forEach((el) => this.enhanceImage(el));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private enhanceVideo(el: HTMLElement): void {
|
|
54
|
+
el.setAttribute('data-enhanced', 'true');
|
|
55
|
+
const src = el.dataset['videoSrc'] ?? '';
|
|
56
|
+
const title = el.dataset['videoTitle'] ?? 'Video player';
|
|
57
|
+
const iframe = document.createElement('iframe');
|
|
58
|
+
iframe.src = src;
|
|
59
|
+
iframe.title = title;
|
|
60
|
+
iframe.setAttribute(
|
|
61
|
+
'allow',
|
|
62
|
+
'accelerometer; encrypted-media; gyroscope; picture-in-picture',
|
|
63
|
+
);
|
|
64
|
+
iframe.setAttribute('allowfullscreen', '');
|
|
65
|
+
iframe.loading = 'lazy';
|
|
66
|
+
el.appendChild(iframe);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private enhanceImage(el: HTMLElement): void {
|
|
70
|
+
el.setAttribute('data-enhanced', 'true');
|
|
71
|
+
const src = el.dataset['imageSrc'] ?? '';
|
|
72
|
+
const alt = el.dataset['imageAlt'] ?? '';
|
|
73
|
+
const caption = el.dataset['imageCaption'] ?? '';
|
|
74
|
+
const width = el.dataset['imageWidth'] ?? '';
|
|
75
|
+
|
|
76
|
+
// Replace the placeholder with an actual <figure>
|
|
77
|
+
const figure = document.createElement('figure');
|
|
78
|
+
figure.className = 'ngmd-image';
|
|
79
|
+
if (width) figure.style.maxWidth = width;
|
|
80
|
+
|
|
81
|
+
const img = document.createElement('img');
|
|
82
|
+
img.src = src;
|
|
83
|
+
img.alt = alt;
|
|
84
|
+
img.loading = 'lazy';
|
|
85
|
+
figure.appendChild(img);
|
|
86
|
+
|
|
87
|
+
if (caption) {
|
|
88
|
+
const figcaption = document.createElement('figcaption');
|
|
89
|
+
figcaption.textContent = caption;
|
|
90
|
+
figure.appendChild(figcaption);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
el.replaceWith(figure);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Component, computed, inject, signal } from '@angular/core';
|
|
2
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { NavigationEnd, Router, RouterLink } from '@angular/router';
|
|
4
|
+
import { filter, map, startWith } from 'rxjs';
|
|
5
|
+
import { LucideAngularModule, ArrowLeft, ArrowRight, Pencil } from 'lucide-angular';
|
|
6
|
+
import { pageMeta } from 'virtual:ngmd/page-meta';
|
|
7
|
+
import { navItems } from '../../ngmd.config';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bottom-of-page chrome shown under every docs route: previous/next sibling
|
|
11
|
+
* pages derived from `ngmd.config.ts`, an "Edit on GitHub" link, and the
|
|
12
|
+
* page's last-updated date (commit cs from `git log`, baked at build time
|
|
13
|
+
* via the page-meta vite plugin).
|
|
14
|
+
*/
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'app-page-footer',
|
|
17
|
+
imports: [RouterLink, LucideAngularModule],
|
|
18
|
+
template: `
|
|
19
|
+
<footer class="mt-12 border-t border-zinc-200 dark:border-zinc-800 pt-6 pb-10 text-sm">
|
|
20
|
+
<div class="flex flex-wrap items-center justify-between gap-3 text-zinc-500 dark:text-zinc-400">
|
|
21
|
+
@if (editUrl(); as url) {
|
|
22
|
+
<a
|
|
23
|
+
[href]="url"
|
|
24
|
+
target="_blank"
|
|
25
|
+
rel="noopener noreferrer"
|
|
26
|
+
class="inline-flex items-center gap-1.5 hover:text-zinc-900 dark:hover:text-zinc-100"
|
|
27
|
+
>
|
|
28
|
+
<i-lucide [img]="editIcon" class="size-3.5"></i-lucide>
|
|
29
|
+
Edit this page on GitHub
|
|
30
|
+
</a>
|
|
31
|
+
}
|
|
32
|
+
@if (lastUpdated(); as date) {
|
|
33
|
+
<span>Last updated: {{ date }}</span>
|
|
34
|
+
}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
@if (prev() || next()) {
|
|
38
|
+
<nav class="mt-6 grid gap-3 sm:grid-cols-2">
|
|
39
|
+
@if (prev(); as p) {
|
|
40
|
+
<a
|
|
41
|
+
[routerLink]="p.href"
|
|
42
|
+
class="group rounded-lg border border-zinc-200 dark:border-zinc-800 p-4 hover:border-fuchsia-500 dark:hover:border-fuchsia-400 transition-colors sm:col-start-1"
|
|
43
|
+
>
|
|
44
|
+
<span class="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
|
45
|
+
<i-lucide [img]="prevIcon" class="size-3.5"></i-lucide>
|
|
46
|
+
Previous
|
|
47
|
+
</span>
|
|
48
|
+
<span class="mt-1 block text-base font-medium text-zinc-900 dark:text-zinc-100">{{ p.label }}</span>
|
|
49
|
+
</a>
|
|
50
|
+
}
|
|
51
|
+
@if (next(); as n) {
|
|
52
|
+
<a
|
|
53
|
+
[routerLink]="n.href"
|
|
54
|
+
class="group rounded-lg border border-zinc-200 dark:border-zinc-800 p-4 hover:border-fuchsia-500 dark:hover:border-fuchsia-400 transition-colors text-right sm:col-start-2"
|
|
55
|
+
>
|
|
56
|
+
<span class="flex items-center justify-end gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
|
57
|
+
Next
|
|
58
|
+
<i-lucide [img]="nextIcon" class="size-3.5"></i-lucide>
|
|
59
|
+
</span>
|
|
60
|
+
<span class="mt-1 block text-base font-medium text-zinc-900 dark:text-zinc-100">{{ n.label }}</span>
|
|
61
|
+
</a>
|
|
62
|
+
}
|
|
63
|
+
</nav>
|
|
64
|
+
}
|
|
65
|
+
</footer>
|
|
66
|
+
`,
|
|
67
|
+
})
|
|
68
|
+
export class PageFooter {
|
|
69
|
+
private readonly router = inject(Router);
|
|
70
|
+
|
|
71
|
+
readonly editIcon = Pencil;
|
|
72
|
+
readonly prevIcon = ArrowLeft;
|
|
73
|
+
readonly nextIcon = ArrowRight;
|
|
74
|
+
|
|
75
|
+
private readonly url = toSignal(
|
|
76
|
+
this.router.events.pipe(
|
|
77
|
+
filter((e) => e instanceof NavigationEnd),
|
|
78
|
+
map(() => this.router.url),
|
|
79
|
+
startWith(this.router.url),
|
|
80
|
+
),
|
|
81
|
+
{ initialValue: '/' },
|
|
82
|
+
);
|
|
83
|
+
private readonly cleanUrl = computed(
|
|
84
|
+
() => this.url().split('?')[0].split('#')[0],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
private readonly meta = computed(() => pageMeta[this.cleanUrl()]);
|
|
88
|
+
readonly editUrl = computed(() => this.meta()?.editUrl ?? '');
|
|
89
|
+
readonly lastUpdated = computed(() => {
|
|
90
|
+
const iso = this.meta()?.lastUpdated;
|
|
91
|
+
if (!iso) return '';
|
|
92
|
+
const [y, m, d] = iso.split('-');
|
|
93
|
+
return y && m && d ? `${d}/${m}/${y}` : iso;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
private readonly index = computed(() =>
|
|
97
|
+
navItems.findIndex((n) => n.href === this.cleanUrl()),
|
|
98
|
+
);
|
|
99
|
+
readonly prev = computed(() => {
|
|
100
|
+
const i = this.index();
|
|
101
|
+
return i > 0 ? navItems[i - 1] : null;
|
|
102
|
+
});
|
|
103
|
+
readonly next = computed(() => {
|
|
104
|
+
const i = this.index();
|
|
105
|
+
return i >= 0 && i < navItems.length - 1 ? navItems[i + 1] : null;
|
|
106
|
+
});
|
|
107
|
+
}
|