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.
Files changed (68) hide show
  1. package/README.md +64 -0
  2. package/index.mjs +165 -0
  3. package/package.json +49 -0
  4. package/template/README.md +26 -0
  5. package/template/angular.json +54 -0
  6. package/template/index.html +47 -0
  7. package/template/link-guard.plugin.ts +190 -0
  8. package/template/package.json +76 -0
  9. package/template/page-meta.plugin.ts +132 -0
  10. package/template/public/analog.svg +1 -0
  11. package/template/public/favicon.ico +0 -0
  12. package/template/public/favicon.svg +46 -0
  13. package/template/public/logo-mark.svg +46 -0
  14. package/template/public/logo.svg +53 -0
  15. package/template/public/vite.svg +1 -0
  16. package/template/sitemap.plugin.ts +148 -0
  17. package/template/src/app/app.config.server.ts +10 -0
  18. package/template/src/app/app.config.ts +59 -0
  19. package/template/src/app/app.spec.ts +20 -0
  20. package/template/src/app/app.ts +260 -0
  21. package/template/src/app/components/breadcrumb.ts +76 -0
  22. package/template/src/app/components/code-copy.ts +90 -0
  23. package/template/src/app/components/code-group.ts +70 -0
  24. package/template/src/app/components/command-palette.ts +299 -0
  25. package/template/src/app/components/external-links.ts +55 -0
  26. package/template/src/app/components/heading-anchors.ts +95 -0
  27. package/template/src/app/components/hlm-card.ts +31 -0
  28. package/template/src/app/components/media-enhancer.ts +95 -0
  29. package/template/src/app/components/page-footer.ts +107 -0
  30. package/template/src/app/components/sidebar.ts +65 -0
  31. package/template/src/app/components/toc.ts +131 -0
  32. package/template/src/app/layout-mode.service.ts +10 -0
  33. package/template/src/app/pages/[...not-found].page.ts +47 -0
  34. package/template/src/app/pages/index.page.ts +28 -0
  35. package/template/src/app/pages/welcome.page.ts +18 -0
  36. package/template/src/app/theme.ts +54 -0
  37. package/template/src/app/title-strategy.ts +48 -0
  38. package/template/src/app/ui/alert.ts +46 -0
  39. package/template/src/app/ui/callout.ts +57 -0
  40. package/template/src/app/ui/card.ts +41 -0
  41. package/template/src/app/ui/code-block.ts +76 -0
  42. package/template/src/app/ui/hero.ts +42 -0
  43. package/template/src/app/ui/image.ts +26 -0
  44. package/template/src/app/ui/index.ts +45 -0
  45. package/template/src/app/ui/pill.ts +42 -0
  46. package/template/src/app/ui/tabs.ts +76 -0
  47. package/template/src/app/ui/video.ts +40 -0
  48. package/template/src/app/ui/workflow.ts +51 -0
  49. package/template/src/content/welcome.md +19 -0
  50. package/template/src/main.server.ts +7 -0
  51. package/template/src/main.ts +6 -0
  52. package/template/src/marked-extensions/index.ts +47 -0
  53. package/template/src/marked-extensions/ngmd-code-group.ts +126 -0
  54. package/template/src/marked-extensions/ngmd-code-highlight.ts +102 -0
  55. package/template/src/marked-extensions/ngmd-code-import.ts +118 -0
  56. package/template/src/marked-extensions/ngmd-image.ts +41 -0
  57. package/template/src/marked-extensions/ngmd-keywords.ts +75 -0
  58. package/template/src/marked-extensions/ngmd-video.ts +48 -0
  59. package/template/src/marked-extensions/shiki-shared.ts +42 -0
  60. package/template/src/ngmd.config.ts +98 -0
  61. package/template/src/server/routes/api/v1/hello.ts +3 -0
  62. package/template/src/styles.css +347 -0
  63. package/template/src/test-setup.ts +6 -0
  64. package/template/src/vite-env.d.ts +9 -0
  65. package/template/tsconfig.app.json +14 -0
  66. package/template/tsconfig.json +34 -0
  67. package/template/tsconfig.spec.json +11 -0
  68. package/template/vite.config.ts +78 -0
