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,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,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
|
+
};
|