From 9ccf6b296b3d54a3069eb87b84bdbd80377ff7b4 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 21 Jun 2024 16:55:20 -0500 Subject: [PATCH] feat(content): introduce build-time rendering/highlighting for markdown (#1174) --- apps/blog-app/src/app/app.config.ts | 10 +- apps/blog-app/vite.config.ts | 8 ++ .../docs-app/docs/features/routing/content.md | 107 ++++++++---------- .../content/prism-highlighter/src/index.ts | 9 +- .../src/lib/prism-highlighter.spec.ts | 56 --------- .../content/shiki-highlighter/src/index.ts | 70 ++++-------- .../src/lib/shiki-highlighter.spec.ts | 30 ----- packages/content/src/index.ts | 6 +- packages/content/src/lib/content-renderer.ts | 44 ++++++- packages/content/src/lib/get-content-files.ts | 2 +- .../lib/markdown-content-renderer.service.ts | 37 +----- .../content/src/lib/markdown.component.ts | 2 +- packages/content/src/lib/provide-content.ts | 41 +++++++ packages/create-analog/index.js | 8 ++ .../template-blog/vite.config.ts | 3 + packages/platform/src/lib/content-plugin.ts | 51 ++++++++- .../lib/content/marked-content-highlighter.ts | 7 ++ .../src/lib/content/marked-setup.service.ts | 49 ++++++++ .../src/lib/content}/prism/angular.js | 0 .../platform/src/lib/content/prism/index.ts | 7 ++ .../lib/content/prism}/prism-highlighter.ts | 29 +++-- .../platform/src/lib/content/shiki/index.ts | 49 ++++++++ .../lib/content/shiki}/shiki-highlighter.ts | 38 +++---- packages/platform/src/lib/options.ts | 6 + packages/platform/src/lib/platform-plugin.ts | 5 +- packages/platform/tsconfig.lib.json | 1 + packages/router/src/lib/routes.ts | 2 +- .../src/lib/angular-vite-plugin.ts | 2 +- 28 files changed, 390 insertions(+), 289 deletions(-) delete mode 100644 packages/content/prism-highlighter/src/lib/prism-highlighter.spec.ts delete mode 100644 packages/content/shiki-highlighter/src/lib/shiki-highlighter.spec.ts create mode 100644 packages/content/src/lib/provide-content.ts create mode 100644 packages/platform/src/lib/content/marked-content-highlighter.ts create mode 100644 packages/platform/src/lib/content/marked-setup.service.ts rename packages/{content/prism-highlighter/src/lib => platform/src/lib/content}/prism/angular.js (100%) create mode 100644 packages/platform/src/lib/content/prism/index.ts rename packages/{content/prism-highlighter/src/lib => platform/src/lib/content/prism}/prism-highlighter.ts (78%) create mode 100644 packages/platform/src/lib/content/shiki/index.ts rename packages/{content/shiki-highlighter/src/lib => platform/src/lib/content/shiki}/shiki-highlighter.ts (60%) diff --git a/apps/blog-app/src/app/app.config.ts b/apps/blog-app/src/app/app.config.ts index 55ea02cab..973485d8a 100644 --- a/apps/blog-app/src/app/app.config.ts +++ b/apps/blog-app/src/app/app.config.ts @@ -14,12 +14,10 @@ export const appConfig: ApplicationConfig = { provideHttpClient(), provideClientHydration(), provideContent( - withMarkdownRenderer({ loadMermaid: () => import('mermaid') }), - withShikiHighlighter({ - highlighter: { - additionalLangs: ['mermaid'], - }, - }) + withMarkdownRenderer({ + loadMermaid: () => import('mermaid'), + }), + withShikiHighlighter() ), provideFileRouter( withInMemoryScrolling({ anchorScrolling: 'enabled' }), diff --git a/apps/blog-app/vite.config.ts b/apps/blog-app/vite.config.ts index 21f41ebe4..ac3d02ed1 100644 --- a/apps/blog-app/vite.config.ts +++ b/apps/blog-app/vite.config.ts @@ -19,6 +19,14 @@ export default defineConfig(() => { plugins: [ analog({ static: true, + content: { + highlighter: 'shiki', + shikiOptions: { + highlighter: { + additionalLangs: ['mermaid'], + }, + }, + }, prerender: { routes: async () => { return [ diff --git a/apps/docs-app/docs/features/routing/content.md b/apps/docs-app/docs/features/routing/content.md index 5a50f732b..b6c7fd00e 100644 --- a/apps/docs-app/docs/features/routing/content.md +++ b/apps/docs-app/docs/features/routing/content.md @@ -108,33 +108,64 @@ export const appConfig: ApplicationConfig = { }; ``` +To enable build-time syntax highlighting with `shiki`, configure the `analog` plugin in the `vite.config.ts`. + +```ts +import { defineConfig } from 'vite'; +import analog from '@analogjs/platform'; + +export default defineConfig({ + // ... + plugins: [ + analog({ + content: { + highlighter: 'shiki', + }, + }), + ], +}); +``` + #### Configure Shiki Highlighter > Please check out [Shiki Documentation](https://shiki.style/) for more information on configuring Shiki. -To configure Shiki, you can pass a `WithShikiHighlighterOptions` object to the `withShikiHighlighter()` function. +To configure Shiki, you can pass options to the `shikiOptions` object. ```ts -import { withShikiHighlighter } from '@analogjs/content/shiki-highlighter'; - -provideContent( - withMarkdownRenderer(), - withShikiHighlighter({ - highlight: { theme: 'nord' }, - }) -); +import { defineConfig } from 'vite'; +import analog from '@analogjs/platform'; + +export default defineConfig({ + // ... + plugins: [ + analog({ + content: { + highlighter: 'shiki', + shikiOptions: { + highlight: { + // alternate theme + theme: 'ayu-dark' + } + highlighter: { + // add more languages + additionalLangs: ['mermaid'], + }, + }, + }, + }), + ], +}); ``` -By default, `withShikiHighlighter` has the following options. +By default, `shikiOptions` has the following options. -```json +```ts { + "container": "%s", "highlight": { - "themes": { - "dark": "github-dark", - "light": "github-light" - } - }, + "theme": "github-dark" + } "highlighter": { "langs": [ "json", @@ -145,55 +176,13 @@ By default, `withShikiHighlighter` has the following options. "html", "css", "angular-html", - "angular-ts" + "angular-ts", ], "themes": ["github-dark", "github-light"] } } ``` -Provided options will be merged **shallowly**. For example: - -```ts -import { withShikiHighlighter } from '@analogjs/content/shiki-highlighter'; - -withShikiHighlighter({ - highlighter: { - // langs will be provied by the default options - themes: ['ayu-dark'], // only ayu-dark will be bundled - }, - highlight: { - theme: 'ayu-dark', // use ayu-dark as the theme - // theme: 'dark-plus' // ERROR: dark-plus is not bundled - }, -}); -``` - -### Custom Syntax Highlighter - -If you want to use a custom syntax highlighter, you can use the `withHighlighter()` function to provide a custom highlighter. - -```ts -import { withHighlighter, MarkedContentHighlighter } from '@analogjs/content'; -// NOTE: make sure to install 'marked-highlight' if not already installed -import { markedHighlight } from 'marked-highlight'; - -class CustomHighlighter extends MarkedContentHighlighter { - override getHighlightExtension() { - return markedHighlight({ - highlight: (code, lang) => { - return 'your custom highlight'; - }, - }); - } -} - -provideContent( - withMarkdownRenderer(), - withHighlighter({ useClass: CustomHighlighter }) -); -``` - ## Defining Content Files For more flexibility, markdown content files can be provided in the `src/content` folder. Here you can list markdown files such as blog posts. diff --git a/packages/content/prism-highlighter/src/index.ts b/packages/content/prism-highlighter/src/index.ts index 53f7da9a6..eb3dad76d 100644 --- a/packages/content/prism-highlighter/src/index.ts +++ b/packages/content/prism-highlighter/src/index.ts @@ -1,9 +1,6 @@ -import { withHighlighter } from '@analogjs/content'; +import { ContentRenderer, NoopContentRenderer } from '@analogjs/content'; import { Provider } from '@angular/core'; -import { PrismHighlighter } from './lib/prism-highlighter'; -export { PrismHighlighter }; - -export function withPrismHighlighter(): Provider { - return withHighlighter({ useClass: PrismHighlighter }); +export function withPrismHighlighter(): Provider[] { + return [{ provide: ContentRenderer, useClass: NoopContentRenderer }]; } diff --git a/packages/content/prism-highlighter/src/lib/prism-highlighter.spec.ts b/packages/content/prism-highlighter/src/lib/prism-highlighter.spec.ts deleted file mode 100644 index e15493677..000000000 --- a/packages/content/prism-highlighter/src/lib/prism-highlighter.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - MarkdownContentRendererService, - MarkedContentHighlighter, - MarkedSetupService, -} from '@analogjs/content'; -import { TestBed } from '@angular/core/testing'; -import { withPrismHighlighter } from '../index'; - -describe('PrismHighlighter', () => { - function setup() { - TestBed.configureTestingModule({ - providers: [ - MarkdownContentRendererService, - MarkedSetupService, - withPrismHighlighter(), - ], - }); - return { service: TestBed.inject(MarkdownContentRendererService) }; - } - - it('render should correctly highlight diff code blocks', async () => { - const { service } = setup(); - window.Prism.languages['diff'] = {}; - let testCode = "```diff-javascript\nconsole.log('Hello, world!');\n```"; - let result = await service.render(testCode); - - expect(result).toContain( - '
'
-    );
-
-    testCode = "```diff-typescript\nconsole.log('Hello, world!');\n```";
-    result = await service.render(testCode);
-
-    expect(result).toContain(
-      '
'
-    );
-  });
-
-  it('render should fall back to language-only highlighting if `diff` plugin is not imported', async () => {
-    const { service } = setup();
-    delete window.Prism.languages['diff'];
-    let testCode = "```diff-javascript\nconsole.log('Hello, world!');\n```";
-    let result = await service.render(testCode);
-
-    expect(result).toContain(
-      '
'
-    );
-
-    testCode = "```diff-typescript\nconsole.log('Hello, world!');\n```";
-    result = await service.render(testCode);
-
-    expect(result).toContain(
-      '
'
-    );
-  });
-});
diff --git a/packages/content/shiki-highlighter/src/index.ts b/packages/content/shiki-highlighter/src/index.ts
index 854e3730b..cc8d862eb 100644
--- a/packages/content/shiki-highlighter/src/index.ts
+++ b/packages/content/shiki-highlighter/src/index.ts
@@ -1,54 +1,32 @@
-import { withHighlighter } from '@analogjs/content';
+import { ContentRenderer, NoopContentRenderer } from '@analogjs/content';
 import { Provider } from '@angular/core';