@@ -0,0 +1,26 @@
1
+ import { Component, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'ngmd-image',
5
+ template: `
6
+ <figure class="my-6 mx-0" [style.max-width]="width()">
7
+ <img
8
+ [src]="src()"
9
+ [alt]="alt()"
10
+ loading="lazy"
11
+ class="w-full h-auto rounded-lg border border-zinc-200 dark:border-zinc-800"
12
+ />
13
+ @if (caption()) {
14
+ <figcaption class="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
15
+ {{ caption() }}
16
+ </figcaption>
17
+ }
18
+ </figure>
19
+ `,
20
+ })
21
+ export class NgmdImage {
22
+ readonly src = input.required<string>();
23
+ readonly alt = input<string>('');
24
+ readonly caption = input<string>('');
25
+ readonly width = input<string>('');
26
+ }
@@ -0,0 +1,45 @@
1
+ export { NgmdAlert } from './alert';
2
+ export { NgmdCallout } from './callout';
3
+ export { NgmdCard } from './card';
4
+ export { NgmdCodeBlock } from './code-block';
5
+ export { NgmdHero } from './hero';
6
+ export { NgmdImage } from './image';
7
+ export { NgmdPill, NgmdPillRow } from './pill';
8
+ export { NgmdStep, NgmdWorkflow } from './workflow';
9
+ export { NgmdTab, NgmdTabs } from './tabs';
10
+ export { NgmdVideo } from './video';
11
+
12
+ import { NgmdAlert } from './alert';
13
+ import { NgmdCallout } from './callout';
14
+ import { NgmdCard } from './card';
15
+ import { NgmdCodeBlock } from './code-block';
16
+ import { NgmdHero } from './hero';
17
+ import { NgmdImage } from './image';
18
+ import { NgmdPill, NgmdPillRow } from './pill';
19
+ import { NgmdStep, NgmdWorkflow } from './workflow';
20
+ import { NgmdTab, NgmdTabs } from './tabs';
21
+ import { NgmdVideo } from './video';
22
+
23
+ /**
24
+ * Spread into a page's `imports` to get every chrome component in one go:
25
+ * `imports: [...NgmdUi]`. For lighter pages, import only what you use.
26
+ *
27
+ * Not declared `as const`: Angular's standalone-component compiler needs
28
+ * to resolve the array contents statically; a readonly tuple makes it
29
+ * bail out and the page silently renders empty.
30
+ */
31
+ export const NgmdUi = [
32
+ NgmdAlert,
33
+ NgmdCallout,
34
+ NgmdCard,
35
+ NgmdCodeBlock,
36
+ NgmdHero,
37
+ NgmdImage,
38
+ NgmdPill,
39
+ NgmdPillRow,
40
+ NgmdStep,
41
+ NgmdWorkflow,
42
+ NgmdTab,
43
+ NgmdTabs,
44
+ NgmdVideo,
45
+ ];
@@ -0,0 +1,42 @@
1
+ import { Component, computed, input } from '@angular/core';
2
+ import { RouterLink } from '@angular/router';
3
+
4
+ @Component({
5
+ selector: 'ngmd-pill',
6
+ imports: [RouterLink],
7
+ template: `
8
+ @if (isExternal()) {
9
+ <a
10
+ [href]="href()"
11
+ target="_blank"
12
+ rel="noopener noreferrer"
13
+ class="inline-flex items-center px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-sm font-medium text-zinc-900 dark:text-zinc-100 no-underline transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-900"
14
+ >
15
+ {{ title() }}
16
+ </a>
17
+ } @else {
18
+ <a
19
+ [routerLink]="href()"
20
+ class="inline-flex items-center px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-sm font-medium text-zinc-900 dark:text-zinc-100 no-underline transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-900"
21
+ >
22
+ {{ title() }}
23
+ </a>
24
+ }
25
+ `,
26
+ })
27
+ export class NgmdPill {
28
+ readonly href = input.required<string>();
29
+ readonly title = input.required<string>();
30
+
31
+ readonly isExternal = computed(() => /^https?:\/\//.test(this.href()));
32
+ }
33
+
34
+ @Component({
35
+ selector: 'ngmd-pill-row',
36
+ template: `
37
+ <div class="flex flex-wrap gap-2 my-4">
38
+ <ng-content></ng-content>
39
+ </div>
40
+ `,
41
+ })
42
+ export class NgmdPillRow {}
@@ -0,0 +1,76 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ AfterContentInit,
4
+ Component,
5
+ ContentChildren,
6
+ Directive,
7
+ inject,
8
+ input,
9
+ QueryList,
10
+ signal,
11
+ TemplateRef,
12
+ } from '@angular/core';
13
+ import { BrnTabsImports } from '@spartan-ng/brain/tabs';
14
+
15
+ @Directive({
16
+ selector: 'ng-template[ngmdTab]',
17
+ standalone: true,
18
+ })
19
+ export class NgmdTab {
20
+ readonly title = input.required<string>({ alias: 'ngmdTab' });
21
+ readonly templateRef = inject(TemplateRef);
22
+ }
23
+
24
+ @Component({
25
+ selector: 'ngmd-tabs',
26
+ imports: [BrnTabsImports, NgTemplateOutlet],
27
+ template: `
28
+ <div
29
+ [brnTabs]="active()"
30
+ (brnTabsChange)="active.set($any($event))"
31
+ class="my-6 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden"
32
+ >
33
+ <div
34
+ brnTabsList
35
+ class="flex flex-wrap border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900"
36
+ >
37
+ @for (tab of tabs(); track tab.key) {
38
+ <button
39
+ type="button"
40
+ [brnTabsTrigger]="tab.key"
41
+ class="px-4 py-2.5 text-sm font-medium border-b-2 cursor-pointer transition-colors data-[state=active]:border-zinc-900 dark:data-[state=active]:border-zinc-100 data-[state=active]:text-zinc-900 dark:data-[state=active]:text-zinc-100 data-[state=inactive]:border-transparent data-[state=inactive]:text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 bg-transparent"
42
+ >
43
+ {{ tab.label }}
44
+ </button>
45
+ }
46
+ </div>
47
+
48
+ @for (tab of tabs(); track tab.key) {
49
+ <div
50
+ [brnTabsContent]="tab.key"
51
+ class="p-5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
52
+ >
53
+ <ng-container *ngTemplateOutlet="tab.template"></ng-container>
54
+ </div>
55
+ }
56
+ </div>
57
+ `,
58
+ })
59
+ export class NgmdTabs implements AfterContentInit {
60
+ @ContentChildren(NgmdTab) private readonly children!: QueryList<NgmdTab>;
61
+
62
+ readonly tabs = signal<
63
+ { key: string; label: string; template: TemplateRef<unknown> }[]
64
+ >([]);
65
+ readonly active = signal('');
66
+
67
+ ngAfterContentInit(): void {
68
+ const list = this.children.toArray().map((c, i) => ({
69
+ key: `tab-${i}`,
70
+ label: c.title(),
71
+ template: c.templateRef,
72
+ }));
73
+ this.tabs.set(list);
74
+ if (list[0]) this.active.set(list[0].key);
75
+ }
76
+ }
@@ -0,0 +1,40 @@
1
+ import { Component, computed, inject, input } from '@angular/core';
2
+ import { DomSanitizer } from '@angular/platform-browser';
3
+
4
+ @Component({
5
+ selector: 'ngmd-video',
6
+ template: `
7
+ <div class="relative w-full aspect-video my-6 rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800">
8
+ <iframe
9
+ [src]="safeUrl()"
10
+ [title]="title()"
11
+ class="absolute inset-0 w-full h-full border-0"
12
+ allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
13
+ allowfullscreen
14
+ loading="lazy"
15
+ ></iframe>
16
+ </div>
17
+ `,
18
+ })
19
+ export class NgmdVideo {
20
+ private readonly sanitizer = inject(DomSanitizer);
21
+
22
+ readonly src = input.required<string>();
23
+ readonly title = input<string>('Video player');
24
+
25
+ readonly safeUrl = computed(() =>
26
+ this.sanitizer.bypassSecurityTrustResourceUrl(this.embedUrl()),
27
+ );
28
+
29
+ private readonly embedUrl = computed(() => {
30
+ const src = this.src();
31
+ if (src.startsWith('https://www.youtube.com/embed/')) return src;
32
+ const yt = src.match(/youtube\.com\/watch\?v=([\w-]+)/);
33
+ if (yt) return `https://www.youtube.com/embed/${yt[1]}`;
34
+ const ytShort = src.match(/youtu\.be\/([\w-]+)/);
35
+ if (ytShort) return `https://www.youtube.com/embed/${ytShort[1]}`;
36
+ const vm = src.match(/vimeo\.com\/(\d+)/);
37
+ if (vm) return `https://player.vimeo.com/video/${vm[1]}`;
38
+ return src;
39
+ });
40
+ }
@@ -0,0 +1,51 @@
1
+ import {
2
+ AfterContentInit,
3
+ Component,
4
+ ContentChildren,
5
+ input,
6
+ QueryList,
7
+ signal,
8
+ } from '@angular/core';
9
+
10
+ @Component({
11
+ selector: 'ngmd-step',
12
+ template: `
13
+ <div class="flex gap-4 pb-8 relative ngmd-step">
14
+ <div
15
+ class="flex-shrink-0 w-10 h-10 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 flex items-center justify-center font-semibold text-sm relative z-10"
16
+ >
17
+ {{ index() + 1 }}
18
+ </div>
19
+ <div class="flex-1 min-w-0">
20
+ @if (title()) {
21
+ <h3 class="text-lg font-semibold mt-1.5 mb-2 text-zinc-900 dark:text-zinc-100">{{ title() }}</h3>
22
+ }
23
+ <div class="text-zinc-700 dark:text-zinc-300 leading-relaxed [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
24
+ <ng-content></ng-content>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ `,
29
+ })
30
+ export class NgmdStep {
31
+ readonly title = input<string>('');
32
+ readonly index = signal(0);
33
+ }
34
+
35
+ @Component({
36
+ selector: 'ngmd-workflow',
37
+ template: `
38
+ <div
39
+ class="my-8 [&_.ngmd-step:not(:last-child)]:before:content-[''] [&_.ngmd-step:not(:last-child)]:before:absolute [&_.ngmd-step:not(:last-child)]:before:left-5 [&_.ngmd-step:not(:last-child)]:before:top-11 [&_.ngmd-step:not(:last-child)]:before:bottom-0 [&_.ngmd-step:not(:last-child)]:before:w-px [&_.ngmd-step:not(:last-child)]:before:bg-zinc-200 dark:[&_.ngmd-step:not(:last-child)]:before:bg-zinc-800"
40
+ >
41
+ <ng-content></ng-content>
42
+ </div>
43
+ `,
44
+ })
45
+ export class NgmdWorkflow implements AfterContentInit {
46
+ @ContentChildren(NgmdStep) private readonly steps!: QueryList<NgmdStep>;
47
+
48
+ ngAfterContentInit(): void {
49
+ this.steps.forEach((step, i) => step.index.set(i));
50
+ }
51
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ title: Welcome
3
+ ---
4
+
5
+ # Welcome
6
+
7
+ This is your first docs page, rendered from `src/content/welcome.md`.
8
+
9
+ ## Add a page
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.
12
+
13
+ ## Add nav
14
+
15
+ Edit the `nav` array in `src/ngmd.config.ts`. Sidebar, command palette, breadcrumb, and prev/next footer all read from there.
16
+
17
+ ## Authoring components
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.
@@ -0,0 +1,7 @@
1
+ import '@angular/platform-server/init';
2
+ import { render } from '@analogjs/router/server';
3
+
4
+ import { App } from './app/app';
5
+ import { config } from './app/app.config.server';
6
+
7
+ export default render(App, config);
@@ -0,0 +1,6 @@
1
+ import { bootstrapApplication } from '@angular/platform-browser';
2
+
3
+ import { App } from './app/app';
4
+ import { appConfig } from './app/app.config';
5
+
6
+ bootstrapApplication(App, appConfig);
@@ -0,0 +1,47 @@
1
+ import type { MarkedExtension } from 'marked';
2
+ import { ngmdVideoExtension } from './ngmd-video';
3
+ import { ngmdImageExtension } from './ngmd-image';
4
+ import { ngmdKeywordsExtension } from './ngmd-keywords';
5
+
6
+ /**
7
+ * Marked extensions are split into two arrays.
8
+ *
9
+ * `ngmdRuntimeExtensions` — safe to register on the browser-side marked
10
+ * instance via `app.config.ts > provideAppInitializer`. Only token-level
11
+ * extensions with no Node deps (currently `<ngmd-video>` and `<ngmd-image>`).
12
+ *
13
+ * `ngmdBuildExtensions` — used at build time by `vite.config.ts >
14
+ * markedOptions.extensions`. Adds the build-only extensions that touch
15
+ * `node:fs` (code-import) or load a shiki highlighter (code-group), which
16
+ * would crash if pulled into the client bundle.
17
+ *
18
+ * Chrome (cards, tabs, callouts, alerts, pill rows, workflows, hero, code
19
+ * blocks) lives as Angular components under `src/app/ui/`, not here.
20
+ */
21
+ export const ngmdRuntimeExtensions: MarkedExtension[] = [
22
+ {
23
+ extensions: [ngmdVideoExtension, ngmdImageExtension],
24
+ },
25
+ ngmdKeywordsExtension,
26
+ ];
27
+
28
+ // Build-time-only extensions are imported lazily below so the runtime bundle
29
+ // never resolves their `node:fs` / `shiki` imports. The async getter is
30
+ // called by `vite.config.ts` (Node context) only.
31
+ export async function getBuildExtensions(): Promise<MarkedExtension[]> {
32
+ const [
33
+ { ngmdCodeImportExtension },
34
+ { ngmdCodeGroupExtension },
35
+ { ngmdCodeHighlightExtension },
36
+ ] = await Promise.all([
37
+ import('./ngmd-code-import'),
38
+ import('./ngmd-code-group'),
39
+ import('./ngmd-code-highlight'),
40
+ ]);
41
+ return [
42
+ ...ngmdRuntimeExtensions,
43
+ ngmdCodeImportExtension,
44
+ ngmdCodeGroupExtension,
45
+ ngmdCodeHighlightExtension,
46
+ ];
47
+ }
@@ -0,0 +1,126 @@
1
+ import type { MarkedExtension } from 'marked';
2
+ import { getHighlighter, LANGS, escapeHtml } from './shiki-shared';
3
+
4
+ /**
5
+ * Adjacent fenced code blocks tagged with `group="..."` merge into a tabbed
6
+ * UI. Tab labels default to the language; pass `name="pnpm"` to override.
7
+ * Mark the initial tab with the `active` flag.
8
+ *
9
+ * ```bash group="install" name="pnpm" active
10
+ * pnpm create ngmd@latest my-docs
11
+ * ```
12
+ *
13
+ * ```bash group="install" name="npm"
14
+ * npm create ngmd@latest my-docs
15
+ * ```
16
+ *
17
+ * Implementation: preprocess runs before marked tokenizes. It finds
18
+ * consecutive group fences, pre-renders each body through a cached shiki
19
+ * highlighter, and emits a single self-contained HTML wrapper. By the time
20
+ * marked sees it, it's a finished `<div>` block with `<pre>` children — no
21
+ * fenced-code re-parsing, no tokenizer race with marked-shiki, no marked
22
+ * HTML-block quirks.
23
+ */
24
+
25
+ let groupCounter = 0;
26
+
27
+ const FENCE_WITH_GROUP_RE =
28
+ /^```([\w-]+)?[\t ]+([^\n]*?\bgroup="([^"]+)"[^\n]*)\n([\s\S]*?)\n```$/gm;
29
+
30
+ interface Fence {
31
+ start: number;
32
+ end: number;
33
+ lang: string;
34
+ attrs: string;
35
+ group: string;
36
+ body: string;
37
+ }
38
+
39
+ function getAttr(attrs: string, name: string): string | undefined {
40
+ return new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1];
41
+ }
42
+
43
+ function hasFlag(attrs: string, name: string): boolean {
44
+ return new RegExp(`(^|\\s)${name}(\\s|$)`).test(attrs);
45
+ }
46
+
47
+ async function renderCode(body: string, lang: string): Promise<string> {
48
+ const safeLang = LANGS.includes(lang) ? lang : 'text';
49
+ const highlighter = await getHighlighter();
50
+ return highlighter.codeToHtml(body, { lang: safeLang, theme: 'github-dark' });
51
+ }
52
+
53
+ export const ngmdCodeGroupExtension: MarkedExtension = {
54
+ hooks: {
55
+ async preprocess(markdown: string): Promise<string> {
56
+ const fences: Fence[] = [];
57
+ const re = new RegExp(
58
+ FENCE_WITH_GROUP_RE.source,
59
+ FENCE_WITH_GROUP_RE.flags,
60
+ );
61
+ let m: RegExpExecArray | null;
62
+ while ((m = re.exec(markdown)) !== null) {
63
+ fences.push({
64
+ start: m.index,
65
+ end: m.index + m[0].length,
66
+ lang: m[1] ?? '',
67
+ attrs: m[2],
68
+ group: m[3],
69
+ body: m[4],
70
+ });
71
+ }
72
+ if (fences.length === 0) return markdown;
73
+
74
+ // Cluster consecutive same-group fences (whitespace-only between).
75
+ const clusters: Fence[][] = [];
76
+ let current: Fence[] = [];
77
+ for (const f of fences) {
78
+ if (
79
+ current.length > 0 &&
80
+ current[0].group === f.group &&
81
+ /^\s*$/.test(markdown.slice(current.at(-1)!.end, f.start))
82
+ ) {
83
+ current.push(f);
84
+ } else {
85
+ if (current.length > 0) clusters.push(current);
86
+ current = [f];
87
+ }
88
+ }
89
+ if (current.length > 0) clusters.push(current);
90
+
91
+ // Replace from end to start so indices stay valid.
92
+ let result = markdown;
93
+ for (let i = clusters.length - 1; i >= 0; i--) {
94
+ const c = clusters[i];
95
+ if (c.length < 2) continue;
96
+
97
+ const groupId = `cg-${++groupCounter}`;
98
+ let activeIdx = c.findIndex((f) => hasFlag(f.attrs, 'active'));
99
+ if (activeIdx === -1) activeIdx = 0;
100
+
101
+ const tabs = c
102
+ .map((f, idx) => {
103
+ const name = getAttr(f.attrs, 'name') ?? (f.lang || `tab ${idx + 1}`);
104
+ return `<button type="button" class="ngmd-code-group__tab" data-target="${groupId}-${idx}" data-active="${idx === activeIdx}">${escapeHtml(name)}</button>`;
105
+ })
106
+ .join('');
107
+
108
+ const panels = (
109
+ await Promise.all(
110
+ c.map(async (f, idx) => {
111
+ const html = await renderCode(f.body, f.lang);
112
+ return `<div class="ngmd-code-group__panel" data-id="${groupId}-${idx}" data-active="${idx === activeIdx}">${html}</div>`;
113
+ }),
114
+ )
115
+ ).join('');
116
+
117
+ const wrapper = `\n\n<div class="ngmd-code-group" data-group="${groupId}"><div class="ngmd-code-group__tabs">${tabs}</div>${panels}</div>\n\n`;
118
+
119
+ result =
120
+ result.slice(0, c[0].start) + wrapper + result.slice(c.at(-1)!.end);
121
+ }
122
+
123
+ return result;
124
+ },
125
+ },
126
+ };
@@ -0,0 +1,102 @@
1
+ import type { MarkedExtension } from 'marked';
2
+ import { getHighlighter, LANGS } from './shiki-shared';
3
+
4
+ /**
5
+ * Fenced code blocks tagged with `{1,3-5}` get the matching lines visually
6
+ * highlighted. Comma-separated ranges, GitHub-style: `{1}`, `{3-5}`, `{1,3-5,8}`.
7
+ *
8
+ * ```ts {3-5}
9
+ * import { Component } from '@angular/core';
10
+ *
11
+ * @Component({
12
+ * selector: 'app-hello',
13
+ * template: '<h1>Hello</h1>',
14
+ * })
15
+ * export class Hello {}
16
+ * ```
17
+ *
18
+ * Implementation: preprocess detects the meta, pre-renders the fence via a
19
+ * cached shiki highlighter, then post-processes the rendered HTML to add
20
+ * `class="highlighted"` to matching `<span class="line">` elements. CSS in
21
+ * styles.css tints those lines.
22
+ *
23
+ * Scope: skips fences with `group="..."` (handled by ngmd-code-group) or
24
+ * `file="..."` (handled by ngmd-code-import). One fence, one treatment.
25
+ */
26
+
27
+ // Capture: lang, line ranges in {}, body. Skips fences whose info string
28
+ // contains `group=` or `file=` so those routes own the fence.
29
+ const FENCE_RE =
30
+ /^```([\w-]+)?[\t ]+\{([0-9,\-\s]+)\}[\t ]*\n([\s\S]*?)\n```$/gm;
31
+
32
+ function parseRanges(spec: string): Set<number> {
33
+ const lines = new Set<number>();
34
+ for (const part of spec.split(',').map((s) => s.trim()).filter(Boolean)) {
35
+ const m = part.match(/^(\d+)(?:-(\d+))?$/);
36
+ if (!m) continue;
37
+ const start = parseInt(m[1], 10);
38
+ const end = m[2] ? parseInt(m[2], 10) : start;
39
+ for (let i = start; i <= end; i++) lines.add(i);
40
+ }
41
+ return lines;
42
+ }
43
+
44
+ /**
45
+ * Walks the shiki output's `<span class="line">` elements, adds the
46
+ * `highlighted` class to lines whose 1-indexed position is in `set`.
47
+ */
48
+ function applyHighlights(html: string, set: Set<number>): string {
49
+ let lineNum = 0;
50
+ return html.replace(/<span class="line"/g, () => {
51
+ lineNum++;
52
+ return set.has(lineNum)
53
+ ? '<span class="line highlighted"'
54
+ : '<span class="line"';
55
+ });
56
+ }
57
+
58
+ export const ngmdCodeHighlightExtension: MarkedExtension = {
59
+ hooks: {
60
+ async preprocess(markdown: string): Promise<string> {
61
+ // Quick negative check before scanning.
62
+ if (!/^```[\w-]*[\t ]+\{[0-9,\-\s]+\}/m.test(markdown)) return markdown;
63
+
64
+ const matches: { start: number; end: number; lang: string; spec: string; body: string }[] = [];
65
+ const re = new RegExp(FENCE_RE.source, FENCE_RE.flags);
66
+ let m: RegExpExecArray | null;
67
+ while ((m = re.exec(markdown)) !== null) {
68
+ // Skip if the fence also carries `group=` or `file=` (other ext owns).
69
+ const infoLineEnd = markdown.indexOf('\n', m.index);
70
+ const infoLine = markdown.slice(m.index, infoLineEnd);
71
+ if (/\b(?:group|file)="/.test(infoLine)) continue;
72
+ matches.push({
73
+ start: m.index,
74
+ end: m.index + m[0].length,
75
+ lang: m[1] ?? '',
76
+ spec: m[2],
77
+ body: m[3],
78
+ });
79
+ }
80
+ if (matches.length === 0) return markdown;
81
+
82
+ const highlighter = await getHighlighter();
83
+ const renders = await Promise.all(
84
+ matches.map(async (mt) => {
85
+ const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text';
86
+ const raw = highlighter.codeToHtml(mt.body, {
87
+ lang: safeLang,
88
+ theme: 'github-dark',
89
+ });
90
+ return applyHighlights(raw, parseRanges(mt.spec));
91
+ }),
92
+ );
93
+
94
+ let result = markdown;
95
+ for (let i = matches.length - 1; i >= 0; i--) {
96
+ const mt = matches[i];
97
+ result = result.slice(0, mt.start) + `\n\n${renders[i]}\n\n` + result.slice(mt.end);
98
+ }
99
+ return result;
100
+ },
101
+ },
102
+ };