docs(docs-infra): share markdown rendering and highlighting code between api-gen and guides (#63357)

This reduces code duplication and improves the maintability.

PR Close #63357
This commit is contained in:
Matthieu Riegler
2025-08-25 03:59:16 +02:00
committed by Andrew Kushnir
parent 5a111064b8
commit 47dc4ffd1a
152 changed files with 654 additions and 1015 deletions
@@ -27,14 +27,12 @@ ts_project(
),
deps = [
":entities",
"//adev:node_modules/@types/node",
"//adev:node_modules/html-entities",
"//adev:node_modules/marked",
"//adev:node_modules/preact",
"//adev:node_modules/preact-render-to-string",
"//adev:node_modules/prettier",
"//adev:node_modules/shiki",
"//adev/shared-docs/pipeline/shared",
"//adev/shared-docs/pipeline/shared:shiki",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/regions",
],
)
@@ -11,12 +11,12 @@ import path from 'path';
import {CliCommand} from './cli-entities.mjs';
import {DocEntry} from './entities.mjs';
import {isCliEntry, isHiddenEntry} from './entities/categorization.mjs';
import {configureMarkedGlobally} from './marked/configuration.mjs';
import {getRenderable} from './processing.mjs';
import {renderEntry} from './rendering.mjs';
import {initHighlighter} from './shiki/shiki.mjs';
import {setCurrentSymbol, setSymbols} from './symbol-context.mjs';
import {CliCommandRenderable, DocEntryRenderable} from './entities/renderables.mjs';
import {initHighlighter} from '../../shared/shiki.mjs';
import {setHighlighterInstance} from './shiki/shiki.mjs';
/** The JSON data file format for extracted API reference info. */
interface EntryCollection {
@@ -25,7 +25,7 @@ interface EntryCollection {
moduleLabel?: string;
normalizedModuleName: string;
entries: DocEntry[] | CliCommand[];
symbols: Map<string, string>;
symbols: Record<string, string>;
}
/** Parse all JSON data source files into an array of collections. */
@@ -34,10 +34,15 @@ function parseEntryData(srcs: string[]): EntryCollection[] {
const fileContent = readFileSync(jsonDataFilePath, {encoding: 'utf8'});
const fileContentJson = JSON.parse(fileContent) as unknown;
if ((fileContentJson as EntryCollection).entries) {
return {
...(fileContentJson as EntryCollection),
symbols: new Map((fileContentJson as any).symbols ?? []),
};
const symbols = Object.fromEntries(
// TODO: refactor that, it's dirty and we can probably do better than this.
// We're removing the leading `@angular/` from module names.
(((fileContentJson as any).symbols ?? []) as [string, string][]).map(
([symbol, moduleName]) => [symbol, moduleName.slice(9)],
),
);
return {...(fileContentJson as EntryCollection), symbols};
}
// CLI subcommands should generate a separate file for each subcommand.
@@ -50,7 +55,7 @@ function parseEntryData(srcs: string[]): EntryCollection[] {
moduleName: 'unknown',
normalizedModuleName: 'unknown',
entries: [fileContentJson as DocEntry],
symbols: new Map(),
symbols: {},
},
...command.subcommands!.map((subCommand) => {
return {
@@ -58,7 +63,7 @@ function parseEntryData(srcs: string[]): EntryCollection[] {
moduleName: 'unknown',
normalizedModuleName: 'unknown',
entries: [{...subCommand, parentCommand: command} as any],
symbols: new Map(),
symbols: {},
};
}),
];
@@ -69,7 +74,7 @@ function parseEntryData(srcs: string[]): EntryCollection[] {
moduleName: 'unknown',
normalizedModuleName: 'unknown',
entries: [fileContentJson as DocEntry], // TODO: fix the typing cli entries aren't DocEntry
symbols: new Map(),
symbols: {},
};
});
}
@@ -90,10 +95,8 @@ function getNormalizedFilename(normalizedModuleName: string, entry: DocEntry | C
}
async function main() {
configureMarkedGlobally();
// Shiki highlighter needs to be setup in an async context
await initHighlighter();
setHighlighterInstance(await initHighlighter());
const [paramFilePath] = process.argv.slice(2);
const rawParamLines = readFileSync(paramFilePath, {encoding: 'utf8'}).split('\n');
@@ -1,17 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {marked} from 'marked';
import {renderer} from './renderer.mjs';
/** Globally configures marked for rendering JsDoc content to HTML. */
export function configureMarkedGlobally() {
marked.use({
renderer,
});
}
@@ -1,92 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Renderer, Tokens} from 'marked';
import {codeToHtml} from '../shiki/shiki.mjs';
import {SECTION_HEADING, SECTION_SUB_HEADING} from '../styling/css-classes.mjs';
/**
* Custom renderer for marked that will be used to transform markdown files to HTML
* files that can be used in the Angular docs.
*/
export const renderer: Partial<Renderer> = {
code({lang, text}): string {
const highlightResult = codeToHtml(text, lang)
// remove spaces/line-breaks between elements to not mess-up `pre` style
.replace(/>\s+</g, '><');
return `
<div class="docs-code" role="group">
${highlightResult}
</div>
`;
},
image({href, title, text}): string {
return `
<img src="${href}" alt="${text}" title="${title}" class="docs-image">
`;
},
link(this: Renderer, {href, tokens}): string {
return `<a href="${href}">${this.parser.parseInline(tokens)}</a>`;
},
list(this: Renderer, {items, ordered, start}) {
if (ordered) {
return `
<ol class="docs-ordered-list">
${items.map((item) => this.listitem(item)).join('')}
</ol>
`;
}
return `
<ul class="docs-list">
${items.map((item) => this.listitem(item)).join('')}
</ul>
`;
},
table(this: Renderer, {header, rows}: Tokens.Table) {
return `
<div class="docs-table docs-scroll-track-transparent">
<table>
<thead>
${this.tablerow({text: header.map((cell) => this.tablecell(cell)).join('')})}
</thead>
<tbody>
${rows
.map((row) => this.tablerow({text: row.map((cell) => this.tablecell(cell)).join('')}))
.join('')}
</tbody>
</table>
</div>
`;
},
heading(this: Renderer, {text, depth, tokens}: Tokens.Heading) {
const id = text
.toLowerCase()
.replaceAll(' ', '-')
.replace(/[^a-z0-9-]/g, '');
// Since we have a code transformer `addApiLinksToHtml` which adds anchors
// to code blocks of known symbols, we add an additional `data-skip-anchor`
// attribute that prevents the transformation. This is needed since nested
// anchor tags are illegal and break the HTML.
const textRenderer = new Renderer();
textRenderer.codespan = ({text}) => `<code data-skip-anchor>${text}</code>`;
const parsedText = this.parser.parseInline(tokens, textRenderer);
// The template matches templates/section-heading.tsx
return `
<h${depth} id="${id}" class="${SECTION_HEADING} ${SECTION_SUB_HEADING}">
<a href="#${id}" aria-label="Link to ${text} section" tabIndex="-1">
${parsedText}
</a>
</h${depth}>
`;
},
};
@@ -1,196 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import * as blockC from './region-matchers/block-c.mjs';
import * as html from './region-matchers/html.mjs';
import * as inlineC from './region-matchers/inline-c.mjs';
import * as inlineCOnly from './region-matchers/inline-c-only.mjs';
import * as inlineHash from './region-matchers/inline-hash.mjs';
import {FileType, removeEslintComments} from './remove-eslint-comments.mjs';
const WHOLE_FILE_REGION_NAME = '';
const DEFAULT_PLASTER = '. . .';
const REGION_MATCHERS = {
ts: inlineC,
js: inlineC,
mjs: inlineCOnly,
es6: inlineC,
html: html,
svg: html,
css: blockC,
conf: inlineHash,
yaml: inlineHash,
yml: inlineHash,
sh: inlineHash,
jade: inlineCOnly,
pug: inlineCOnly,
json: inlineCOnly,
'json.annotated': inlineCOnly,
};
interface Region {
lines: string[];
open?: boolean;
ranges: {from: number; to?: number}[];
}
/**
* NOTE: We assume that the tag defining the beginning and end of the region will be in different lines in each case.
* For example, in HTML, we don't expect to have the following code on one line: <!-- docregion name -->content<!--enddocregion name-->
*/
export const regionParser = (contents: string, filePath: string) => {
const fileType: FileType | undefined = filePath?.split('.').pop() as FileType;
if (!fileType) {
throw new Error(`Incorrect file type for region parser: ${filePath}!`);
}
const regionMatcher = REGION_MATCHERS[fileType];
const openRegions: string[] = [];
const regionMap: Record<string, Region> = {};
let countOfRegionLines = 0;
if (regionMatcher) {
let plaster = regionMatcher.createPlasterComment(DEFAULT_PLASTER);
const lines = removeEslintComments(contents, fileType)
.split(/\r?\n/)
.filter((line, index) => {
const startRegion = line.match(regionMatcher.regionStartMatcher);
const endRegion = line.match(regionMatcher.regionEndMatcher);
const updatePlaster = line.match(regionMatcher.plasterMatcher);
// start region processing
if (startRegion) {
// open up the specified region
handleStartRegion(
startRegion,
regionMap,
index,
countOfRegionLines,
plaster,
openRegions,
);
} else if (endRegion) {
// end region processing
handleEndRegion(openRegions, endRegion, regionMap, index, countOfRegionLines);
} else if (updatePlaster) {
// doc plaster processing
const plasterString = updatePlaster[1].trim();
plaster = plasterString ? regionMatcher.createPlasterComment(plasterString) : '';
} else {
// simple line of content processing
openRegions.forEach((regionName) => regionMap[regionName].lines.push(line));
// do not filter out this line from the content
return true;
}
// this line contained an annotation so let's filter it out
countOfRegionLines++;
return false;
});
if (!regionMap[WHOLE_FILE_REGION_NAME]) {
regionMap[WHOLE_FILE_REGION_NAME] = {lines, ranges: [{from: 1, to: lines.length + 1}]};
}
return {
contents: lines.join('\n'),
regionMap,
totalLinesCount: lines.length,
};
} else {
return {contents, regionMap, totalLinesCount: 0};
}
};
function handleStartRegion(
startRegion: RegExpMatchArray,
regionMap: Record<string, Region>,
index: number,
countOfRegionLines: number,
plaster: string,
openRegions: string[],
) {
const regionNames = getRegionNames(startRegion[1]);
if (regionNames.length === 0) {
regionNames.push(WHOLE_FILE_REGION_NAME);
}
for (const regionName of regionNames) {
const region = regionMap[regionName];
if (region) {
if (region.open) {
throw new Error(`Tried to open a region, named "${regionName}", that is already open`);
}
// Region is opened, set from range value.
region.open = true;
region.ranges.push({from: getFromRangeValue(index, countOfRegionLines)});
if (plaster) {
// Use the same indent as the docregion marker
const indent = startRegion[0].split(/[^ ]/, 1);
region.lines.push(indent + plaster);
}
} else {
regionMap[regionName] = {
lines: [],
open: true,
ranges: [
{
from: getFromRangeValue(index, countOfRegionLines),
},
],
};
}
openRegions.push(regionName);
}
}
function handleEndRegion(
openRegions: string[],
endRegion: RegExpMatchArray,
regionMap: Record<string, Region>,
index: number,
countOfRegionLines: number,
) {
if (openRegions.length === 0) {
throw new Error('Tried to close a region when none are open');
}
// close down the specified region (or most recent if no name is given)
const regionNames = getRegionNames(endRegion[1]);
if (regionNames.length === 0) {
regionNames.push(openRegions[openRegions.length - 1]);
}
for (const regionName of regionNames) {
const region = regionMap[regionName];
if (!region || !region.open) {
throw new Error(`Tried to close a region, named "${regionName}", that is not open`);
}
// Region is closed, we can define the last line number of the region
region.open = false;
region.ranges[region.ranges.length - 1].to = index - countOfRegionLines;
removeLast(openRegions, regionName);
}
}
function getFromRangeValue(index: number, countOfRegionLines: number): number {
return index - countOfRegionLines + 1;
}
function getRegionNames(input: string): string[] {
return input.trim() === '' ? [] : input.split(',').map((name) => name.trim());
}
function removeLast(array: string[], item: string): void {
const index = array.lastIndexOf(item);
array.splice(index, 1);
}
@@ -6,41 +6,24 @@
* found in the LICENSE file at https://angular.dev/license
*/
let highlighter: any;
import {HighlighterGeneric} from 'shiki';
export async function initHighlighter() {
const {createHighlighter} = await import('shiki');
highlighter = await createHighlighter({
themes: ['github-light', 'github-dark'],
langs: [
'javascript',
'typescript',
'angular-html',
'angular-ts',
'shell',
'html',
'http',
'json',
'jsonc',
'nginx',
'markdown',
'apache',
],
});
/**
* Singleton instance of the Shiki highlighter used during API page generation.
*
* TODO: Consider removing this singleton pattern if we find a better alternative.
*/
let highlighter: HighlighterGeneric<any, any>;
export function setHighlighterInstance(instance: HighlighterGeneric<any, any>) {
highlighter = instance;
}
export function codeToHtml(code: string, language: string | undefined): string {
const html = highlighter.codeToHtml(code, {
lang: language ?? 'text',
themes: {
light: 'github-light',
dark: 'github-dark',
},
cssVariablePrefix: '--shiki-',
defaultColor: false,
});
return html;
export function getHighlighterInstance(): HighlighterGeneric<any, any> {
if (!highlighter) {
throw new Error(
'Shiki highlighter has not been initialized. Make sure to call `setHighlighterInstance`.',
);
}
return highlighter;
}
/**
@@ -6,30 +6,17 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {getSymbolUrl as sharedGetSymbolUrl} from '../../shared/linking.mjs';
/**
* API pages are generated each package at a time.
* This allows to use a global context to store the symbols and their corresponding module names.
* This allows us to use a global context to store the symbols and their corresponding module names.
*/
let symbols = new Map<string, string>();
let symbols: Record<string, string> = {};
// This is used to store the currently processed symbol (usually a class or an interface)
let currentSymbol: string | undefined;
export function setSymbols(newSymbols: Map<string, string>): void {
symbols = newSymbols;
}
/**
* Returns the module name of a symbol.
* eg: 'ApplicationRef' => 'core', 'FormControl' => 'forms'
* Also supports class.member, 'NgZone.runOutsideAngular => 'core'
*/
export function getModuleName(symbol: string): string | undefined {
const moduleName = symbols.get(symbol);
return moduleName?.replace('@angular/', '');
}
export function setCurrentSymbol(symbol: string): void {
currentSymbol = symbol;
}
@@ -38,6 +25,14 @@ export function getCurrentSymbol(): string | undefined {
return currentSymbol;
}
export function setSymbols(newSymbols: Record<string, string>): void {
symbols = newSymbols;
}
export function getSymbolUrl(symbol: string): string | undefined {
return sharedGetSymbolUrl(symbol, symbols);
}
export function unknownSymbolMessage(link: string, symbol: string): string {
return `WARNING: {@link ${link}} is invalid, ${symbol} or ${currentSymbol}.${symbol} is unknown in this context`;
}
@@ -20,9 +20,10 @@ import {SectionDescription} from './section-description';
import {SectionUsageNotes} from './section-usage-notes';
import {SectionApi} from './section-api';
import {SectionHeading} from './section-heading';
import {codeToHtml} from '../shiki/shiki.mjs';
import {RawHtml} from './raw-html';
import {DeprecationWarning} from './deprecation-warning';
import {codeToHtml} from '../../../shared/shiki.mjs';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
/** Component to render a class API reference document. */
export function ClassReference(entry: ClassEntryRenderable | PipeEntryRenderable) {
@@ -33,7 +34,13 @@ export function ClassReference(entry: ClassEntryRenderable | PipeEntryRenderable
<>
<div className={SECTION_CONTAINER + ' docs-reference-api-section'}>
<SectionHeading name="Pipe usage" />
<RawHtml value={codeToHtml((entry as PipeEntry).usage, 'angular-html')} />
<RawHtml
value={codeToHtml(
getHighlighterInstance(),
(entry as PipeEntry).usage,
'angular-html',
)}
/>
</div>
</>
) : (
@@ -7,9 +7,7 @@
*/
import {h} from 'preact';
import {getModuleName} from '../symbol-context.mjs';
import {getLinkToModule} from '../transforms/url-transforms.mjs';
import {shouldLinkSymbol} from '../../../shared/link-exemption.mjs';
import {getSymbolUrl} from '../symbol-context.mjs';
const symbolRegex = /([a-zA-Z_$][a-zA-Z_$0-9\.]*)/;
@@ -24,11 +22,8 @@ export function CodeSymbol(props: {code: string}) {
// Every even index is a non-match when the regex has 1 capturing group
if (index % 2 === 0) return rawSymbol;
let [symbol, subSymbol] = rawSymbol.split('.'); // Also takes care of methods, enum value etc.
const moduleName = getModuleName(symbol);
if (shouldLinkSymbol(symbol) && moduleName) {
const url = getLinkToModule(moduleName, symbol, subSymbol);
const url = getSymbolUrl(rawSymbol);
if (url) {
return <a href={url}>{rawSymbol}</a>;
}
@@ -8,11 +8,13 @@
import {h} from 'preact';
import {RawHtml} from './raw-html';
import {codeToHtml} from '../shiki/shiki.mjs';
import {codeToHtml} from '../../../shared/shiki.mjs';
import {get} from 'http';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
/** Component to render a header of the CLI page. */
export function HighlightTypeScript(props: {code: string}) {
const result = codeToHtml(props.code, 'typescript');
const result = codeToHtml(getHighlighterInstance(), props.code, 'typescript');
const withScrollTrack = result.replace(/^(<pre class="shiki)/, '$1 docs-mini-scroll-track');
return <RawHtml value={withScrollTrack} className="docs-code" />;
@@ -9,18 +9,17 @@
import {resolve} from 'path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {configureMarkedGlobally} from '../marked/configuration.mjs';
import {getRenderable} from '../processing.mjs';
import {renderEntry} from '../rendering.mjs';
import {initHighlighter} from '../shiki/shiki.mjs';
import {initHighlighter} from '../../../shared/shiki.mjs';
import {setHighlighterInstance} from '../shiki/shiki.mjs';
describe('CLI docs to html', () => {
let fragment: DocumentFragment;
let entryJson: any;
beforeAll(async () => {
await initHighlighter();
await configureMarkedGlobally();
setHighlighterInstance(await initHighlighter());
const entryContent = await readFile(resolve('./fake-cli-entries.json'), {
encoding: 'utf-8',
@@ -1276,14 +1276,14 @@
}
],
"symbols": [
["AfterRenderPhase", "core"],
["afterRender", "core"],
["EmbeddedViewRef", "core"],
["ChangeDetectionStrategy", "core"],
["ChangeDetectorRef", "core"],
["withNoHttpTransferCache", "platform-browser"],
["withHttpTransferCacheOptions", "platform-browser"],
["withI18nSupport", "platform-browser"],
["withEventReplay", "platform-browser"]
["AfterRenderPhase", "@angular/core"],
["afterRender", "@angular/core"],
["EmbeddedViewRef", "@angular/core"],
["ChangeDetectionStrategy", "@angular/core"],
["ChangeDetectorRef", "@angular/core"],
["withNoHttpTransferCache", "@angular/platform-browser"],
["withHttpTransferCacheOptions", "@angular/platform-browser"],
["withI18nSupport", "@angular/platform-browser"],
["withEventReplay", "@angular/platform-browser"]
]
}
@@ -8,12 +8,12 @@
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {configureMarkedGlobally} from '../marked/configuration.mjs';
import {getRenderable} from '../processing.mjs';
import {renderEntry} from '../rendering.mjs';
import {initHighlighter} from '../shiki/shiki.mjs';
import {setSymbols} from '../symbol-context.mjs';
import {resolve} from 'path';
import {initHighlighter} from '../../../shared/shiki.mjs';
import {setHighlighterInstance} from '../shiki/shiki.mjs';
// Note: The tests will probably break if the schema of the api extraction changes.
// All entries in the fake-entries are extracted from Angular's api.
@@ -24,14 +24,13 @@ describe('markdown to html', () => {
const entries2 = new Map<string, string>();
beforeAll(async () => {
await initHighlighter();
await configureMarkedGlobally();
setHighlighterInstance(await initHighlighter());
const entryContent = await readFile(resolve('./fake-entries.json'), {
encoding: 'utf-8',
});
const entryJson = JSON.parse(entryContent) as any;
const symbols = new Map<string, string>([
const symbols = Object.fromEntries([
['AfterRenderPhase', 'core'],
['afterRender', 'core'],
['EmbeddedViewRef', 'core'],
@@ -8,11 +8,11 @@
import {readFile} from 'fs/promises';
import {DocEntryRenderable} from '../entities/renderables.mjs';
import {configureMarkedGlobally} from '../marked/configuration.mjs';
import {getRenderable} from '../processing.mjs';
import {initHighlighter} from '../shiki/shiki.mjs';
import {setSymbols} from '../symbol-context.mjs';
import {resolve} from 'path';
import {initHighlighter} from '../../../shared/shiki.mjs';
import {setHighlighterInstance} from '../shiki/shiki.mjs';
// Note: The tests will probably break if the schema of the api extraction changes.
// All entries in the fake-entries are extracted from Angular's api.
@@ -22,14 +22,13 @@ describe('renderable', () => {
const entries = new Map<string, DocEntryRenderable>();
beforeAll(async () => {
await initHighlighter();
await configureMarkedGlobally();
setHighlighterInstance(await initHighlighter());
const entryContent = await readFile(resolve('./fake-entries.json'), {
encoding: 'utf-8',
});
const entryJson = JSON.parse(entryContent) as any;
const symbols = new Map<string, string>([
const symbols = Object.fromEntries([
['AfterRenderPhase', 'core'],
['afterRender', 'core'],
['EmbeddedViewRef', 'core'],
@@ -14,7 +14,7 @@ describe('jsdoc transforms', () => {
it('should transform links', () => {
setCurrentSymbol('Router');
setSymbols(
new Map([
Object.fromEntries([
['Route', 'test'],
['Router', 'test'],
['Router.someMethod', 'test'],
@@ -34,17 +34,16 @@ import {
} from '../entities/categorization.mjs';
import {CodeLineRenderable} from '../entities/renderables.mjs';
import {HasModuleName, HasRenderableToc} from '../entities/traits.mjs';
import {getModuleName} from '../symbol-context.mjs';
import {getSymbolUrl} from '../symbol-context.mjs';
import {
codeToHtml,
getHighlighterInstance,
insertParenthesesForDecoratorInShikiHtml,
replaceKeywordFromShikiHtml,
} from '../shiki/shiki.mjs';
import {filterLifecycleMethods, mergeGettersAndSetters} from './member-transforms.mjs';
import {getLinkToModule} from './url-transforms.mjs';
import {formatJs} from './format-code.mjs';
import {shouldLinkSymbol} from '../../../shared/link-exemption.mjs';
import {codeToHtml} from '../../../shared/shiki.mjs';
const INDENT = ' ';
const SPACE = ' ';
@@ -86,7 +85,11 @@ export async function addRenderableCodeToc<T extends DocEntry & HasModuleName>(
formattedCode = await formatJs(metadata.contents);
}
let codeWithSyntaxHighlighting = codeToHtml(formattedCode ?? metadata?.contents, 'typescript');
let codeWithSyntaxHighlighting = codeToHtml(
getHighlighterInstance(),
formattedCode ?? metadata?.contents,
'typescript',
);
if (isDecoratorEntry(entry)) {
// Shiki requires a keyword for correct formating of Decorators
@@ -526,15 +529,9 @@ export function addApiLinksToHtml(htmlString: string): string {
// The captured content ==> vvvvvvvv
/(?<!<a[^>]*>)(<(?:(?:span)|(?:code))(?!\sdata-skip-anchor)[^>]*>\s*)([^<]*?)(\s*<\/(?:span|code)>)/g,
(type: string, span1: string, potentialSymbolName: string, span2: string) => {
// mySymbol() => mySymbol
const symbolWithoutInvocation = potentialSymbolName.replace(/\([^)]*\);?/g, '');
const [symbol, subSymbol] = symbolWithoutInvocation.split(/(?:#|\.)/) as [string, string?];
const moduleName = getModuleName(symbol);
if (shouldLinkSymbol(symbol) && moduleName) {
return `${span1}<a href="${getLinkToModule(moduleName, symbol, subSymbol)}">${potentialSymbolName}</a>${span2}`;
const url = getSymbolUrl(potentialSymbolName);
if (url) {
return `${span1}<a href="${url}">${potentialSymbolName}</a>${span2}`;
}
return type;
@@ -25,9 +25,8 @@ import {
HasStableFlag,
} from '../entities/traits.mjs';
import {getLinkToModule} from './url-transforms.mjs';
import {addApiLinksToHtml} from './code-transforms.mjs';
import {getCurrentSymbol, getModuleName, unknownSymbolMessage} from '../symbol-context.mjs';
import {getCurrentSymbol, getSymbolUrl, unknownSymbolMessage} from '../symbol-context.mjs';
const JS_DOC_USAGE_NOTE_TAGS: Set<string> = new Set(['remarks', 'usageNotes', 'example']);
export const JS_DOC_SEE_TAG = 'see';
@@ -141,7 +140,11 @@ function getHtmlAdditionalLinks<T extends HasJsDocTags>(entry: T): LinkEntryRend
if (linkMatch) {
const link = linkMatch[1];
const {url, label} = parseAtLink(link);
const parsed = parseAtLink(link);
if (!parsed) {
return undefined;
}
const {url, label} = parsed;
return {label, url};
}
@@ -168,13 +171,17 @@ function wrapExampleHtmlElementsWithCode(text: string) {
*/
function convertLinks(text: string) {
return text.replace(jsDoclinkRegexGlobal, (_, link) => {
const {label, url} = parseAtLink(link);
const parsed = parseAtLink(link);
if (!parsed) {
return `<code>${link}</code>`; // leave the link as-is if we can't parse it
}
const {label, url} = parsed;
return `<a href="${url}"><code>${label}</code></a>`;
});
}
function parseAtLink(link: string): {label: string; url: string} {
function parseAtLink(link: string): {label: string; url: string} | undefined {
// Because of microsoft/TypeScript/issues/59679
// getTextOfJSDocComment introduces an extra space between the symbol and a trailing ()
link = link.replace(/ \(\)$/, '');
@@ -195,24 +202,18 @@ function parseAtLink(link: string): {label: string; url: string} {
};
}
let [symbol, subSymbol] = rawSymbol.replace(/\(\)$/, '').split(/(?:#|\.)/);
let moduleName = getModuleName(symbol);
let url = getSymbolUrl(rawSymbol);
const label = description ?? rawSymbol;
const currentSymbol = getCurrentSymbol();
if (!url) {
const currentSymbol = getCurrentSymbol();
// 2nd attempt, try to get the module name in the context of the current symbol
url = getSymbolUrl(`${currentSymbol}.${rawSymbol}`);
if (!moduleName) {
// 2nd attemp, try to get the module name in the context of the current symbol
moduleName = getModuleName(`${currentSymbol}.${symbol}`);
if (!moduleName || !currentSymbol) {
throw unknownSymbolMessage(link, symbol);
if (!url || !currentSymbol) {
throw unknownSymbolMessage(link, rawSymbol);
}
subSymbol = symbol;
symbol = currentSymbol;
}
return {label, url: getLinkToModule(moduleName, symbol, subSymbol)};
return {label, url};
}
@@ -7,7 +7,6 @@
*/
export const API_PREFIX = 'api';
export const MODULE_NAME_PREFIX = '@angular/';
export function getLinkToModule(moduleName: string, symbol: string, subSymbol?: string) {
return `/${API_PREFIX}/${moduleName}/${symbol}${subSymbol ? `#${subSymbol}` : ''}`;
@@ -18,7 +18,7 @@ ts_project(
"//adev:node_modules/jsdom",
"//adev:node_modules/tinyglobby",
"//adev/shared-docs/pipeline/examples/shared",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/regions",
],
)
@@ -11,10 +11,10 @@ import {readFile} from 'fs/promises';
import {copyFolder, createFolder, removeFolder} from '../shared/file-system.mjs';
import jsdom from 'jsdom';
import {glob} from 'tinyglobby';
import {regionParser} from '../../guides/extensions/docs-code/regions/region-parser.mjs';
import {appendCopyrightToFile} from '../shared/copyright.mjs';
import {FileType} from '../../guides/extensions/docs-code/sanitizers/eslint.mjs';
import {EXCLUDE_FILES_FOR_STACKBLITZ, STACKBLITZ_CONFIG_FILENAME} from './defaults.mjs';
import {regionParser} from '../../shared/regions/region-parser.mjs';
import {FileType} from '../../shared/regions/remove-eslint-comments.mjs';
interface StackblitzConfig {
title: string;
@@ -18,7 +18,7 @@ ts_project(
"//adev:node_modules/tinyglobby",
"//adev/shared-docs/interfaces",
"//adev/shared-docs/pipeline/examples/shared",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/regions",
],
)
@@ -10,13 +10,13 @@ import {join} from 'path';
import {readFile} from 'fs/promises';
import {copyFolder, createFolder} from '../shared/file-system.mjs';
import {glob} from 'tinyglobby';
import {regionParser} from '../../guides/extensions/docs-code/regions/region-parser.mjs';
import {appendCopyrightToFile} from '../shared/copyright.mjs';
import {FileType} from '../../guides/extensions/docs-code/sanitizers/eslint.mjs';
import {EXCLUDE_FILES, CONFIG_FILENAME} from './defaults.mjs';
import {zip, strToU8} from 'fflate';
import {FileAndContent} from '../../../interfaces';
import {FileType} from '../../shared/regions/remove-eslint-comments.mjs';
import {regionParser} from '../../shared/regions/region-parser.mjs';
interface ZipConfig {
ignore: string[];
+2 -12
View File
@@ -53,7 +53,6 @@ ts_project(
srcs = glob(
[
"**/*.mts",
"shiki.d.ts",
],
exclude = ["index.mts"],
),
@@ -61,17 +60,8 @@ ts_project(
"//adev/shared-docs:__subpackages__",
],
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/@types/node",
"//adev:node_modules/diff",
"//adev:node_modules/emoji-regex",
"//adev:node_modules/html-entities",
"//adev:node_modules/jsdom",
"//adev:node_modules/marked",
"//adev:node_modules/mermaid",
"//adev:node_modules/playwright-core",
"//adev:node_modules/shiki",
"//adev/shared-docs/pipeline/shared",
"//adev/shared-docs/pipeline/shared:shiki",
"//adev/shared-docs/pipeline/shared/marked",
],
)
@@ -1,13 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// These kind of comments are used CSS and other languages that do not support inline comments
export const regionStartMatcher = /^\s*\/\*\s*#docregion\s*(.*)\s*\*\/\s*$/;
export const regionEndMatcher = /^\s*\/\*\s*#enddocregion\s*(.*)\s*\*\/\s*$/;
export const plasterMatcher = /^\s*\/\*\s*#docplaster\s*(.*)\s*\*\/\s*$/;
export const createPlasterComment = (plaster: string) => `/* ${plaster} */`;
@@ -1,14 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// These kind of comments are used in HTML
export const regionStartMatcher = /^\s*<!--\s*#docregion\s*(.*?)\s*(?:-->)?\s*$/;
export const regionEndMatcher = /^\s*<!--\s*#enddocregion\s*(.*?)\s*-->\s*$/;
export const plasterMatcher = /^\s*<!--\s*#docplaster\s*(.*?)\s*-->\s*$/;
export const createPlasterComment = (plaster: string) => `<!-- ${plaster} -->`;
@@ -1,13 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// These kind of comments are used in languages that do not support block comments, such as Jade
export const regionStartMatcher = /^\s*\/\/\s*#docregion\s*(.*)\s*$/;
export const regionEndMatcher = /^\s*\/\/\s*#enddocregion\s*(.*)\s*$/;
export const plasterMatcher = /^\s*\/\/\s*#docplaster\s*(.*)\s*$/;
export const createPlasterComment = (plaster: string) => `// ${plaster}`;
@@ -1,13 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// This comment type is used in C like languages such as JS, TS, etc
export const regionStartMatcher = /^\s*\/\/\s*#docregion\s*(.*)\s*$/;
export const regionEndMatcher = /^\s*\/\/\s*#enddocregion\s*(.*)\s*$/;
export const plasterMatcher = /^\s*\/\/\s*#docplaster\s*(.*)\s*$/;
export const createPlasterComment = (plaster: string) => `/* ${plaster} */`;
@@ -1,13 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// These type of comments are used in hash comment based languages such as bash and Yaml
export const regionStartMatcher = /^\s*#\s*#docregion\s*(.*)\s*$/;
export const regionEndMatcher = /^\s*#\s*#enddocregion\s*(.*)\s*$/;
export const plasterMatcher = /^\s*#\s*#docplaster\s*(.*)\s*$/;
export const createPlasterComment = (plaster: string) => `# ${plaster}`;
@@ -1,61 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export type FileType =
| 'ts'
| 'js'
| 'mjs'
| 'es6'
| 'html'
| 'svg'
| 'css'
| 'conf'
| 'yaml'
| 'yml'
| 'sh'
| 'jade'
| 'pug'
| 'json'
| 'json.annotated';
/**
* Remove any ESLint comments from the provided code string. This is particularly useful as we may
* want to store code formatted for an example that would not be allowed in our repo. For instance,
* we can disable ESLint to allow for a `console` usage, despite not allowing it typically, so that
* it can be used in an example in documenation.
*/
export function removeEslintComments(input: string, fileType: FileType) {
if (!input || (fileType !== 'ts' && fileType !== 'js' && fileType !== 'html')) {
return input;
}
return input.replace(regexesForFileTypes[fileType], '');
}
const jsRegexes = [
/\/\/ *eslint-disable(?:-next-line)?(?: .*)?(?:\n *|$)/,
/\n? *\/\/ *eslint-(?:disable-line|enable)(?: .*)?(?=\n|$)/,
/\/\*\s*eslint-disable(?:-next-line)?(?: [\s\S]*?)?\*\/ *(?:\n *)?/,
/\n? *\/\*\s*eslint-(?:disable-line|enable)(?: [\s\S]*?)?\*\//,
];
const htmlRegexes = [
/<!--\s*eslint-disable(?:-next-line)?(?: [\s\S]*?)?--> *(?:\n *)?/,
/\n? *<!--\s*eslint-(?:disable-line|enable)(?: [\s\S]*?)?-->/,
];
const joinRegexes = (regexes: any) =>
new RegExp(regexes.map((regex: any) => `(?:${regex.source})`).join('|'), 'g');
const htmlRegex = joinRegexes(htmlRegexes);
// Note: the js regex needs to also include the html ones to account for inline templates in @Components
const jsRegex = joinRegexes([...jsRegexes, ...htmlRegexes]);
const regexesForFileTypes = {
js: jsRegex,
ts: jsRegex,
html: htmlRegex,
};
+5 -4
View File
@@ -8,8 +8,8 @@
import {readFileSync, writeFileSync} from 'fs';
import path from 'path';
import {parseMarkdown} from './parse.mjs';
import {initHighlighter} from './extensions/docs-code/format/highlight.mjs';
import {initHighlighter} from '../shared/shiki.mjs';
import {parseMarkdownAsync} from '../shared/marked/parse.mjs';
type ApiManifest = ApiManifestPackage[];
interface ApiManifestPackage {
@@ -24,7 +24,7 @@ async function main() {
// The highlighter needs to be setup asynchronously
// so we're doing it at the start of the pipeline
await initHighlighter();
const highlighter = await initHighlighter();
for (const filePath of srcs.split(',')) {
if (!filePath.endsWith('.md')) {
@@ -42,9 +42,10 @@ async function main() {
}
const markdownContent = readFileSync(filePath, {encoding: 'utf8'});
const htmlOutputContent = await parseMarkdown(markdownContent, {
const htmlOutputContent = await parseMarkdownAsync(markdownContent, {
markdownFilePath: filePath,
apiEntries: mapManifestToEntries(apiManifest),
highlighter,
});
// The expected file name structure is the [name of the file].md.html.
@@ -1,58 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {marked} from 'marked';
import {hooks} from './hooks.mjs';
import {Renderer} from './renderer.mjs';
import {docsAlertExtension} from './extensions/docs-alert.mjs';
import {docsCalloutExtension} from './extensions/docs-callout.mjs';
import {docsPillExtension} from './extensions/docs-pill/docs-pill.mjs';
import {docsPillRowExtension} from './extensions/docs-pill/docs-pill-row.mjs';
import {docsVideoExtension} from './extensions/docs-video.mjs';
import {docsWorkflowExtension} from './extensions/docs-workflow/docs-workflow.mjs';
import {docsStepExtension} from './extensions/docs-workflow/docs-step.mjs';
import {docsCardExtension} from './extensions/docs-card/docs-card.mjs';
import {docsCardContainerExtension} from './extensions/docs-card/docs-card-container.mjs';
import {docsDecorativeHeaderExtension} from './extensions/docs-decorative-header.mjs';
import {docsCodeBlockExtension} from './extensions/docs-code/docs-code-block.mjs';
import {docsCodeExtension} from './extensions/docs-code/docs-code.mjs';
import {docsCodeMultifileExtension} from './extensions/docs-code/docs-code-multifile.mjs';
import {ParserContext, setContext} from './utils.mjs';
import {walkTokens} from './walk-tokens.mjs';
export async function parseMarkdown(
markdownContent: string,
context: ParserContext,
): Promise<string> {
setContext(context);
marked.use({
hooks,
extensions: [
docsAlertExtension,
docsCalloutExtension,
docsPillExtension,
docsPillRowExtension,
docsVideoExtension,
docsWorkflowExtension,
docsStepExtension,
docsCardExtension,
docsCardContainerExtension,
docsDecorativeHeaderExtension,
docsCodeBlockExtension,
docsCodeExtension,
docsCodeMultifileExtension,
],
walkTokens,
// The async option causes marked to await walkTokens functions before parsing the tokens and returning an HTML string.
// We leverage this to allow us to use async libraries like mermaid and building stackblitz examples.
async: true,
});
return marked.parse(markdownContent, {renderer: new Renderer()});
}
@@ -1,32 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Renderer as _Renderer} from 'marked';
import {linkRender} from './tranformations/link.mjs';
import {tableRender} from './tranformations/table.mjs';
import {listRender} from './tranformations/list.mjs';
import {imageRender} from './tranformations/image.mjs';
import {textRender} from './tranformations/text.mjs';
import {headingRender} from './tranformations/heading.mjs';
import {codespanRender} from './tranformations/code.mjs';
/**
* Custom renderer for marked that will be used to transform markdown files to HTML
* files that can be used in the Angular docs.
*/
export class Renderer extends _Renderer {
defaultRenderer = new _Renderer();
override link = linkRender;
override table = tableRender;
override list = listRender;
override image = imageRender;
override text = textRender;
override heading = headingRender;
override codespan = codespanRender;
}
@@ -1,8 +0,0 @@
load("//adev/shared-docs:defaults.bzl", "ts_project")
ts_project(
name = "parser_context",
srcs = glob(["*.mts"]),
visibility = ["//adev/shared-docs/pipeline/guides/test:__subpackages__"],
deps = ["//adev/shared-docs/pipeline/guides:guides_lib"],
)
@@ -1,18 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ParserContext} from '../utils.mjs';
export const parserContext: ParserContext = {
apiEntries: {
CommonModule: 'angular/common',
bootstrapApplication: '@angular/platform-browser',
ApplicationRef: 'angular/core',
Router: 'angular/router',
},
};
@@ -1,80 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {existsSync, readFileSync} from 'fs';
import {join} from 'path';
import {cwd} from 'process';
import {shouldLinkSymbol} from '../shared/link-exemption.mjs';
// TODO(josephperrott): Set edit content url based on the owner, repo and branch.
/** The base url for edting the a file in the repository. */
const GITHUB_EDIT_CONTENT_URL = 'https://github.com/angular/angular/edit/main';
/** Get the page title with edit button to modify the page source. */
export function getPageTitle(text: string): string {
return `
<!-- Page title -->
<div class="docs-page-title">
<h1 tabindex="-1">${text}</h1>
<a class="docs-github-links" target="_blank" href="${GITHUB_EDIT_CONTENT_URL}/${context?.markdownFilePath}" title="Edit this page" aria-label="Edit this page">
<!-- Pencil -->
<docs-icon role="presentation">edit</docs-icon>
</a>
</div>`;
}
/** Configuration using environment for parser, providing context. */
export interface ParserContext {
markdownFilePath?: string;
apiEntries?: Record<string, string>;
}
let context: ParserContext = {};
export function setContext(envContext: Partial<ParserContext>) {
context = envContext;
}
export function getApiLink(entryName: string): string | undefined {
if (!context.apiEntries || hasMoreThanOneDot(entryName) || !shouldLinkSymbol(entryName)) {
return undefined;
}
let propName: string | undefined;
// we want to match functions when they have parentheses
if (entryName.endsWith('()')) {
entryName = entryName.slice(0, -2);
}
if (entryName.startsWith('@')) {
entryName = entryName.slice(1);
}
if (entryName.includes('.')) {
[entryName, propName] = entryName.split('.');
}
// We don't want to match entries like "constructor"
const apiEntry = Object.hasOwn(context.apiEntries, entryName) && context.apiEntries[entryName];
return apiEntry ? `/api/${apiEntry}/${entryName}${propName ? `#${propName}` : ''}` : undefined;
}
/** The base directory of the workspace the script is running in. */
const WORKSPACE_DIR = cwd();
export function loadWorkspaceRelativeFile(filePath: string): string {
const fullFilePath = join(WORKSPACE_DIR, filePath);
if (!existsSync(fullFilePath)) {
throw Error(`Cannot find: ${filePath}`);
}
return readFileSync(fullFilePath, {encoding: 'utf-8'});
}
function hasMoreThanOneDot(str: string) {
return str.split('.').length - 1 > 1;
}
@@ -1,38 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Token} from 'marked';
import {DocsCodeToken} from './extensions/docs-code/docs-code.mjs';
/**
* Describe a HANDLE_MERMAID value which esbuild will use at build time to determine if the mermaid
* related code should be included in the bundle.
* THIS VALUE IS NOT AVAILABLE AT RUNTIME.
*/
export declare const HANDLE_MERMAID: boolean;
/** Type guard for if a provided token is the DocsCodeToken. */
function isDocsCodeToken(token: Token): token is DocsCodeToken {
return !!(token as DocsCodeToken).language;
}
/**
* Handle the provided token based on the token itself replacing its content/data in place
* as appropriate.
*/
let mermaid: typeof import('./mermaid/index.mjs');
export async function walkTokens(token: Token): Promise<void> {
if (!isDocsCodeToken(token) || token.language !== 'mermaid') {
return;
}
if (HANDLE_MERMAID) {
mermaid ??= await import('./mermaid/index.mjs');
return mermaid.processMermaidCodeBlock(token);
}
}
+14 -9
View File
@@ -3,15 +3,20 @@ load("//adev/shared-docs:defaults.bzl", "ts_project")
package(default_visibility = ["//visibility:public"])
ts_project(
name = "shared",
srcs = glob(
[
"*.mts",
],
),
name = "linking",
srcs = ["linking.mts"],
visibility = [
"//adev/shared-docs:__subpackages__",
],
deps = [
"//adev/shared-docs/pipeline:__subpackages__",
],
)
ts_project(
name = "shiki",
srcs = ["shiki.mts"],
visibility = [
"//adev/shared-docs/pipeline:__subpackages__",
],
deps = [
"//adev:node_modules/shiki",
],
)
@@ -1,19 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// In some case we know that we don't want to link a symbol
// Example when there is a conflict between API entries and compiler features.
// eg: "animate" is both an Animation API entry and an template instruction "animation.enter"
// TODO: We should have more shared logic for linking between API references & Guides
const LINK_EXEMPT = new Set(['animate', 'animate.enter', 'animate.leave']);
export function shouldLinkSymbol(symbol: string): boolean {
return !LINK_EXEMPT.has(symbol);
}
@@ -0,0 +1,62 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// In some case we know that we don't want to link a symbol
// Example when there is a conflict between API entries and compiler features.
// eg: "animate" is both an Animation API entry and an template instruction "animation.enter"
const LINK_EXEMPT = new Set(['animate', 'animate.enter', 'animate.leave']);
export function shouldLinkSymbol(symbol: string): boolean {
return !LINK_EXEMPT.has(symbol);
}
export type ApiEntries = Record<string, string>; // symbolName -> moduleName (without @angular/ prefix)
/**
* Extracts the symbol name and property name from a symbol string.
* eg:
* foobar() => {symbolName: 'foobar', propName: null}
* ApplicationRef.tick = > {symbolName: 'ApplicationRef', propName: 'tick'}
* ApplicationRef.tick() = > {symbolName: 'ApplicationRef', propName: 'tick'}
* @Component => {symbolName: 'Component', propName: null}
*/
export function extractFromSymbol(symbol: string): {propName: string | null; symbolName: string} {
let propName: string | undefined;
let symbolName = symbol;
// we want to match functions when they have parentheses
if (symbolName.endsWith('()')) {
symbolName = symbolName.slice(0, -2);
}
if (symbolName.startsWith('@')) {
symbolName = symbolName.slice(1);
}
if (symbolName.includes('#')) {
[symbolName, propName] = symbolName.split('#');
} else if (symbolName.includes('.')) {
[symbolName, propName] = symbolName.split('.');
}
return {propName: propName ?? null, symbolName: symbolName};
}
export function getSymbolUrl(symbol: string, apiEntries: ApiEntries): string | undefined {
if (hasMoreThanOneDot(symbol) || !shouldLinkSymbol(symbol)) {
return undefined;
}
const {symbolName, propName} = extractFromSymbol(symbol);
// We don't want to match entries like "constructor"
const apiEntry = Object.hasOwn(apiEntries, symbolName) && apiEntries[symbolName];
return apiEntry ? `/api/${apiEntry}/${symbolName}${propName ? `#${propName}` : ''}` : undefined;
}
function hasMoreThanOneDot(str: string) {
return str.split('.').length - 1 > 1;
}
@@ -0,0 +1,25 @@
load("//adev/shared-docs:defaults.bzl", "ts_project")
package(default_visibility = ["//visibility:public"])
ts_project(
name = "marked",
srcs = glob(["**/*.mts"]),
visibility = [
"//adev/shared-docs/pipeline:__subpackages__",
],
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/@types/node",
"//adev:node_modules/diff",
"//adev:node_modules/emoji-regex",
"//adev:node_modules/html-entities",
"//adev:node_modules/jsdom",
"//adev:node_modules/marked",
"//adev:node_modules/mermaid",
"//adev:node_modules/playwright-core",
"//adev/shared-docs/pipeline/shared:linking",
"//adev/shared-docs/pipeline/shared:shiki",
"//adev/shared-docs/pipeline/shared/regions",
],
)
@@ -7,9 +7,8 @@
*/
import {Tokens, Token, RendererThis, TokenizerThis} from 'marked';
import {anchorTarget} from '../../helpers.mjs';
import {loadWorkspaceRelativeFile} from '../../utils.mjs';
import {setInsideLink} from '../../tranformations/link.mjs';
import {loadWorkspaceRelativeFile, anchorTarget} from '../../helpers.mjs';
import {setInsideLink} from '../../transformations/link.mjs';
interface DocsCardToken extends Tokens.Generic {
type: 'docs-card';
@@ -8,6 +8,7 @@
import {TokenizerThis, RendererThis} from 'marked';
import {CodeToken, formatCode} from './format/index.mjs';
import {AdevDocsRenderer} from '../../renderer.mjs';
export interface DocsCodeBlock extends CodeToken {
type: 'docs-code-block';
@@ -48,6 +49,6 @@ export const docsCodeBlockExtension = {
if (token.language === 'mermaid') {
return token.code;
}
return formatCode(token);
return formatCode(token, (this.parser.renderer as AdevDocsRenderer).context);
},
};
@@ -8,8 +8,9 @@
import {TokenizerThis, RendererThis} from 'marked';
import {CodeToken, formatCode} from './format/index.mjs';
import {FileType, removeEslintComments} from './sanitizers/eslint.mjs';
import {loadWorkspaceRelativeFile} from '../../utils.mjs';
import {loadWorkspaceRelativeFile} from '../../helpers.mjs';
import {AdevDocsRenderer} from '../../renderer.mjs';
import {FileType, removeEslintComments} from '../../../regions/remove-eslint-comments.mjs';
/** Marked token for a custom docs element. */
export interface DocsCodeToken extends CodeToken {
@@ -82,6 +83,6 @@ export const docsCodeExtension = {
return undefined;
},
renderer(this: RendererThis, token: DocsCodeToken) {
return formatCode(token);
return formatCode(token, (this.parser.renderer as AdevDocsRenderer).context);
},
};
@@ -8,7 +8,7 @@
import {diffLines, Change as DiffChange} from 'diff';
import {CodeToken} from './index.mjs';
import {loadWorkspaceRelativeFile} from '../../../utils.mjs';
import {loadWorkspaceRelativeFile} from '../../../helpers.mjs';
export interface DiffMetadata {
code: string;
@@ -10,61 +10,26 @@ import {decode} from 'html-entities';
import {CodeToken} from './index.mjs';
import {expandRangeStringValues} from './range.mjs';
import {JSDOM} from 'jsdom';
import {createHighlighter, HighlighterGeneric} from 'shiki';
import {HighlighterGeneric} from 'shiki';
import {codeToHtml} from '../../../../shiki.mjs';
const lineNumberClassName: string = 'shiki-ln-number';
const lineAddedClassName: string = 'add';
const lineRemovedClassName: string = 'remove';
const lineHighlightedClassName: string = 'highlighted';
let highlighter: HighlighterGeneric<any, any>;
/**
* Highlighter needs to setup asynchronously
*
* This is intended to be invoked at the start of the pipeline
*/
export async function initHighlighter() {
highlighter = await createHighlighter({
themes: ['github-light', 'github-dark'],
langs: [
'javascript',
'typescript',
'angular-html',
'angular-ts',
'shell',
'html',
'http',
'json',
'nginx',
'markdown',
'apache',
'css',
],
});
}
/**
* Updates the provided token's code value to include syntax highlighting.
*/
export function highlightCode(token: CodeToken) {
export function highlightCode(highlighter: HighlighterGeneric<any, any>, token: CodeToken) {
// TODO(josephperrott): Handle mermaid usages i.e. language == mermaidClassName
if (token.language !== 'none' && token.language !== 'file') {
// Decode the code content to replace HTML entities to characters
const decodedCode = decode(token.code);
const fallbackLanguage = guessLanguageFromPath(token.path);
const value = highlighter.codeToHtml(decodedCode, {
// we chose ts a fallback language as most example are ts.
// Idealy all examples should have a specified language
lang: token.language ?? fallbackLanguage,
themes: {
light: 'github-light',
dark: 'github-dark',
},
cssVariablePrefix: '--shiki-',
defaultColor: false,
});
token.code = value;
const language = token.language ?? guessLanguageFromPath(token.path);
const codeAsHtml = codeToHtml(highlighter, decodedCode, language);
token.code = codeAsHtml;
}
const dom = new JSDOM(token.code);
@@ -12,7 +12,8 @@ import {highlightCode} from './highlight.mjs';
import {extractRegions} from './region.mjs';
import {JSDOM} from 'jsdom';
import {expandRangeStringValues} from './range.mjs';
import {getApiLink} from '../../../utils.mjs';
import {ApiEntries, getSymbolUrl} from '../../../../linking.mjs';
import {RendererContext} from '../../../renderer.mjs';
/** Marked token for a custom docs element. */
export interface CodeToken extends Tokens.Generic {
@@ -45,14 +46,14 @@ export interface CodeToken extends Tokens.Generic {
classes?: string[];
}
export function formatCode(token: CodeToken) {
export function formatCode(token: CodeToken, context: RendererContext): string {
if (token.visibleLines !== undefined && token.visibleRegion !== undefined) {
throw Error('Cannot define visible lines and visible region at the same time');
}
extractRegions(token);
calculateDiff(token);
highlightCode(token);
highlightCode(context.highlighter, token);
const containerEl = JSDOM.fragment(`
<div class="docs-code">
@@ -62,7 +63,7 @@ export function formatCode(token: CodeToken) {
`).firstElementChild!;
applyContainerAttributesAndClasses(containerEl, token);
processForApiLinks(containerEl);
processForApiLinks(containerEl, context.apiEntries);
return containerEl.outerHTML;
}
@@ -70,7 +71,7 @@ export function formatCode(token: CodeToken) {
/**
* Process a DOM element to find spans (created by Shiki) and converts them to API links if they match entries.
*/
export function processForApiLinks(fragment: Element): void {
export function processForApiLinks(fragment: Element, apiEntries: ApiEntries): void {
const spans = fragment.querySelectorAll('span:not(:has(span))');
spans.forEach((span) => {
@@ -80,7 +81,7 @@ export function processForApiLinks(fragment: Element): void {
// Yes, index 0 is not interesting for us here
const [, before, symbol, after] = symbolMatch;
const apiLink = getApiLink(symbol);
const apiLink = getSymbolUrl(symbol, apiEntries);
if (apiLink) {
// Create a new link element
const linkElement = fragment.ownerDocument!.createElement('a');
@@ -6,9 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {regionParser} from '../../../../regions/region-parser.mjs';
import {FileType} from '../../../../regions/remove-eslint-comments.mjs';
import {CodeToken} from './index.mjs';
import {regionParser} from '../regions/region-parser.mjs';
import {FileType} from '../sanitizers/eslint.mjs';
/**
* Updates the provided token to include the extracted region as the visible lines for the token.
@@ -7,7 +7,9 @@
*/
import {TokenizerThis, Tokens, RendererThis} from 'marked';
import {getPageTitle, loadWorkspaceRelativeFile} from '../utils.mjs';
import {loadWorkspaceRelativeFile} from '../helpers.mjs';
import {getPageTitle} from '../transformations/heading.mjs';
import {AdevDocsRenderer} from '../renderer.mjs';
interface DocsDecorativeHeaderToken extends Tokens.Generic {
type: 'docs-decorative-header';
@@ -63,7 +65,7 @@ export const docsDecorativeHeaderExtension = {
<div class="docs-header-content">
<docs-breadcrumb></docs-breadcrumb>
${getPageTitle(token.title)}
${getPageTitle(token.title, (this.parser.renderer as AdevDocsRenderer).context.markdownFilePath)}
<p>${token.body}</p>
</div>
@@ -7,7 +7,8 @@
*/
import {Token, Tokens, RendererThis, TokenizerThis} from 'marked';
import {formatHeading} from '../../tranformations/heading.mjs';
import {formatHeading} from '../../transformations/heading.mjs';
import {AdevDocsRenderer} from '../../renderer.mjs';
interface DocsStepToken extends Tokens.Generic {
type: 'docs-step';
@@ -51,7 +52,7 @@ export const docsStepExtension = {
return `
<li>
<span class="docs-step-number" aria-hidden="true"></span>
${formatHeading({text: token.title, depth: 3})}
${formatHeading({text: token.title, depth: 3}, (this.parser.renderer as AdevDocsRenderer).context.markdownFilePath)}
${this.parser.parse(token.tokens)}
</li>
`;
@@ -0,0 +1,32 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {existsSync, readFileSync} from 'fs';
import {join} from 'path';
import {cwd} from 'process';
/** Whether the link provided is external to the application. */
export function isExternalLink(href: string | undefined | null) {
return href?.startsWith('http') ?? false;
}
/** Provide the correct target for the anchor tag based on the link provided. */
export function anchorTarget(href: string | undefined | null) {
return isExternalLink(href) ? ` target="_blank"` : '';
}
/** The base directory of the workspace the script is running in. */
const WORKSPACE_DIR = cwd();
export function loadWorkspaceRelativeFile(filePath: string): string {
const fullFilePath = join(WORKSPACE_DIR, filePath);
if (!existsSync(fullFilePath)) {
throw Error(`Cannot find: ${filePath}`);
}
return readFileSync(fullFilePath, {encoding: 'utf-8'});
}
@@ -0,0 +1,82 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {marked, Token} from 'marked';
import {AdevDocsRenderer, RendererContext} from './renderer.mjs';
import {docsAlertExtension} from './extensions/docs-alert.mjs';
import {docsCalloutExtension} from './extensions/docs-callout.mjs';
import {docsPillExtension} from './extensions/docs-pill/docs-pill.mjs';
import {docsPillRowExtension} from './extensions/docs-pill/docs-pill-row.mjs';
import {docsVideoExtension} from './extensions/docs-video.mjs';
import {docsWorkflowExtension} from './extensions/docs-workflow/docs-workflow.mjs';
import {docsStepExtension} from './extensions/docs-workflow/docs-step.mjs';
import {docsCardExtension} from './extensions/docs-card/docs-card.mjs';
import {docsCardContainerExtension} from './extensions/docs-card/docs-card-container.mjs';
import {docsDecorativeHeaderExtension} from './extensions/docs-decorative-header.mjs';
import {docsCodeBlockExtension} from './extensions/docs-code/docs-code-block.mjs';
import {docsCodeExtension, DocsCodeToken} from './extensions/docs-code/docs-code.mjs';
import {docsCodeMultifileExtension} from './extensions/docs-code/docs-code-multifile.mjs';
import {hooks} from './hooks.mjs';
let markedInstance: typeof marked;
const extensions = [
docsAlertExtension,
docsCalloutExtension,
docsPillExtension,
docsPillRowExtension,
docsVideoExtension,
docsWorkflowExtension,
docsStepExtension,
docsCardExtension,
docsCardContainerExtension,
docsDecorativeHeaderExtension,
docsCodeBlockExtension,
docsCodeExtension,
docsCodeMultifileExtension,
];
export async function parseMarkdownAsync(
markdownContent: string,
context: RendererContext,
): Promise<string> {
markedInstance ??= marked.use({hooks, extensions, walkTokens, async: true});
return markedInstance.parse(markdownContent, {renderer: new AdevDocsRenderer(context)});
}
export function parseMarkdown(markdownContent: string, context: RendererContext): string {
markedInstance ??= marked.use({hooks, extensions, walkTokens});
return markedInstance.parse(markdownContent, {renderer: new AdevDocsRenderer(context)}) as string;
}
/**
* Describe a HANDLE_MERMAID value which esbuild will use at build time to determine if the mermaid
* related code should be included in the bundle.
* THIS VALUE IS NOT AVAILABLE AT RUNTIME.
*/
export declare const HANDLE_MERMAID: boolean;
/** Type guard for if a provided token is the DocsCodeToken. */
function isDocsCodeToken(token: Token): token is DocsCodeToken {
return !!(token as DocsCodeToken).language;
}
/**
* Handle the provided token based on the token itself replacing its content/data in place
* as appropriate.
*/
let mermaid: typeof import('./mermaid/index.mjs');
export async function walkTokens(token: Token): Promise<void> {
if (!isDocsCodeToken(token) || token.language !== 'mermaid') {
return;
}
if (HANDLE_MERMAID) {
mermaid ??= await import('./mermaid/index.mjs');
return mermaid.processMermaidCodeBlock(token);
}
}
@@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Renderer} from 'marked';
import {linkRender} from './transformations/link.mjs';
import {tableRender} from './transformations/table.mjs';
import {listRender} from './transformations/list.mjs';
import {imageRender} from './transformations/image.mjs';
import {textRender} from './transformations/text.mjs';
import {headingRender} from './transformations/heading.mjs';
import {codespanRender} from './transformations/code.mjs';
import {HighlighterGeneric} from 'shiki';
export interface RendererContext {
markdownFilePath: string;
apiEntries: Record<string, string>;
highlighter: HighlighterGeneric<any, any>;
}
/**
* Custom renderer for marked that will be used to transform markdown files to HTML
* files that can be used in the Angular docs.
*/
export class AdevDocsRenderer extends Renderer {
constructor(public context: RendererContext) {
super();
}
defaultRenderer = new Renderer();
override link = linkRender;
override table = tableRender;
override list = listRender;
override image = imageRender;
override text = textRender;
override heading = headingRender;
override codespan = codespanRender;
}
@@ -0,0 +1,8 @@
load("//adev/shared-docs:defaults.bzl", "ts_project")
ts_project(
name = "renderer_context",
srcs = ["renderer-context.mts"],
visibility = ["//adev/shared-docs/pipeline/shared/marked/test:__subpackages__"],
deps = ["//adev/shared-docs/pipeline/shared/marked"],
)
@@ -3,14 +3,12 @@ load("//adev/shared-docs:defaults.bzl", "ts_project", "zoneless_jasmine_test")
ts_project(
name = "code",
testonly = True,
srcs = glob([
"**/*.spec.mts",
]),
srcs = glob(["*.spec.mts"]),
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/guides/test:parser_context",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -9,13 +9,13 @@
import {readFile} from 'fs/promises';
import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {parserContext} from '../parser-context.mjs';
import {rendererContext} from '../renderer-context.mjs';
describe('markdown to html', () => {
let parsedMarkdown: string;
beforeAll(async () => {
const markdownContent = await readFile(resolve('./code.md'), {encoding: 'utf-8'});
parsedMarkdown = await parseMarkdown(markdownContent, parserContext);
parsedMarkdown = await parseMarkdown(markdownContent, rendererContext);
});
it('should render symbol with link', () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -12,13 +12,14 @@ import {JSDOM} from 'jsdom';
import {AlertSeverityLevel} from '../../extensions/docs-alert.mjs';
import {parseMarkdown} from '../../parse.mjs';
import {rendererContext} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
const markdownContent = await readFile(resolve('docs-alert.md'), {encoding: 'utf-8'});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
for (const [key, level] of Object.entries(AlertSeverityLevel)) {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -10,13 +10,14 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {rendererContext} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
const markdownContent = await readFile(resolve('docs-callout.md'), {encoding: 'utf-8'});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it(`defaults to a helpful callout`, () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -10,13 +10,14 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {rendererContext} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
const markdownContent = await readFile(resolve('docs-card-container.md'), {encoding: 'utf-8'});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it('creates card containers containing multiple cards', () => {
@@ -7,8 +7,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/guides/test:parser_context",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -10,15 +10,14 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {parser} from 'marked';
import {parserContext} from '../parser-context.mjs';
import {rendererContext} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
const markdownContent = await readFile(resolve('docs-card.md'), {encoding: 'utf-8'});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, parserContext));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it('creates cards with no links', () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -10,15 +10,15 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {initHighlighter} from '../../extensions/docs-code/format/highlight.mjs';
import {rendererContext, setHighlighter} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
await initHighlighter();
await setHighlighter();
const markdownContent = await readFile(resolve('./docs-code-block.md'), {encoding: 'utf-8'});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it('converts triple ticks into a code block', () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -10,17 +10,17 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {initHighlighter} from '../../extensions/docs-code/format/highlight.mjs';
import {rendererContext, setHighlighter} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
await initHighlighter();
await setHighlighter();
const markdownContent = await readFile(resolve('./docs-code-multifile.md'), {
encoding: 'utf-8',
});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it('converts triple ticks into a code block', () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -10,15 +10,15 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {initHighlighter} from '../../extensions/docs-code/format/highlight.mjs';
import {rendererContext, setHighlighter} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
beforeAll(async () => {
await initHighlighter();
await setHighlighter();
const markdownContent = await readFile(resolve('./docs-code.md'), {encoding: 'utf-8'});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it('converts docs-code elements into a code block', () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)
@@ -10,6 +10,7 @@ import {parseMarkdown} from '../../parse.mjs';
import {resolve} from 'node:path';
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {rendererContext} from '../renderer-context.mjs';
describe('markdown to html', () => {
let markdownDocument: DocumentFragment;
@@ -18,7 +19,7 @@ describe('markdown to html', () => {
const markdownContent = await readFile(resolve('./docs-decorative-header.md'), {
encoding: 'utf-8',
});
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, {}));
markdownDocument = JSDOM.fragment(await parseMarkdown(markdownContent, rendererContext));
});
it('sets the custom title in the header', () => {
@@ -9,7 +9,8 @@ ts_project(
deps = [
"//adev:node_modules/@types/jsdom",
"//adev:node_modules/jsdom",
"//adev/shared-docs/pipeline/guides:guides_lib",
"//adev/shared-docs/pipeline/shared/marked",
"//adev/shared-docs/pipeline/shared/marked/test:renderer_context",
],
)

Some files were not shown because too many files have changed in this diff Show More