-import type { BundledLanguage } from 'shiki';
-import {
-  defaultHighlighterOptions,
-  SHIKI_CONTAINER_OPTION,
-  SHIKI_HIGHLIGHT_OPTIONS,
-  SHIKI_HIGHLIGHTER_OPTIONS,
-  ShikiHighlighter,
-  type ShikiHighlighterOptions,
-  type ShikiHighlightOptions,
-} from './lib/shiki-highlighter';
+import type {
+  BundledLanguage,
+  BundledTheme,
+  CodeOptionsMeta,
+  CodeOptionsMultipleThemes,
+  CodeOptionsSingleTheme,
+  CodeToHastOptionsCommon,
+} from 'shiki';
 
-export { ShikiHighlighter };
+export type ShikiHighlightOptions = Partial<
+  Omit, 'lang'>
+> &
+  CodeOptionsMeta &
+  Partial> &
+  Partial>;
 
-export interface WithShikiHighlighterOptions {
-  highlighter?: Partial & {
-    additionalLangs?: BundledLanguage[];
-  };
-  highlight?: ShikiHighlightOptions;
+export type WithShikiHighlighterOptions = ShikiHighlightOptions & {
   container?: string;
-}
-
-export function withShikiHighlighter({
-  highlighter = {},
-  highlight = {},
-  container = '%s',
-}: WithShikiHighlighterOptions = {}): Provider {
-  if (!highlighter.themes) {
-    if (highlight.theme) {
-      highlighter.themes = [highlight.theme];
-    } else if (highlight.themes && typeof highlight.themes === 'object') {
-      highlighter.themes = Object.values(highlight.themes) as string[];
-    } else {
-      highlighter.themes = defaultHighlighterOptions.themes;
-    }
-  }
-
-  if (!highlighter.langs) {
-    highlighter.langs = defaultHighlighterOptions.langs;
-  }
-
-  if (highlighter.additionalLangs) {
-    highlighter.langs.push(...highlighter.additionalLangs);
-    delete highlighter.additionalLangs;
-  }
+};
 
+export function withShikiHighlighter(
+  _opts: WithShikiHighlighterOptions = {}
+): Provider[] {
   return [
-    { provide: SHIKI_HIGHLIGHTER_OPTIONS, useValue: highlighter },
-    { provide: SHIKI_HIGHLIGHT_OPTIONS, useValue: highlight },
-    { provide: SHIKI_CONTAINER_OPTION, useValue: container },
-    withHighlighter({ useClass: ShikiHighlighter }),
+    {
+      provide: ContentRenderer,
+      useClass: NoopContentRenderer,
+    },
   ];
 }
diff --git a/packages/content/shiki-highlighter/src/lib/shiki-highlighter.spec.ts b/packages/content/shiki-highlighter/src/lib/shiki-highlighter.spec.ts
deleted file mode 100644
index 0e70d0027..000000000
--- a/packages/content/shiki-highlighter/src/lib/shiki-highlighter.spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {
-  MarkdownContentRendererService,
-  MarkedSetupService,
-} from '@analogjs/content';
-import { withShikiHighlighter } from '../index';
-import { TestBed } from '@angular/core/testing';
-
-describe('ShikiHighlighter', () => {
-  function setup() {
-    TestBed.configureTestingModule({
-      providers: [
-        MarkdownContentRendererService,
-        MarkedSetupService,
-        withShikiHighlighter(),
-      ],
-    });
-    return { service: TestBed.inject(MarkdownContentRendererService) };
-  }
-
-  it('render should correctly highlight code blocks with shiki', async () => {
-    const { service } = setup();
-
-    const testCode =
-      '```angular-ts\n@Component({\ntemplate: `

Hello, world!

`\n})\nexport class MyCmp {}\n```'; - const result = await service.render(testCode); - expect(result).toContain( - '
+
+import { Injectable, TransferState, inject, makeStateKey } from '@angular/core';
+import { getHeadingList } from 'marked-gfm-heading-id';
 
 export type TableOfContentItem = {
   id: string;
@@ -19,3 +22,42 @@ export abstract class ContentRenderer {
   // eslint-disable-next-line
   enhance() {}
 }
+
+export class NoopContentRenderer implements ContentRenderer {
+  private readonly transferState = inject(TransferState);
+  private contentId = 0;
+
+  /**
+   * Generates a hash from the content string
+   * to be used with the transfer state
+   */
+  private generateHash(str: string) {
+    let hash = 0;
+    for (let i = 0, len = str.length; i < len; i++) {
+      let chr = str.charCodeAt(i);
+      hash = (hash << 5) - hash + chr;
+      hash |= 0; // Convert to 32bit integer
+    }
+    return hash;
+  }
+
+  async render(content: string) {
+    this.contentId = this.generateHash(content);
+    return content;
+  }
+  enhance() {}
+
+  getContentHeadings(): Array {
+    const key = makeStateKey(
+      `content-headings-${this.contentId}`
+    );
+
+    if (import.meta.env.SSR === true) {
+      const headings = getHeadingList();
+      this.transferState.set(key, headings);
+      return headings;
+    }
+
+    return this.transferState.get(key, []);
+  }
+}
diff --git a/packages/content/src/lib/get-content-files.ts b/packages/content/src/lib/get-content-files.ts
index 4fcb767ec..0d896466a 100644
--- a/packages/content/src/lib/get-content-files.ts
+++ b/packages/content/src/lib/get-content-files.ts
@@ -22,7 +22,7 @@ export const getContentFilesList = () =>
  */
 export const getContentFiles = () =>
   import.meta.glob(['/src/content/**/*.md'], {
-    query: '?raw',
+    query: '?analog-content-file=true',
     import: 'default',
   });
 
diff --git a/packages/content/src/lib/markdown-content-renderer.service.ts b/packages/content/src/lib/markdown-content-renderer.service.ts
index 2ce6a8217..d240b2348 100644
--- a/packages/content/src/lib/markdown-content-renderer.service.ts
+++ b/packages/content/src/lib/markdown-content-renderer.service.ts
@@ -1,9 +1,8 @@
-import { inject, Injectable, InjectionToken, Provider } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
 import { getHeadingList } from 'marked-gfm-heading-id';
 
 import { ContentRenderer, TableOfContentItem } from './content-renderer';
 import { MarkedSetupService } from './marked-setup.service';
-import { RenderTaskService } from './render-task.service';
 
 @Injectable()
 export class MarkdownContentRendererService implements ContentRenderer {
@@ -23,37 +22,3 @@ export class MarkdownContentRendererService implements ContentRenderer {
   // eslint-disable-next-line
   enhance() {}
 }
-
-export interface MarkdownRendererOptions {
-  loadMermaid?: () => Promise;
-}
-
-export function withMarkdownRenderer(
-  options?: MarkdownRendererOptions
-): Provider {
-  return [
-    MarkedSetupService,
-    RenderTaskService,
-    {
-      provide: ContentRenderer,
-      useFactory: () => new MarkdownContentRendererService(),
-      deps: [MarkedSetupService],
-    },
-    options?.loadMermaid
-      ? [
-          {
-            provide: MERMAID_IMPORT_TOKEN,
-            useFactory: options.loadMermaid,
-          },
-        ]
-      : [],
-  ];
-}
-
-export function provideContent(...features: Provider[]) {
-  return [...features];
-}
-
-export const MERMAID_IMPORT_TOKEN = new InjectionToken<
-  Promise
->('mermaid_import');
diff --git a/packages/content/src/lib/markdown.component.ts b/packages/content/src/lib/markdown.component.ts
index 47e11f8a6..380e4ebc0 100644
--- a/packages/content/src/lib/markdown.component.ts
+++ b/packages/content/src/lib/markdown.component.ts
@@ -20,7 +20,7 @@ import { catchError, map, mergeMap } from 'rxjs/operators';
 
 import { AnchorNavigationDirective } from './anchor-navigation.directive';
 import { ContentRenderer } from './content-renderer';
-import { MERMAID_IMPORT_TOKEN } from './markdown-content-renderer.service';
+import { MERMAID_IMPORT_TOKEN } from './provide-content';
 
 @Component({
   selector: 'analog-markdown',
diff --git a/packages/content/src/lib/provide-content.ts b/packages/content/src/lib/provide-content.ts
new file mode 100644
index 000000000..2c88f7c30
--- /dev/null
+++ b/packages/content/src/lib/provide-content.ts
@@ -0,0 +1,41 @@
+import { Provider, InjectionToken } from '@angular/core';
+import { ContentRenderer, NoopContentRenderer } from './content-renderer';
+import { RenderTaskService } from './render-task.service';
+
+export interface MarkdownRendererOptions {
+  loadMermaid?: () => Promise;
+}
+
+const CONTENT_RENDERER_PROVIDERS: Provider[] = [
+  {
+    provide: ContentRenderer,
+    useClass: NoopContentRenderer,
+  },
+];
+
+export function withMarkdownRenderer(
+  options?: MarkdownRendererOptions
+): Provider {
+  return [
+    CONTENT_RENDERER_PROVIDERS,
+    options?.loadMermaid
+      ? [
+          {
+            provide: MERMAID_IMPORT_TOKEN,
+            useFactory: options.loadMermaid,
+          },
+        ]
+      : [],
+  ];
+}
+
+export function provideContent(...features: Provider[]) {
+  return [
+    { provide: RenderTaskService, useClass: RenderTaskService },
+    ...features,
+  ];
+}
+
+export const MERMAID_IMPORT_TOKEN = new InjectionToken<
+  Promise
+>('mermaid_import');
diff --git a/packages/create-analog/index.js b/packages/create-analog/index.js
index cf15636ed..e0440910f 100755
--- a/packages/create-analog/index.js
+++ b/packages/create-analog/index.js
@@ -402,6 +402,14 @@ function ensureSyntaxHighlighter(root, pkg, highlighter) {
   for (const [name, version] of Object.entries(dependencies)) {
     pkg.dependencies[name] = version;
   }
+
+  const viteConfigPath = path.join(root, 'vite.config.ts');
+  const viteConfigContent = fs.readFileSync(viteConfigPath, 'utf-8');
+
+  fs.writeFileSync(
+    viteConfigPath,
+    viteConfigContent.replace(/__CONTENT_HIGHLIGHTER__/g, highlighter)
+  );
 }
 
 init().catch((e) => {
diff --git a/packages/create-analog/template-blog/vite.config.ts b/packages/create-analog/template-blog/vite.config.ts
index f468abdc7..4d6e0095b 100644
--- a/packages/create-analog/template-blog/vite.config.ts
+++ b/packages/create-analog/template-blog/vite.config.ts
@@ -13,6 +13,9 @@ export default defineConfig(({ mode }) => ({
   },
   plugins: [
     analog({
+      content: {
+        highlighter: '__CONTENT_HIGHLIGHTER__',
+      },
       prerender: {
         routes: ['/blog', '/blog/2022-12-27-my-first-post'],
       },
diff --git a/packages/platform/src/lib/content-plugin.ts b/packages/platform/src/lib/content-plugin.ts
index 16bc955f4..e13b7c0dc 100644
--- a/packages/platform/src/lib/content-plugin.ts
+++ b/packages/platform/src/lib/content-plugin.ts
@@ -1,14 +1,33 @@
 import { Plugin } from 'vite';
 import { readFileSync } from 'node:fs';
 
+import {
+  WithShikiHighlighterOptions,
+  getShikiHighlighter,
+} from './content/shiki/index.js';
+import { getPrismHighlighter } from './content/prism/index.js';
+
 interface Content {
   code: string;
   attributes: string;
 }
 
-export function contentPlugin(): Plugin[] {
+export function contentPlugin(
+  {
+    highlighter,
+    shikiOptions,
+  }: {
+    highlighter?: 'shiki' | 'prism';
+    shikiOptions?: WithShikiHighlighterOptions;
+  } = { highlighter: 'prism' }
+): Plugin[] {
   const cache = new Map();
 
+  const markedHighlighter =
+    highlighter === 'shiki'
+      ? getShikiHighlighter(shikiOptions)
+      : getPrismHighlighter();
+
   return [
     {
       name: 'analogjs-content-frontmatter',
@@ -43,5 +62,35 @@ export function contentPlugin(): Plugin[] {
         return `export default ${content.attributes}`;
       },
     },
+    {
+      name: 'analogjs-content-file',
+      enforce: 'post',
+      async load(id) {
+        if (!id.includes('analog-content-file=true')) {
+          return;
+        }
+
+        const fm: any = await import('front-matter');
+        // The `default` property will be available in CommonJS environment, for instance,
+        // when running unit tests. It's safe to retrieve `default` first, since we still
+        // fallback to the original implementation.
+        const frontmatterFn = fm.default || fm;
+        const fileContents = readFileSync(id.split('?')[0], 'utf8');
+        const { body, frontmatter } = frontmatterFn(fileContents);
+
+        // parse markdown and highlight
+        const { MarkedSetupService } = await import(
+          './content/marked-setup.service.js'
+        );
+        const markedSetupService = new MarkedSetupService(markedHighlighter);
+        const mdContent = (await markedSetupService
+          .getMarkedInstance()
+          .parse(body)) as unknown as string;
+
+        return `export default ${JSON.stringify(
+          `---\n${frontmatter}\n---\n\n${mdContent}`
+        )}`;
+      },
+    },
   ];
 }
diff --git a/packages/platform/src/lib/content/marked-content-highlighter.ts b/packages/platform/src/lib/content/marked-content-highlighter.ts
new file mode 100644
index 000000000..d9926f44b
--- /dev/null
+++ b/packages/platform/src/lib/content/marked-content-highlighter.ts
@@ -0,0 +1,7 @@
+export interface MarkedContentHighlighter {
+  augmentCodeBlock?(code: string, lang: string): string;
+}
+
+export abstract class MarkedContentHighlighter {
+  abstract getHighlightExtension(): import('marked').marked.MarkedExtension;
+}
diff --git a/packages/platform/src/lib/content/marked-setup.service.ts b/packages/platform/src/lib/content/marked-setup.service.ts
new file mode 100644
index 000000000..d66a9e719
--- /dev/null
+++ b/packages/platform/src/lib/content/marked-setup.service.ts
@@ -0,0 +1,49 @@
+import { marked } from 'marked';
+import { gfmHeadingId } from 'marked-gfm-heading-id';
+import { mangle } from 'marked-mangle';
+
+import { MarkedContentHighlighter } from './marked-content-highlighter.js';
+
+export class MarkedSetupService {
+  private readonly marked: typeof marked;
+
+  constructor(private readonly highlighter?: MarkedContentHighlighter) {
+    const renderer = new marked.Renderer();
+    renderer.code = (code: string, lang: string) => {
+      // Let's do a language based detection like on GitHub
+      // So we can still have non-interpreted mermaid code
+      if (lang === 'mermaid') {
+        return '
' + code + '
'; + } + + if (!lang) { + return '
' + code + '
'; + } + + if (this.highlighter?.augmentCodeBlock) { + return this.highlighter?.augmentCodeBlock(code, lang); + } + + return `
${code}
`; + }; + + const extensions = [gfmHeadingId(), mangle()]; + + if (this.highlighter) { + extensions.push(this.highlighter.getHighlightExtension()); + } + + marked.use(...extensions, { + renderer, + pedantic: false, + gfm: true, + breaks: false, + }); + + this.marked = marked; + } + + getMarkedInstance(): typeof marked { + return this.marked; + } +} diff --git a/packages/content/prism-highlighter/src/lib/prism/angular.js b/packages/platform/src/lib/content/prism/angular.js similarity index 100% rename from packages/content/prism-highlighter/src/lib/prism/angular.js rename to packages/platform/src/lib/content/prism/angular.js diff --git a/packages/platform/src/lib/content/prism/index.ts b/packages/platform/src/lib/content/prism/index.ts new file mode 100644 index 000000000..e085a51ed --- /dev/null +++ b/packages/platform/src/lib/content/prism/index.ts @@ -0,0 +1,7 @@ +import { PrismHighlighter } from './prism-highlighter.js'; + +export { PrismHighlighter }; + +export function getPrismHighlighter() { + return new PrismHighlighter(); +} diff --git a/packages/content/prism-highlighter/src/lib/prism-highlighter.ts b/packages/platform/src/lib/content/prism/prism-highlighter.ts similarity index 78% rename from packages/content/prism-highlighter/src/lib/prism-highlighter.ts rename to packages/platform/src/lib/content/prism/prism-highlighter.ts index feb20bc48..b8603747b 100644 --- a/packages/content/prism-highlighter/src/lib/prism-highlighter.ts +++ b/packages/platform/src/lib/content/prism/prism-highlighter.ts @@ -1,21 +1,20 @@ -import { MarkedContentHighlighter } from '@analogjs/content'; -import { Injectable } from '@angular/core'; import { markedHighlight } from 'marked-highlight'; import 'prismjs'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-css'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-markup'; -import 'prismjs/components/prism-typescript'; -import 'prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard'; -import 'prismjs/plugins/toolbar/prism-toolbar'; -import './prism/angular'; +import 'prismjs/components/prism-bash.js'; +import 'prismjs/components/prism-css.js'; +import 'prismjs/components/prism-javascript.js'; +import 'prismjs/components/prism-json.js'; +import 'prismjs/components/prism-markup.js'; +import 'prismjs/components/prism-typescript.js'; +import 'prismjs/plugins/toolbar/prism-toolbar.js'; +import 'prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard.js'; +import './angular.js'; + +import { MarkedContentHighlighter } from '../marked-content-highlighter.js'; declare const Prism: typeof import('prismjs'); -@Injectable() export class PrismHighlighter extends MarkedContentHighlighter { override augmentCodeBlock(code: string, lang: string): string { const classes = @@ -38,8 +37,8 @@ export class PrismHighlighter extends MarkedContentHighlighter { --------------------------------------------------------------------------------------- The \`diff\` language and plugin are not available in the provided setup. To enable it, add the following imports your \`main.ts\`: - import 'prismjs/components/prism-diff'; - import 'prismjs/plugins/diff-highlight/prism-diff-highlight'; + import 'prismjs/components/prism-diff.js'; + import 'prismjs/plugins/diff-highlight/prism-diff-highlight.js'; --------------------------------------------------------------------------------------- `); } @@ -50,7 +49,7 @@ export class PrismHighlighter extends MarkedContentHighlighter { --------------------------------------------------------------------------------------- The requested language '${lang}' is not available in the provided setup. To enable it, add the following import your \`main.ts\`: - import 'prismjs/components/prism-${lang}'; + import 'prismjs/components/prism-${lang}.js'; --------------------------------------------------------------------------------------- `); } diff --git a/packages/platform/src/lib/content/shiki/index.ts b/packages/platform/src/lib/content/shiki/index.ts new file mode 100644 index 000000000..ed64e2983 --- /dev/null +++ b/packages/platform/src/lib/content/shiki/index.ts @@ -0,0 +1,49 @@ +import { + defaultHighlighterOptions, + ShikiHighlighter, + ShikiHighlighterOptions, + ShikiHighlightOptions, +} from './shiki-highlighter.js'; +import { BundledLanguage } from 'shiki/langs'; + +export { ShikiHighlighter }; + +export interface WithShikiHighlighterOptions { + highlighter?: Partial & { + additionalLangs?: BundledLanguage[]; + }; + highlight?: ShikiHighlightOptions; + container?: string; +} + +export function getShikiHighlighter({ + highlighter = {}, + highlight = {}, + container = '%s', +}: WithShikiHighlighterOptions = {}): ShikiHighlighter { + if (!highlighter.themes) { + if (highlight.theme) { + highlighter.themes = [highlight.theme]; + } else if (highlight.themes && typeof highlight.themes === 'object') { + highlighter.themes = Object.values(highlight.themes) as string[]; + } else { + highlighter.themes = defaultHighlighterOptions.themes; + } + } + + if (!highlighter.langs) { + highlighter.langs = defaultHighlighterOptions.langs; + } + + if (highlighter.additionalLangs) { + highlighter.langs.push(...highlighter.additionalLangs); + delete highlighter.additionalLangs; + } + + return new ShikiHighlighter( + highlighter as ShikiHighlighterOptions, + highlight, + container, + !!highlighter.langs.includes('mermaid') + ); +} diff --git a/packages/content/shiki-highlighter/src/lib/shiki-highlighter.ts b/packages/platform/src/lib/content/shiki/shiki-highlighter.ts similarity index 60% rename from packages/content/shiki-highlighter/src/lib/shiki-highlighter.ts rename to packages/platform/src/lib/content/shiki/shiki-highlighter.ts index 119aa4cb0..4a83a2fbc 100644 --- a/packages/content/shiki-highlighter/src/lib/shiki-highlighter.ts +++ b/packages/platform/src/lib/content/shiki/shiki-highlighter.ts @@ -1,8 +1,3 @@ -import { - MarkedContentHighlighter, - MERMAID_IMPORT_TOKEN, -} from '@analogjs/content'; -import { inject, Injectable, InjectionToken } from '@angular/core'; import markedShiki from 'marked-shiki'; import { type BundledLanguage, @@ -14,6 +9,8 @@ import { getHighlighter, } from 'shiki'; +import { MarkedContentHighlighter } from '../marked-content-highlighter.js'; + export type ShikiHighlighterOptions = Parameters[0]; export type ShikiHighlightOptions = Partial< Omit, 'lang'> @@ -37,29 +34,20 @@ export const defaultHighlighterOptions = { themes: ['github-dark', 'github-light'], }; -export const [ - SHIKI_HIGHLIGHTER_OPTIONS, - SHIKI_HIGHLIGHT_OPTIONS, - SHIKI_CONTAINER_OPTION, -] = [ - new InjectionToken('SHIKI_HIGHLIGHTER_OPTIONS'), - new InjectionToken('SHIKI_HIGHLIGHT_OPTIONS'), - new InjectionToken('SHIKI_CONTAINER_OPTION'), -]; - -@Injectable() export class ShikiHighlighter extends MarkedContentHighlighter { - private readonly highlighterOptions = inject(SHIKI_HIGHLIGHTER_OPTIONS); - private readonly highlightOptions = inject(SHIKI_HIGHLIGHT_OPTIONS); - private readonly highlighterContainer = inject(SHIKI_CONTAINER_OPTION); - private readonly hasLoadMermaid = inject(MERMAID_IMPORT_TOKEN, { - optional: true, - }); private readonly highlighter = getHighlighter(this.highlighterOptions); - override getHighlightExtension() { + constructor( + private highlighterOptions: ShikiHighlighterOptions, + private highlightOptions: ShikiHighlightOptions, + private container: string, + private hasLoadMermaid = false + ) { + super(); + } + getHighlightExtension() { return markedShiki({ - container: this.highlighterContainer, + container: this.container, highlight: async (code, lang, props) => { if (this.hasLoadMermaid && lang === 'mermaid') { return `
${code}
`; @@ -73,7 +61,7 @@ export class ShikiHighlighter extends MarkedContentHighlighter { lang, // required by `transformerMeta*` meta: { __raw: props.join(' ') }, - themes: { dark: 'github-dark', light: 'github-light' }, + theme: 'github-dark', }, this.highlightOptions ) diff --git a/packages/platform/src/lib/options.ts b/packages/platform/src/lib/options.ts index 4be1cca99..5c5e069cb 100644 --- a/packages/platform/src/lib/options.ts +++ b/packages/platform/src/lib/options.ts @@ -2,6 +2,8 @@ import type { PluginOptions } from '@analogjs/vite-plugin-angular'; import type { NitroConfig, PrerenderRoute } from 'nitropack'; import type { SitemapConfig } from '@analogjs/vite-plugin-nitro'; +import type { WithShikiHighlighterOptions } from './content/shiki/index.js'; + export interface PrerenderOptions { /** * Add additional routes to prerender through crawling page links. @@ -34,6 +36,10 @@ export interface Options { jit?: boolean; index?: string; workspaceRoot?: string; + content?: { + highlighter: 'shiki' | 'prism'; + shikiOptions?: WithShikiHighlighterOptions; + }; } export interface PrerenderContentDir { diff --git a/packages/platform/src/lib/platform-plugin.ts b/packages/platform/src/lib/platform-plugin.ts index 89f82e9a8..f8e486f45 100644 --- a/packages/platform/src/lib/platform-plugin.ts +++ b/packages/platform/src/lib/platform-plugin.ts @@ -29,7 +29,10 @@ export function platformPlugin(opts: Options = {}): Plugin[] { ...viteNitroPlugin(platformOptions, nitroOptions), (platformOptions.ssr ? ssrBuildPlugin() : false) as Plugin, ...routerPlugin(), - ...contentPlugin(), + ...contentPlugin({ + highlighter: platformOptions?.content?.highlighter, + shikiOptions: platformOptions?.content?.shikiOptions, + }), ...angular({ jit: platformOptions.jit, workspaceRoot: platformOptions.workspaceRoot, diff --git a/packages/platform/tsconfig.lib.json b/packages/platform/tsconfig.lib.json index d51801992..386b61c5e 100644 --- a/packages/platform/tsconfig.lib.json +++ b/packages/platform/tsconfig.lib.json @@ -7,6 +7,7 @@ "sourceMap": false, "declaration": true, "types": [], + "allowJs": true, "paths": { "@analogjs/vite-plugin-angular": [ "./node_modules/@analogjs/vite-plugin-angular" diff --git a/packages/router/src/lib/routes.ts b/packages/router/src/lib/routes.ts index 09eeb5125..3c60bd85c 100644 --- a/packages/router/src/lib/routes.ts +++ b/packages/router/src/lib/routes.ts @@ -17,7 +17,7 @@ const FILES = import.meta.glob([ const CONTENT_FILES = import.meta.glob( ['/src/app/routes/**/*.md', '/src/app/pages/**/*.md'], - { query: '?raw', import: 'default' } + { query: '?analog-content-file=true', import: 'default' } ); export type Files = Record Promise>; diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index 4105e84d6..28362e6d7 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -285,7 +285,7 @@ export function angular(options?: PluginOptions): Plugin[] { /** * Skip transforming content files */ - if (id.includes('analog-content-list=true')) { + if (id.includes('analog-content-')) { return; }