Skip to content
Merged
31 changes: 28 additions & 3 deletions apps/docs-app/docs/features/routing/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,34 @@ export default defineConfig({
shikiOptions: {
highlight: {
// alternate theme
theme: 'ayu-dark'
}
theme: 'ayu-dark',
},
highlighter: {
// add more languages for Shiki itself
additionalLangs: ['diff'],
},
},
},
}),
],
});
```

For Mermaid-heavy content, keep the existing `loadMermaid` runtime path and skip Mermaid grammar loading in Shiki to avoid unnecessary server-side highlighting work in constrained CI environments:

```ts
import { defineConfig } from 'vite';
import analog from '@analogjs/platform';

export default defineConfig({
plugins: [
analog({
content: {
highlighter: 'shiki',
shikiOptions: {
highlighter: {
// add more languages
additionalLangs: ['mermaid'],
skipLangs: ['mermaid'],
},
},
},
Expand All @@ -205,6 +228,8 @@ export default defineConfig({
});
```

With `skipLangs: ['mermaid']`, Analog keeps Mermaid blocks on the existing `<pre class="mermaid">` path for `loadMermaid`, while Shiki skips loading and tokenizing the Mermaid grammar.

By default, `shikiOptions` has the following options.

```ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { markedHighlight } from 'marked-highlight';

declare const Prism: typeof import('prismjs');

type HighlightExtension = ReturnType<
MarkedContentHighlighter['getHighlightExtension']
>;

@Injectable()
export class PrismHighlighter extends MarkedContentHighlighter {
override augmentCodeBlock(code: string, lang: string): string {
Expand All @@ -14,7 +18,7 @@ export class PrismHighlighter extends MarkedContentHighlighter {
return `<pre class="${classes}"><code class="${classes}">${code}</code></pre>`;
}

override getHighlightExtension() {
override getHighlightExtension(): HighlightExtension {
return markedHighlight({
async: true,
highlight: (code: string, lang: string) => {
Expand Down Expand Up @@ -51,6 +55,6 @@ export class PrismHighlighter extends MarkedContentHighlighter {
lang,
);
},
});
}) as HighlightExtension;
}
}
4 changes: 2 additions & 2 deletions packages/content/resources/src/content-file-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ContentRenderer,
parseRawContentFile,
injectContentFileLoader,
CONTENT_LOCALE,
injectContentLocale,
} from '@analogjs/content';
import { ActivatedRoute } from '@angular/router';

Expand Down Expand Up @@ -116,7 +116,7 @@ export function contentFileResource<
>(params?: ContentFileParams, fallback = 'No Content Found') {
const loaderPromise = injectContentFileLoader();
const contentRenderer = inject(ContentRenderer);
const locale = inject(CONTENT_LOCALE, { optional: true });
const locale = injectContentLocale();
const contentFilesMap = toSignal(from(loaderPromise()));
const input =
params ||
Expand Down
47 changes: 5 additions & 42 deletions packages/content/resources/src/content-files-resource.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { inject, resource } from '@angular/core';
import { resource } from '@angular/core';
import {
injectContentListLoader,
InjectContentFilesFilterFunction,
ContentFile,
CONTENT_LOCALE,
filterByLocale,
injectContentLocale,
} from '@analogjs/content';

export function contentFilesResource<Attributes extends Record<string, any>>(
filterFn?: InjectContentFilesFilterFunction<Attributes> | undefined,
) {
const contentListLoader = injectContentListLoader<Attributes>();
const locale = inject(CONTENT_LOCALE, { optional: true });
const locale = injectContentLocale();
const contentList = contentListLoader().then((items) => {
let results = locale ? filterContentByLocale(items, locale) : items;
let results = locale ? filterByLocale(items, locale) : items;
if (filterFn) {
results = results.filter(filterFn);
}
Expand All @@ -23,40 +23,3 @@ export function contentFilesResource<Attributes extends Record<string, any>>(
loader: () => contentList,
});
}

function filterContentByLocale<T extends Record<string, any>>(
files: ContentFile<T>[],
locale: string,
): ContentFile<T>[] {
const localePrefix = `/content/${locale}/`;

const allLocalePrefixes = new Set<string>();
for (const file of files) {
const match = file.filename.match(/\/content\/([a-z]{2}(?:-[a-zA-Z]+)?)\//);
if (match) {
allLocalePrefixes.add(`/content/${match[1]}/`);
}
}

const localizedBasePaths = new Set<string>();
for (const file of files) {
if (file.filename.includes(localePrefix)) {
localizedBasePaths.add(file.filename.replace(localePrefix, '/content/'));
}
}

return files.filter((file) => {
if (file.attributes['locale']) {
return file.attributes['locale'] === locale;
}
if (file.filename.includes(localePrefix)) {
return true;
}
for (const prefix of allLocalePrefixes) {
if (prefix !== localePrefix && file.filename.includes(prefix)) {
return false;
}
}
return !localizedBasePaths.has(file.filename);
});
}
116 changes: 116 additions & 0 deletions packages/platform/src/lib/content/shiki/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const createHighlighterMock = vi.fn(async () => ({
codeToHtml: vi.fn((_code: string, options: { lang: string }) => {
return `<pre class="shiki" data-lang="${options.lang}"></pre>`;
}),
}));

vi.mock('shiki', () => ({
createHighlighter: createHighlighterMock,
}));

vi.mock('marked-shiki', () => ({
default: (options: unknown) => options,
}));

let getShikiHighlighter: typeof import('./index.js').getShikiHighlighter;

beforeEach(async () => {
vi.resetModules();
createHighlighterMock.mockClear();
({ getShikiHighlighter } = await import('./index.js'));
});

describe('getShikiHighlighter', () => {
it('does not load skipped languages into shiki and preserves the mermaid render path', async () => {
const highlighter = getShikiHighlighter({
highlighter: {
additionalLangs: ['mermaid'],
skipLangs: ['mermaid'],
},
});

expect(createHighlighterMock).toHaveBeenCalledWith(
expect.objectContaining({
langs: expect.not.arrayContaining(['mermaid']),
}),
);

const extension = highlighter.getHighlightExtension() as {
highlight: (
code: string,
lang: string,
props: string[],
) => Promise<string>;
};

await expect(extension.highlight('graph TD;', 'mermaid', [])).resolves.toBe(
'<pre class="mermaid">graph TD;</pre>',
);
});

it('returns plain fenced code blocks for skipped non-mermaid languages', async () => {
const highlighter = getShikiHighlighter({
highlighter: {
additionalLangs: ['yaml'],
skipLangs: ['yaml'],
},
});

const extension = highlighter.getHighlightExtension() as {
highlight: (
code: string,
lang: string,
props: string[],
) => Promise<string>;
};

await expect(extension.highlight('name: analog', 'yaml', [])).resolves.toBe(
'<pre class="language-yaml"><code class="language-yaml">name: analog</code></pre>',
);
});

it('escapes HTML when rendering skipped languages', async () => {
const highlighter = getShikiHighlighter({
highlighter: {
additionalLangs: ['yaml'],
skipLangs: ['yaml'],
},
});

const extension = highlighter.getHighlightExtension() as {
highlight: (
code: string,
lang: string,
props: string[],
) => Promise<string>;
};

await expect(
extension.highlight(`<div class="x">Tom & 'Jerry'</div>`, 'yaml', []),
).resolves.toBe(
'<pre class="language-yaml"><code class="language-yaml">&lt;div class=&quot;x&quot;&gt;Tom &amp; &#39;Jerry&#39;&lt;/div&gt;</code></pre>',
);
});

it('still returns mermaid blocks when loadMermaid handling is enabled and the language is not skipped', async () => {
const highlighter = getShikiHighlighter({
highlighter: {
additionalLangs: ['mermaid'],
},
});

const extension = highlighter.getHighlightExtension() as {
highlight: (
code: string,
lang: string,
props: string[],
) => Promise<string>;
};

await expect(extension.highlight('graph TD;', 'mermaid', [])).resolves.toBe(
'<pre class="mermaid">graph TD;</pre>',
);
});
});
26 changes: 21 additions & 5 deletions packages/platform/src/lib/content/shiki/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export function getShikiHighlighter({
return highlighterInstance;
}

const additionalLangs = highlighter.additionalLangs ?? [];
const skipLangs = highlighter.skipLangs ?? [];
const hasMermaidSupport =
highlighter.langs?.includes('mermaid') ||
additionalLangs.includes('mermaid');

if (!highlighter.themes) {
if (highlight.theme) {
highlighter.themes = [highlight.theme];
Expand All @@ -29,19 +35,29 @@ export function getShikiHighlighter({
}

if (!highlighter.langs) {
highlighter.langs = defaultHighlighterOptions.langs;
highlighter.langs = [...defaultHighlighterOptions.langs];
}

if (additionalLangs.length > 0) {
highlighter.langs.push(...additionalLangs);
}

if (highlighter.additionalLangs) {
highlighter.langs.push(...highlighter.additionalLangs);
delete highlighter.additionalLangs;
if (skipLangs.length > 0) {
const skipSet = new Set<string>(skipLangs);
highlighter.langs = highlighter.langs.filter(
(lang: unknown) => typeof lang !== 'string' || !skipSet.has(lang),
);
}

delete highlighter.additionalLangs;
delete highlighter.skipLangs;

highlighterInstance = new ShikiHighlighter(
highlighter as ShikiHighlighterOptions,
highlight,
container,
!!highlighter.langs.includes('mermaid'),
hasMermaidSupport,
skipLangs,
);

return highlighterInstance;
Expand Down
1 change: 1 addition & 0 deletions packages/platform/src/lib/content/shiki/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BundledLanguage } from 'shiki/langs';
export interface WithShikiHighlighterOptions {
highlighter?: Partial<ShikiHighlighterOptions> & {
additionalLangs?: BundledLanguage[];
skipLangs?: BundledLanguage[];
};
highlight?: ShikiHighlightOptions;
container?: string;
Expand Down
22 changes: 19 additions & 3 deletions packages/platform/src/lib/content/shiki/shiki-highlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import {
type CodeOptionsMultipleThemes,
type CodeOptionsSingleTheme,
type CodeToHastOptionsCommon,
getHighlighter,
createHighlighter,
} from 'shiki';

import { MarkedContentHighlighter } from '../marked/marked-content-highlighter.js';

export type ShikiHighlighterOptions = Parameters<typeof getHighlighter>[0];
export type ShikiHighlighterOptions = Parameters<typeof createHighlighter>[0];
export type ShikiHighlightOptions = Partial<
Omit<CodeToHastOptionsCommon<BundledLanguage>, 'lang'>
> &
Expand All @@ -34,14 +34,24 @@ export const defaultHighlighterOptions = {
themes: ['github-dark', 'github-light'],
};

function escapeHtml(code: string): string {
return code
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

export class ShikiHighlighter extends MarkedContentHighlighter {
private readonly highlighter = getHighlighter(this.highlighterOptions);
private readonly highlighter = createHighlighter(this.highlighterOptions);

constructor(
private highlighterOptions: ShikiHighlighterOptions,
private highlightOptions: ShikiHighlightOptions,
private container: string,
private hasLoadMermaid = false,
private skipLangs: string[] = [],
) {
super();
}
Expand All @@ -53,6 +63,12 @@ export class ShikiHighlighter extends MarkedContentHighlighter {
return `<pre class="mermaid">${code}</pre>`;
}

if (this.skipLangs.includes(lang as string)) {
const escapedCode = escapeHtml(code);

return `<pre class="language-${lang}"><code class="language-${lang}">${escapedCode}</code></pre>`;
}

const { codeToHtml } = await this.highlighter;
return codeToHtml(
code,
Expand Down
Loading
Loading