![]()
Note: This article is mostly up to date. There may have been a few changes to the website since the article was written, such as the copy button at the bottom of each code block, but what is herein discussed is still valid and serves as a good reference for the integration of markdown into an Angular frontend.
Integrating Markdown into an Angular Frontend
This article discusses the setup for the frontend of the projects page's portfolio website. The initial projects page displays a list of project application in a large card-like view and associated articles in smaller card-like views beneath each related project. The most intricate part of this portion of the website is the integration of the articles written in markdown on the backend and rendered on the frontend. This walkthrough first takes us through the dependencies that help display and effectively stylize the markdown articles transmitted from the backend. There are a few extension discussed, one of which is prepackaged while the other is customized in the project. A directive is also added into the project to help render the math equations and other specialized elements in the markdown articles. This ensures that the code one might use in LaTeX or KaTeX will render correctly. Once all of the dependencies are written, the article moves onto the DTOs, services classes, and components for the projects and articles, all of which are necessary to retrieve and display the data packets from the backend.
Dependencies
What are dependencies? Dependencies are the packages that are required to run the project. They are added to the project via the npm package manager. Dependencies come from the npm registry, which is a a repository of packages that can be installed and used in a project. While the code for these dependencies is added to the node_modules directory, npm also writes its name and version into the package.json file and updates the package-lock.json file to reflect the new dependency. This article extends the definition of dependencies to include the plugins, extensions, and directives that are used to display and stylize the markdown articles.
npm i -D @tailwindcss/typography
This is a pretty significant dependency as it allows for the markdown articles to be displayed on the frontend using a plugin that must be added to the tailwind.config.js file. Without this, the markdown articles would not be displayed correctly and most of the work done below is lost. Adding this dependency requires an extra step discussed in the section titled,"Adding a Plugin to Tailwind".
npm install ngx-markdown@^19 marked@^15 marked-gfm-heading-id
Since this project is using Angular 19, the dependencies must be compatible with Angular 19. Adding the @^* to the end of each dependency ensures that the dependency installed is compatible with Angular 19. The few dependencies listed above each support the markdown articles in their own unique way. The ngx-markdown dependency let's the project use Angular components and directives such as <markdown>. This ensures that the rendered markdown text, such as # headers, ** bold **, will be displayed as HTML in the project's Angular templates. The ngx-markdown dependency uses the marked dependency under the hood to parse raw markdown text into HTML. Installing it explicitly gives one control over the version and allows its options to be configured, as is done in the inline code extension discussed below. The marked-gfm-heading-id dependency is a Github-Flavored Markdown (GFM) heading id extension, which makes it possible to link to specific headings in the markdown articles, as is demonstrated in this article. These three dependencies support the display of the markdown articles on the frontend while the next two support some beautiful code and math stylizing.
npm install prismjs katex
The PrismJS and KaTeX libraries are often used together with the ngx-markdown to give markdown syntax highlighting and math support. The PrismJS is a lightweight syntax highlighting library that automatically colors code blocks according to their related language(python, typescript, javascript, etc.). It has plugins for line numbers, copy to clipboard, diff highlighting, and more. The KaTeX library is a fast math typesetting library from Khan Academy that renders LaTex-style math inside the HTML. It works both inline ($...$) and as a block ($$...$$) and supports most of LaTex's math symbols. These dependencies help code examples and math equations within markdown articles look professional and improves readability.
npm install --save-dev @types/katex
The above is a TypeScript helper package, not a runtime library. The @types/katex declares it as a type definition package for KaTeX. As the KaTeX package is written in JavaScript and does not include TypeScript typings by default, installing the above gives TypeScript knowledge of the KaTeX API. Most notably in this project, it allows for the renderInElement function to be used within the math-katex.directive.ts file.
Adding a Plugin to Tailwind
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ["./src/**/*.{html,ts}"],
theme: { extend: {} },
plugins: [require('@tailwindcss/typography')],
};
Note the line: plugins: [require('@tailwindcss/typography')]. This is the prose plugin that is added via npm install -D @tailwindcss/typography. It let's most of styling occur from the outer wrapper or the parent components. In the case of this website, the typography plugin does most of the heavy lifting for the rendered markdown articles. While the markdown articles are discussed in the last few sections, it is worth noting that none of the unique code block, image centering, or inline styling for the Markdown articles loaded from the backend will apply without this. Every bit of color rendered in the articles would be lost without tailwind's typography plugin. This one line code entry into the plugins array can make or break everything that is to follow.
Add CSS to angular.json
"styles": [
"src/tailwind.css",
"src/styles.scss",
"node_modules/prismjs/themes/prism.css",
"node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css",
"node_modules/katex/dist/katex.min.css"
],
In an Angular project, external CSS from node_modules can be registered globally by adding them to the styles array in the angular.json configuration file. This ensures that libraries, such as PrismJS for syntax highlighting, its line-numbers plugin, and KaTeX for math rendering, are consistently applied across the application. Within angular.json, the styles array typically appears in two places: under architect > build, which applies the styles during the production build of the application, and under architect > test, which makes the same styles available when running unit tests. Keeping these entries synchronized guarantees that both the built application and its test environment render components with the same styling behavior.
Styles SCSS
When inline code blocks are used in a markdown file, backticks surround the code blocks as delimiters. When the inline code blocks are rendered on the frontend via the tailwind typographic plugin, these backticks are not removed and instead are rendered as decorative text. While this might be useful, the other typographic styles such as the colored text, slight padding around the sides, etc, make the code blocks stand out enough to justify removing the eyesore that these backticks create. This is done in the global styles.scss file in the src/ directory.
styles.scss
.prose.no-code-quotes code::before,
.prose.no-code-quotes code::after {
content: none !important;
}
@media (prefers-color-scheme: dark) {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none !important;
}
}
There are only two global styles applied to this project, both of which are to account for how the code block extensions create a few unwanted features. The first SCSS block of scss code targets <code> element with the prose and no-code-quotes class attribute selectors. It sets the content property to none with the !important flag for the ::before and ::after pseudo-elements of these <code> elements. This effectively hides any content that would have been inserted before or after the <code> elements specifically within the context of elements with the classes prose and no-code-quotes. To use this, one first has to add the prose class selector followed by the no-code-quotes class selector within a class attribute, such as class="prose no-code-quotes". Without prose preceding the no-code-quotes, the code block will not be styled correctly. The second block of scss code is used to account for a white shadow that appears underneath the inline and codeblock text when dark mode is activated by the user's browser. Applying these two stylizers in the global styles sheets ensures that whenever markdown code appears, none of the text will be highlighted with a unique background color and there will be no inverted white shadow underneath the text in dark mode.
Inline Code Block Styling
Other styling that will be applied alongside the prose tailwind typographic selector includes and inline language code block styling extension. In markdown, the backtick is often used inside a string of text to write a segment of code inline. When rendered on the frontend via the tailwind typographic plugin, it prints colored text without any highlighting. An inline language extension can be used to create different styles for different languages so that a backtick paired with a language tag will print a code block with the corresponding language highlighting. This was coded as an extension under the src/app/ directory by running the CLI command touch src/app/inline-lang.extensions.ts from the root directory.
inline-lang.extensions.ts
import type { MarkedExtension } from 'marked';
const LANG_MAP: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'bash',
bash: 'bash',
json: 'json',
md: 'markdown',
html: 'markup', // Prism calls HTML "markup"
css: 'css',
yml: 'yaml',
nginx: 'nginx',
docker: 'docker',
dockerfile: 'docker',
};
function escapeHtml(s: string) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
export function inlineLangCodespan(): MarkedExtension {
return {
renderer: {
codespan(codespan: { text: string }) {
// pattern: "<lang> : <code>"
const m = codespan.text.match(/^(\w+)\s*:\s+([\s\S]+)$/);
if (m) {
const short = m[1].toLowerCase();
const lang = LANG_MAP[short] ?? short; // fallback to the token
const body = escapeHtml(m[2]);
return `<code class="font-semibold !bg-transparent px-1 !text-sm language-${lang}">${body}</code>`;
}
// default inline code (no language tag)
return `<code>${escapeHtml(codespan.text)}</code>`;
},
},
};
}
The inlineLangCodespan function is a Marked extension in TypeScript that allows for inline code blocks with specified language tags to be rendered with Prims syntax highlighting.
import type { MarkedExtension } from 'marked';
The above imports the MarkedExtension type from the marked module in TypeScript. This allows for use of the MarkedExtension for type checking and IntelliSense support without importing the actual implementation of the module. The type keyword in the import statement tells TypeScript to only import the type information and not the runtime value.
const LANG_MAP: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'bash',
bash: 'bash',
json: 'json',
md: 'markdown',
html: 'markup', // Prism calls HTML "markup"
css: 'css',
yml: 'yaml',
nginx: 'nginx',
docker: 'docker',
dockerfile: 'docker',
};
This creates a mapping object in TypeScript. This object is used to map short language codes to their corresponding Prism language classes for syntax highlighting in code blocks.
function escapeHtml(s: string) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
The escapeHtml function takes a string input and replaces special characters like &, <, and > with their corresponding HTML entities (&, <, and >) to prevent HTML injection attacks and ensure the text is displayed correctly in an HTML context.
export function inlineLangCodespan(): MarkedExtension {
return {
renderer: {
codespan(codespan: { text: string }) {
// pattern: "<lang> : <code>"
const m = codespan.text.match(/^(\w+)\s*:\s+([\s\S]+)$/);
if (m) {
const short = m[1].toLowerCase();
const lang = LANG_MAP[short] ?? short; // fallback to the token
const body = escapeHtml(m[2]);
return `<code class="font-semibold !bg-transparent px-1 !text-sm language-${lang}">${body}</code>`;
}
// default inline code (no language tag)
return `<code>${escapeHtml(codespan.text)}</code>`;
},
},
};
}
The function inlineLangCodespan is a TypeScript function that is part of a Marked extension. It processes inline code spans with language tags and converts them into HTML code blocks with appropriate language classes. The renderer: {...} part in the inlineLangCodespan function is defining an object literal with a property named renderer. This property is used to specify custom rendering functions for different elements in the Markdown content. In this case, the codespan method is defined with the renderer object to handle the rendering of inline code spans with language tags. The codespan method processes the text content of the inline code span. The language-* selector is from PrismJS.
const m = codespan.text.match(/^(\w+)\s*:\s+([\s\S]+)$/);
if (m) {
const short = m[1].toLowerCase();
const lang = LANG_MAP[short] ?? short; // fallback to the token
const body = escapeHtml(m[2]);
return `<code class="font-semibold !bg-transparent px-1 !text-sm language-${lang}">${body}</code>`;
}
The inlineLangCodespan function uses a regular expression to match a specific pattern within the text content of an inline code span. Let's break down the regular expression and its purpose using the example text: bash: django-admin startproject backend. This will help to understand how the function works and how to write similar regular expressions for other patterns. The first step is breaking down the regular expression pattern into its components. The entire regular expression pattern is: ^(\w+)\s*:\s+([\s\S]+)$. In this expression, the ^ and $ are the anchors that match the start and end of the string respectively. The (\w+) captures a word at the start (bash). The \w matches any word character, which includes letters (a-z, A-Z), digits (0-9), and underscores (_). The + matches the previous token one or more times. The (...) captures the first group of characters. The \s*:\s+ matches is the separator, :, surrounded by whitespace, which is identified by the \s* and \s+ tokens. Whitespace characters include spaces, tabs, and newlines. The ([\s\S]+) captures the rest of the string (django-admin startproject backend). The \S matches any non-whitespace character, this includes letters, digits, and special characters. Used in tandem with \s, [\s\S]+ matches any combination of whitespace and non-whitespace characters. Placing this combination in (...) followed by $ captures the rest of the string in its own group. This is the second group of characters. When the text is passed to the match method, const m captures the first and second groups of characters. In this case m[1] stores bash and m[2] stores django-admin startproject backend. The first part of the array, m[1], is set to the constant short, which then maps the short language code to its corresponding Prism language class. The body or second group identified in the regular expression, m[2], is then passed through the escapeHtml(...) method to ensure certain special characters are replaced with their corresponding html entities. These variables are then used within the return statement. In the case of our example text, `bash: django-admin startproject backend` will output <code class="font-semibold !bg-transparent px-1 !text-sm language-bash">django-admin startproject backend</code> and render as django-admin startproject backend. There are a few tailwind selectors added that help to style the code block, such as rendering the font weight to be semibold and the background to be transparent, adding a slight padding around the sides, ensuring the text size is set to small to match the size of the text in the markdown article. This all contributes to supporting some very pretty and easily identifiable inline code blocks that make the markdown articles a little easier to read through.
Katex Directive
To generate the MathKatexDirective directive class, run the following CLI command in the root directory of the project:
ng generate directive math-katex
Note: Running the above should automatically add the directive to the app.module.ts file.
math-katex.directive.ts
import { Directive, ElementRef, AfterViewChecked } from '@angular/core';
import renderMathInElement from 'katex/contrib/auto-render';
@Directive({
selector: '[appMathKatex]' // usage: <div appMathKatex>…</div>
})
export class MathKatexDirective implements AfterViewChecked {
private rendered = false;
constructor(private el: ElementRef) {}
ngAfterViewChecked() {
if (this.rendered) return; // prevent infinite re-renders
renderMathInElement(this.el.nativeElement, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false
});
this.rendered = true;
}
}
In the above example, appMathKatex is used as the attribute selector that identifies the directive to be applied. In this case it will help render the math equations within the div element.
This Angular directive (MathKatexDirective) auto-typesets LaTeX math inside the host element using KaTeX’s auto-render. It’s applied via the attribute selector [appMathKatex] (e.g.<div appMathKatex>…</div>). On the first AfterViewChecked run, it calls renderMathInElement(...) on the host DOM node (ElementRef.nativeElement) with two delimiter styles—$$ … $$ for display math and \(...\) for inline math—so any matching text is converted into rendered equations. The rendered boolean acts as a simple guard to prevent re-rendering and infinite loops, and throwOnError: false ensures invalid math doesn’t crash rendering but is skipped gracefully .With these pieces in place, the next step is to register and bootstrap them in app.module.ts—adding the directives to declarations, the Markdown and Tailwind support to imports, and the platform services to providers—so they’re available app-wide.
Bootstrapping the Dependencies
app.module.ts
...
import { ProjectsComponent } from './components/projects/projects.component';
import { ArticleComponent } from './components/projects/article/article.component';
import { MathKatexDirective } from './math-katex.directive';
import { inlineLangCodespan } from './inline-lang.extensions';
import {MarkdownModule, MARKED_OPTIONS, MARKED_EXTENSIONS, MarkedOptions } from 'ngx-markdown';
import { gfmHeadingId } from 'marked-gfm-heading-id';
// Prism
import 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-markdown';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-nginx';
// line numbers class selector
import 'prismjs/plugins/line-numbers/prism-line-numbers';
...
@NgModule({
declarations: [
...
ProjectsComponent,
ArticleComponent,
MathKatexDirective,
],
imports: [
...
MarkdownModule.forRoot({
sanitize: SecurityContext.NONE,
markedOptions: {
provide: MARKED_OPTIONS,
useValue: <MarkedOptions>{
gfm: true,
breaks: false,
pedantic: false,
},
},
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useFactory: () => gfmHeadingId(),
multi: true,
},
{
provide: MARKED_EXTENSIONS,
useFactory: () => inlineLangCodespan(),
multi: true,
}
]
})
],
providers: [
provideClientHydration(withEventReplay()),
provideHttpClient(withFetch())
],
bootstrap: [AppComponent]
})
export class AppModule { }
Bootstrapping the dependencies is the last step in the process of integrating all the dependencies for the markdown articles into the app. The custom declaration shared in the code block below should be added automatically when the ng generate CLI command is run.
import { ProjectsComponent } from './components/projects/projects.component';
import { ArticleComponent } from './components/projects/article/article.component';
import { MathKatexDirective } from './math-katex.directive';
...
@NgModule({
declarations: [
...
ProjectsComponent,
ArticleComponent,
MathKatexDirective,
],
These are all written within the custom components and directives. While the MathKatexDirective code was shared above, the other two are written explored in more detail in the Service Classes and Components sections of this walkthrough. Declaring these in the @NgModule ensures that they are available throughout the project. While the code that we write is included in the declaration array, the dependency packages are included in the imports array.
import {MarkdownModule, MARKED_OPTIONS, MARKED_EXTENSIONS, MarkedOptions } from 'ngx-markdown';
import { gfmHeadingId } from 'marked-gfm-heading-id';
import { inlineLangCodespan } from './inline-lang.extensions';
...
@NgModule({
imports: [
...
MarkdownModule.forRoot({
sanitize: SecurityContext.NONE,
markedOptions: {
provide: MARKED_OPTIONS,
useValue: <MarkedOptions>{
gfm: true,
breaks: false,
pedantic: false,
},
},
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useFactory: () => gfmHeadingId(),
multi: true,
},
{
provide: MARKED_EXTENSIONS,
useFactory: () => inlineLangCodespan(),
multi: true,
}
]
})
],
These are the three big dependency added via the npm package manager and included in the imports array for the markdown articles. The ngx-markdown library uses the marked library to parse the markdown articles into HTML. It includes marked within its own dependency package and, as such, marked does not need to be imported separately. These, however, are imported uniquely into the imports array. To enable the marked-gfm-heading-id, it must be added through the forRoot(...) method of the MarkdownModule object. Within the forRoot(...) method, sanitize is called with SecurityContext.NONE to ensure that the markdown articles are rendered without any sanitization. When this is not stated explicitly, Angular sanitizes the markdown articles by default to prevent cross-site scripting (XSS) attacks. However, default sanitization prevents the marked-gfm-heading-id dependency from scrolling to the correct heading of a markdown article when linked with a # fragment in the url. Thus, setting it to SecurityContext.NONE enables this feature. This, of course, means that the sanitization of the markdown articles must be handled manually (covered in :: link article :: ). After sanitation is explicitly set to none, MARKED_OPTIONS is injected into the provide property of the MarkdownModule object. This is integrated to include the gfm Github-style features, which enables tables, task lists, autolinks@example.com, and strikethrough syntax within the markdown article. The markedExtensions property array is then called to include the gfmHeadingId and inlineLangCodespan extensions via a similar injection technique. This technique adds the MARKED_EXTENSIONS as an injection token into the provide property and applies the useFactory property to call the gfmHeadingId and inlineLangCodespan functions. The gfmHeadingId extension adds the scrolling behavior discussed earlier in this paragraph and the inlineLangCodespan extension adds the inline language code block styling extension written in the inline-lang.extensions.ts file.
providers: [
provideClientHydration(withEventReplay()),
provideHttpClient(withFetch())
],
The provideClientHydration(withEventReplay()) and provideHttpClient(withFetch()) are used to provide the client hydration and fetch services to the project. Without these, the project will not be able to hydrate the client and fetch the data from the backend. After the server renders the Angular app, provideClientHydration(withEventReplay()) enables client-side hydration (so the browser reuses the server-rendered DOM) and records user interactions during startup so they’re replayed after the app attaches—preventing “lost clicks” and reducing flicker. With hydration, Angular’s HTTP transfer cache is on by default, allowing the browser to reuse server-made GET responses (which is what prevents most duplicate requests, provided the URLs/origins match). In laymen's terms, it prevents multiple calls from being made to the backend when a page is opened by a user and much more. The provideHttpClient(withFetch()) is the modern, provider-based way to wire up Angular’s HttpClient (replacing HttpClientModule) and opts it into the Fetch backend (Promise-based, streaming-friendly) instead of legacy XHR. Together, these providers deliver smooth SSR to client handoff, preserve early user interactions, and enable efficient reuse of server HTTP responses on the client.
// Prism
import 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-markdown';
import 'prismjs/components/prism-django';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-nginx';
import 'prismjs/plugins/line-numbers/prism-line-numbers';
Prism is imported to ensure that the code blocks are rendered with the correct syntax highlighting. It does not need to be added to the NgModule imports array. Prism attaches itself to the global window object (window.Prism). The ngx-markdown package know how to hook into Prism if it detects it on the page. The declarations, imports, and providers arrays inside the @NgModule are only for angular specific constructs. Since Prism is not angular specific, it does not need to be added to the NgModule imports array.
Data Transfer Objects
Now that the dependencies have been added and the typescript files are properly written and imported, we need to generate the DTOs for the project and article models. Since the backend is not discussed in this article I simply share a json file that seeds viable data into the sqlite3 database for illustration purposes. The frontend DTOs must match the serialized data in the backend illustrated in the json block below.
[
...
{
"model": "portfolio.project",
"pk": 1,
"fields": {
"name": "Portfolio Website",
"description": "This is the portfolio website that you are currently viewing. It was built to share the courses that I took, host some of the projects that I have worked on, and share some articles related to each project.",
"category": "application",
"slug": "portfolio-website",
"image": "projects/images/portfolio-icon.png",
"expanded": false,
"sort_index": 1001
}
},
{
"model": "portfolio.article",
"pk": 1,
"fields": {
"project": 1,
"title": "Backend Development with Django",
"slug": "backend-development-with-django",
"category": "Article: Walk Through",
"file": "projects/portfolio-website/backend-article.md",
"image": "projects/images/django-badge.png",
"expanded": false,
"sort_index": 1001
}
},
{
"model": "portfolio.article",
"pk": 2,
"fields": {
"project": 1,
"title": "Angular Single Page Frontend",
"slug": "angular-single-page-frontend",
"category": "Article: Walk Through",
"file": "projects/portfolio-website/frontend-angular-article.md",
"image": "projects/images/angular-icon.png",
"expanded": false,
"sort_index": 1002
}
},
{
"model": "portfolio.article",
"pk": 3,
"fields": {
"project": 1,
"title": "Integrating Markdown Articles into an Angular Frontend",
"slug": "integrating-markdown-articles-into-an-angular-frontend",
"category": "Article: Walk Through",
"file": "projects/portfolio-website/angular-markdown-article.md",
"image": "projects/images/angular-icon.png",
"expanded": false,
"sort_index": 1003
}
}
]
To generate the Data Transfer Objects (DTOs) on the frontend, run the following CLI commands in the root directory of the project:
ng generate interface models/project --type=dto
ng generate interface models/article --type=dto
This will generate the project.dto.ts and article.dto.ts interfaces in the src/app/models directory.
Project DTO
import { Article } from "./article.dto";
export interface Project {
id: number;
name: string;
description: string | null;
slug: string;
image: string | null;
articles: Article[];
someBool: boolean;
sort_index: number;
created_at: string;
updated_at: string;
}
Article DTO
export interface Article {
id: number;
project_id: number;
title: string;
slug: string;
category: string;
file: string | null;
image: string | null;
whatABool: boolean;
sort_index: number;
created_at: string;
updated_at: string;
}
The Article DTO is a child of the Project DTO and, as such, the Project DTO includes an array of Article DTOs. All of the properties are related to the serialized data from the backend that has been shown in the json block above. Hence, the data passed into article.id should match the "pk" field, which stands for primary key, under the json block containing the portfolio.article model while the data passed into article.project_id should match the "pk" field under the json block containing the article's related project. So, in the instance of the article titled "Integrating Markdown into an Angular Frontend", when the data passed into article.id is 3 the corresponding data passed into article.project_id is 1. In the json block above, the "project" field is the foreign key to the portfolio.project model. This allows the backend to create a relationship between the article and the project. While the field "project_id" does not need to be seeded in the json block above, it is still a valid field in the Article DTO because of this relationship. Now that the DTOs have been written to reflect the data packets that are returned from the backend, the next step is to generate the service classes for the Project and Article models.
API Service Classes
In an Angular application, service classes are used to organize and encapsulate reusable logic that isn’t directly tied to a specific component’s view. They typically handle tasks like data fetching (e.g., making HTTP requests), state management, business logic, or shared utility functions. Services are provided to Angular’s dependency injection (DI) system, which allows components (and other services) to request them without having to manually create instances. This helps keep components lean, promotes code reuse, and makes the application easier to maintain and test. While some of the services were discussed in the "Dependencies" section of this article, the following sections explores the data fetching services written for the Project and Article models.
Project Service Class
ng generate service services/project
The ng generate service services/project command generates the ProjectService class in the src/app/services/. This service is responsible for fetching project data packets from the backend and returning them to the frontend. A bare bones service class transmitting the data packets can be run with the following code.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Project } from '../models/project.dto';
@Injectable({
providedIn: 'root'
})
export class ProjectService {
private readonly baseUrl = `/api/projects`;
constructor(private http: HttpClient) { }
getProjects(): Observable<Project[]> {
return this.http.get<Project[]>(this.baseUrl);
}
getProject(slug: string): Observable<Project> {
return this.http.get<Project>(`${this.baseUrl}/${slug}`);
}
}
This minimalist ProjectService class is an Angular service marked with @Injectable({ providedIn: 'root' }), which makes it available application-wide through dependency injection. This class contains two methods: getProjects() and getProject(slug: string). Both of these methods make HTTP GET requests to retrieve project entity packets from the backend API at the /api/projects endpoint. The first method, getProjects(), retrieves from an endpoint that contains all the projects in the database while the second method, getProject(slug: string), retrieves the data packet from an endpoint that contains one project related to the slug passed in as an argument. The projects are then returned to the frontend via the Observable object, which is a class used to represent a stream of data that can be observed over time. This service class can be used in the Projects Component to fetch and display the projects on the frontend. The same logic applies to the Article Service Class, which is discussed in the next section.
Article Service Class
To generate the ArticleService, run the following CLI command in the root directory of the project:
ng generate service services/project
This generates the Article Service in the src/app/services/. The Article Service is responsible for fetching articles from the backend and a minimal service class is generated with the following code:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Article } from '../models/article.dto';
@Injectable({
providedIn: 'root'
})
export class ArticleService {
private readonly baseUrl = `/api/articles`;
constructor(private http: HttpClient) { }
getArticles(): Observable<Article[]> {
return this.http.get<Article[]>(this.baseUrl);
}
getArticle(slug: string): Observable<Article> {
return this.http.get<Article>(`${this.baseUrl}/${slug}`);
}
getArticleMarkdown(slug: string) {
return this.http.get(`/api/articles/${slug}/raw/`, { responseType: 'text' });
}
}
This is also a minimalist service class that is available application-wide through dependency injection. It targets a few endpoints, the /api/articles endpoint that contains a list of articles, an extended slug endpoint for that returns the properties or fields for a specific article, and an extended slug endpoint for that returns the raw markdown content of the article. These endpoints are fetched in three methods: getArticles(), getArticle(slug: string), and getArticleMarkdown(slug: string). The first two methods are identical in logic to the project's service class and were discussed in detail in the previous section. The third method retrieves the raw markdown content of the article at the API endpoint specific to the slug passed in as an argument. The response type is set to 'text' to ensure that the raw markdown content is returned as a string. This service class can be used in the Article Component to parse the content and display it on the frontend in appropriately formatted markdown.
Components
Components in Angular build four critical parts of the application: the typescript class, the html file, the scss file, and a test file. This article focuses mostly on the typescript class and the html file. These classes work together to build the functionality of the application's views. In the case of the projects page of this website, there are two components that need to be constructed: the Projects Component and the Article Component. The Projects Component is responsible for fetching projects from the backend and displaying them in a card view. The Article Component is responsible for fetching an article from the backend and displaying it in a markdown view.
Projects Component Class
Now that the services classes have been coded with the necessary methods to retrieve the data packets from the backend, the next step is to write the component classes for the Projects and Article models. These typescript and html component classes will implement the service classes to fetch the data packets from the backend and display them on the frontend in a card view and a markdown view, respectively.
To generate the class ProjectsComponent, run the following CLI command in the root directory of the project:
ng generate component components/projects
This creates the class ProjectsComponent in the src/app/components/. The Projects Component is responsible for fetching projects from the backend and displaying them in a card view. A minimal component class that can retrieve the data packets from the backend is illustrated in the following code:
import { Component, OnInit } from '@angular/core';
import { ProjectService } from '../../services/project.service';
import { Project } from '../../models/project.dto';
import { Router } from '@angular/router';
@Component({
selector: 'app-projects',
standalone: false,
templateUrl: './projects.component.html',
styleUrl: './projects.component.scss'
})
export class ProjectsComponent implements OnInit {
projects: Project[] = [];
constructor(private projectService: ProjectService, private router: Router) {}
ngOnInit(): void {
this.projectService.getProjects().subscribe({
next: (projects) => {
// this.projects = projects;
this.projects = projects.map(project => ({
...project,
category: project.category ? this.capitalize(project.category) : null
}));
// console.dir(projects, { depth: null });
},
error: (error) => {
console.error('Error loading projects:', error);
}
})
}
open(parameter: {slug: string}, componentPath: string): void {
const go = () => this.router.navigate(['/', componentPath, parameter.slug]);
this.viewTransition(go);
}
viewTransition(go: () => void): void {
if ('startViewTransition' in document) {
(document as any).startViewTransition(go);
} else {
go();
}
}
capitalize(str: string | null): string {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
}
The above is the projects typescript component class that integrates the necessary methods to load and display the projects on the frontend. It contains contains five methods, one of which is most critical to the functionality of the component: ngOnInit(). This method is an Angular lifecycle hook method that is called by Angular when the component is initialized and is ready to perform tasks such as initializing data, fetching data from services, or setting up subscriptions. This method subscribes to the getProjects() method of the ProjectService class and returns the projects to the frontend. Within this subscribe method, there are two callback functions, next and error, that are called by the producer when the producer has the value during subscription. If one value, such as error(), is called, then the callback function of next() is not called. Within the next() callback function the projects are mapped to a new array and the category property is capitalized if the category contains a non-null string. This mapping function is a common pattern for data transformation in Angular applications. Within the mapping, the ...project is a spread operator that copies the properties of the project object, after which the various properties are transformed if necessary. The first letter of the string value of the category property is capitalized by the capitalize() method if it exists. Other properties can likewise be transformed within this map method in much the same way. The console.dir(projects, { depth: null }); method is used to log the projects to the console for debugging purposes. When uncommented and set to a depth of null, it presents the projects in a nested object format with all the properties and their values. When depth is set to something else, such as 1, it only presents the first level of the projects object. Within the error() callback function, the error message is logged to the console for debugging purposes. All of this done within the ngOnInit() method makes the component class functional and ready to be used in the html file of the component.
There are one other notable method that have yet to be discussed: open(parameter: {slug: string}, componentPath: string): void. This method is called within the html file of the component and are used to navigate to its respective page. The open(...) passes the project object's slug as an argument, accepts a string specific to its angular path, and sets the line const go = () => this.router.navigate(['/', componentPath, project.slug]); to navigate to a specific page associated with the path. In the case of the first project, the website set to host all subsequent projects, it leads to the home page of the website. The viewTransition(...) method is used to navigate to the respective pages with a slight transition effect. The viewTransition(...) first checks if the 'viewTransition' property exists in the document object. The document object is a global object that represents the document in the browser. Within this control flow, the code (document as any) is a type assertion that tells TypeScript to pretend the document is of any type, so that it does not check the properties that it has. Once it is any, TypeScript won't block access to viewTransition(...). The method viewTransition(...) then accepts the go(..) function as an argument, which opens the page associated with the slug that was passed in as an argument to the original open(...) methods. These can then be called within the html file of the component to navigate to the respective pages, as discussed in the next section.
projects.component.html:
<div *ngFor="let project of projects">
<div id="projects-card" (click)="open(project, 'home')"
class="bg-white rounded-xl shadow-md p-4 mt-4 cursor-pointer transition-transform duration-200 hover:scale-105 flex border-1 border-gray-100">
<!-- Left side: text (70%) -->
<div id="project-left-wrapper" class="w-9/12 pr-4 flex flex-col gap-3">
<div>
<h3 class="text-lg font-semibold line-clamp-2">
{{ project.name }}
</h3>
<p class="text-sm text-neutral-500 line-clamp-2">{{ project.category }}</p>
</div>
<p class="text-sm text-gray-500 line-clamp-3">
{{ project.description }}
</p>
</div>
<!-- Right side: image (30%) -->
<div id="project-right-wrapper" class="w-3/12 flex-shrink-0">
<img [src]="project.image" [alt]="project.name" class="w-full h-full object-contain rounded-md">
</div>
</article>
<div id="article-cards" class="flex flex-wrap items-start">
<div class="grid grid-cols-2 mb-4 mt-4 gap-x-5 gap-y-4">
<article id="article-card" *ngFor="let article of project.articles" (click)="open(article, 'articles')"
class="bg-white rounded-xl shadow-md p-2 cursor-pointer transition transform duration-200 hover:scale-105 grid grid-cols-10 gap-3 border-1 border-gray-100">
<div id="article-left-wrapper" class="col-span-6 flex flex-col gap-2 pr-1 min-w-0">
<div class="text-lg font-semibold line-clamp-2 md:line-clamp-3">
{{article.title}}</div>
<p class="text-sm text-neutral-500 line-clamp-2">{{ article.category }}</p>
</div>
<div id="article-right-wrapper" class="col-span-4">
<div class="relative w-full aspect-[4/3] rounded-md overflow-hidden">
<img [src]="article.image" [alt]="article.title"
class="absolute inset-0 w-full h-full object-contain p-1">
</div>
</div>
</article>
</div>
</div>
</div>
For the projects.component.html file, this article will focus less on the tailwind styling and more on the logic and functionality of the component. The first container uses the *ngFor Angular structural directive to iterate over a collection of projects in the components and generates all the subsequent HTML elements for each item in the collection. This includes two types of containers, both of which have a card like layout. The first is a large card view of the project application, which stems from the first div element with the id project-card, and is the parent for each of the subsequent smaller card views presented underneath the first. This first div element also contains an Event Listener signified by (click)=, which calls the open(...) method discussed in the previous section. This method calls the router module to navigate to the home page and use the components associated with the project's slug. In terms of layout, this first wrapper has two div wrappers that set the width by division, splitting the left side of the wrapper into 75% of the parent container's width with the class selector w-9/12 and the right side of the wrapper into 25% of the parent container's width with the class selector w-3/12. The flex flex-col gap-3 classes set the left side to a flex container with a column layout and a gap of 3 units between the child elements, which, in this case, are project.name, project.category, and project.description. The only other attribute selector of note within the subsequent wrappers is line-clamp-* class selectors, which are used to limit the number of lines of text in the child elements to the number of lines specified by the class selector. Once that limit is reached, the text is truncated and the ending is replaced with an ellipsis. The attribute selector for the right side of is set to w-3/12, or 25%. So, while the first sizing container uses 75% of the parent container's width, the second sizing container uses 25% of the parent container's width. The right side displays an image with two different class selectors are at two different levels. On the initial container-level, flex-shrink-0 tells the parent flexbox not to let the column shrink below its intrinsic width of 3/12. On the second level, the content-level, object-contain tells the image element to fit inside the box while preserving the aspect ratio. The result of these two levels' selectors working together is that the image column won't collapse and the image itself will always look correct. This is the first method used to space the content out in proportionally appeasing ways.
The subsequent containers that link to the article use a grid layout and split the view two columns starting with the div element containing the id article-cards. In the previous layout method, this might have been achieved with w-1/2 but, in this case, it is achieved by using the class selectors grid grid-cols-2. This allows for two cards to be stacked side-by-side within the container. It also allows for the contents within these cards to be further divided when it calls the subsequent class selectors grid grid-cols-10 in the article element with the id article-card. In the proceeding elements, using the class selector col-span-*, with a number replacing the asterisk, lets the contents of said element span the number of columns within the grid layout of its parent container. The first wrapper is set to col-span-6, fills the leftside of the container, and displays the title and category of the article. The second wrapper is set to col-span-4, fills the rightside of the container, and displays the image of the article. The article element used within these smaller cards contains an Event Listener signified by (click)=, which is set to the method open(...). By clicking on any of these smaller cards, the method calls the router module to navigate to the page and use the components associated with the article's slug. These article components are discussed in the section titled "Markdown Article Component".
Markdown Article Component
article.component.ts
import { Component, OnInit, Inject, PLATFORM_ID, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Article } from '../../../models/article.dto';
import { ArticleService } from '../../../services/article.service';
import { Subscription, switchMap, tap } from 'rxjs';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-article',
standalone: false,
templateUrl: './article.component.html',
styleUrls: ['./article.component.scss']
})
export class ArticleComponent implements OnInit, OnDestroy {
article: Article = {} as Article;
markdown: string = '';
error: string | null = null;
isBrowser = false;
private sub?: Subscription;
constructor(
private articleService: ArticleService,
private route: ActivatedRoute,
@Inject(PLATFORM_ID) private platformId: Object) {
this.isBrowser = isPlatformBrowser(this.platformId);
}
ngOnInit(): void {
const slug = this.route.snapshot.paramMap.get('slug');
if (!slug) return;
this.sub = this.articleService.getArticle(slug).pipe(
tap(article => (this.article = article)),
// load raw markdown text (from the backend / API)
switchMap(article => this.articleService.getArticleMarkdown(article.slug))
).subscribe({
next: (markdown) => {
this.markdown = markdown;
},
error: (error) => {
this.error = error?.message ?? `Failed to load article`;
console.error('Error loading article:', error);
},
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
}
private slugifyGitHub(text: string): string {
return text.toLowerCase().trim()
// remove punctuation / symbols GitHub ignores
.replace(/[\u2000-\u206F\u2E00-\u2E7F'"“”‘’.:,!?/\\`~@#$%^&*()+={}\[\]|;<>]/g, '')
// collapse whitespaces to dashes
.replace(/\s+/g, '-')
// collapse multiple dashes
.replace(/-+/g, '-')
}
onMarkdownReady(): void {
const heads = Array.from(document.querySelectorAll('.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6')) as HTMLElement[];
const seen = new Map<string, number>();
for (const h of heads) {
if (!h.id || !h.id.trim()){
const base = this.slugifyGitHub(h.textContent ?? '');
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
h.id = n ? `${base}-${n}` : base;
}
}
const frag = this.route.snapshot.fragment;
if (!frag) return;
// Give Prism/KaTeX one microtask to finish touching the DOM
queueMicrotask(() => {
const el = document.getElementById(frag);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
});
}
Finally, we come to the bread and butter of this markdown project, the component that displays the markdown articles: the article component. This is the culmination of all the work done up to this point and, as such, will be broken down into smaller chucks below.
import { Component, OnInit, Inject, PLATFORM_ID, OnDestroy } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
The first five components come from the Angular core library and are used to create the component class. The Component decorator is used to create the component class with the necessary properties, such as the selector, standalone, templateUrl, and styleUrls. The OnInit and OnDestroy interfaces are used to implement the lifecycle hooks of the component that are called by Angular when the component is initialized and closed or destroyed. The Inject decorator is used to inject the PLATFORM_ID service into the component. The isPlatformBrowser function from the @angular/common library works in tandem with the Inject decorator and the PLATFORM_ID service to determine if the component is running on the server or the browser. This is used to prevent SSR flicker. SSR flicker is a phenomenon where the server-side rendered HTML is not immediately replaced by the client-side rendered HTML, causing a flicker effect. Without this getting injected into the constructor, the markdown article would not be rendered on the browser.
import { ActivatedRoute } from '@angular/router';
This component comes from the Angular router library and is used to navigate to the article page which contains a ton of code that is not relevant to the article component. Here, it is used to get the slug from the route parameter used to fetch the article from the backend as well as the fragment from the route parameter that is used to scroll to a specific heading in the markdown article.
import { Article } from '../../../models/article.dto';
import { ArticleService } from '../../../services/article.service';
These were two of the components that we previous wrote and were mentioned earlier in the article. Here, they are finally being implemented in the article component. The article data object is used to store the article data properties fetched from the backend by the article service. The implementation in this article component is discussed in more detail below.
import { Subscription, switchMap, tap } from 'rxjs';
These three components come from the RxJS library, a reactive extension library developed for JavaScript but extended for use in Angular TypeScript. Theses three components are used to handle the subscription to the article service. The Subscription interface is used to create a subscription to the article service. The switchMap operator is used to switch to a new observable when the previous observable completes. In the case of our implementation, there is a pipeline of observables that are chained together to fetch the article from the backend and then the raw markdown content of the article. The tap operator is used to perform a side effect on the observable, such as storing the data in the article object.
isBrowser = false;
...
constructor(
private articleService: ArticleService,
private route: ActivatedRoute,
@Inject(PLATFORM_ID) private platformId: Object) {
this.isBrowser = isPlatformBrowser(this.platformId);
}
The constructor is used to inject the article service and the activated route into the component. The @Inject(PLATFORM_ID) private platformId: Object service is used to determine if the component is running on the server or the browser. This is used to prevent SSR flicker. The isPlatformBrowser(this.platformId) function is used to determine if the component is running on the browser. This can then be used to prevent the server from rendering fragile browser-only things like syntax highlighting, KaTeX, scrolling to headings, and so forth. Without the isBrowser variable, the server would attempt to render these fragile browser-only things, causing a flicker effect or preventing the article from being rendered at all.
private slugifyGitHub(text: string): string {
return text.toLowerCase().trim()
// remove punctuation / symbols GitHub ignores
.replace(/[\u2000-\u206F\u2E00-\u2E7F'"“”‘’.:,!?/\\`~@#$%^&*()+={}\[\]|;<>]/g, '')
// collapse whitespaces to dashes
.replace(/\s+/g, '-')
// collapse multiple dashes
.replace(/-+/g, '-')
}
onMarkdownReady(): void {
const heads = Array.from(document.querySelectorAll('.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6')) as HTMLElement[];
const seen = new Map<string, number>();
for (const h of heads) {
if (!h.id || !h.id.trim()){
const base = this.slugifyGitHub(h.textContent ?? '');
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
h.id = n ? `${base}-${n}` : base;
}
}
const frag = this.route.snapshot.fragment;
if (!frag) return;
// Give Prism/KaTeX one microtask to finish touching the DOM
queueMicrotask(() => {
const el = document.getElementById(frag);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
});
}
The last method from the component left to discuss is the onMarkdownReady(...) method. This method helps the marked-gfm-heading-id dependency work correctly by first grabbing the fragment, which is visible in the url after the #, and then scrolling to the heading that matches the fragment. This is a common pattern for scrolling to a specific heading in the markdown article. The code selects all heading elements (h1-h6) inside elements with the class prose and ensures each has a unique id. An id in html is an attribute set within an element that is used to uniquely identify an element in the html document. While the heading should have been set with the marked-gfm-heading-id dependency, some sanitation occurs that strips the ids. If a heading doesn't already have an id, it generates one by slugifying the heading text (GitHub-style) To avoid duplicates, it keeps track of previously seen slugs in a map and appends a numeric suffix when necessary. At const frag, the fragment of the url retrieved matches the id of the heading that is being scrolled to.
In this method, the queueMicrotask function is then called. Within this function, the document object, a global object that represents the document in the browser, is used to get the element with the id that matches the fragment. If the element is found, the scrollIntoView method is called to scroll to the element.
article: Article = {} as Article;
markdown: string = '';
error: string | null = null;
private sub?: Subscription;
...
ngOnInit(): void {
const slug = this.route.snapshot.paramMap.get('slug');
if (!slug) return;
this.sub = this.articleService.getArticle(slug).pipe(
tap(article => (this.article = article)),
// load raw markdown text (from the backend / API)
switchMap(article => this.articleService.getArticleMarkdown(article.slug))
).subscribe({
next: (markdown) => {
this.markdown = markdown;
// console.log(this.markdown);
console.log(this.article);
},
error: (error) => {
this.error = error?.message ?? `Failed to load article`;
console.error('Error loading article:', error);
},
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
The ngOnInit(...) method is a lifecycle hook that is called by Angular when the component is initialized. On a superficial level, it is used to fetch the article from the backend and store the data in the article object. However, the more important part of this method is the subscription to the article service. The subscription that takes place here is stored in a private variable called sub so that it can be used to unsubscribe from the subscription when the component is destroyed. This is important because the process of retrieving the raw markdown article requires two API calls, one to fetch the article from the backend and one to fetch the raw markdown content of the article. This process is also known as "callback hell" in RxJS. Callback hell occurs when the switchMap method takes the value from the outer observable's articleService.getArticle(...) call, which returns the article object with all the properties fetched from the backend, and transforms it into a new inner observable, the second .getArticleMarkdown(...) call, by passing in the article.slug as an argument. This second call inner observable is where the "callback hell" comes in. If the outer observable emits again before the inner observable has completed, the inner observable will be called again and nothing stops the previous inner subscription from running. Without saving the subscription in a private variable, there would be two subscriptions running in parallel until they complete. By saving the subscription in a private variable and integrating the ngOnDestroy(...) method, we ensure that the subscription is only called once and the previous subscription is stopped. Since subscription can be effectively stopped, we know that no callbacks will be running in parallel when the switchMap method emits the raw markdown content that gets passed back out to the next operator in the pipeline, where the markdown data is stored in the markdown object and eventually rendered on the frontend.
article.component.html
<div class="p-2">
<div *ngIf="error" class="text-red-600">{{ error }}</div>
<div id="markdown-container" *ngIf="isBrowser && markdown" appMathKatex class="
prose no-code-quotes max-w-none dark:prose-invert text-justify
prose-pre:!my-10 prose-pre:rounded-lg prose-pre:w-11/12 prose-pre:!mx-auto prose-pre:!shadow-sm
prose-code:!text-gray-800 max-md:prose-code:!text-xs line-numbers
prose-img:block prose-img:mx-auto prose-img:w-1/3 md:prose-img:w-1/2 prose-img:h-auto
prose-headings:text-shadow-md prose-headings:text-center prose-headings:p-4
prose-h4:underline prose-h4:text-left prose-h4:pl-0 prose-h4:!text-xl prose-h4:!font-semibold
max-md:[&_pre.line-numbers_.line-numbers-rows]:hidden max-md:[&_pre.line-numbers]:!pl-4
... (omitted for brevity) ...
">
<markdown [data]="markdown" (ready)="onMarkdownReady()"></markdown>
</div>
</div>
The above is the first part of the article.component.html file that defines the Angular template responsible for displaying individual articles within the application. The crux of this file is the markdown directive and the container div with the id markdown-container that surrounds it. The markdown directive is used to display the raw markdown content of the article with the support of an event emitter that calls the onMarkDownReady() method written in the article.component.ts file. The container div with the markdown-container id contains seven lines that are used to help stylize the prose elements within the markdown article. The first line applies the Tailwind Typography (prose) for consistent text styling, removes quotations around inline code (no-code-quotes), disables maximum width constraints (max-w-none), enables a dark mode theme inversion (dark:prose-invert, which might not have an effect on certain browsers), and justifies the text (text-justify). The second line targets the code blocks and applies a slight padding to the top and bottom margins (!my-10), rounds the corners of the code blocks (rounded-lg), sets the width of the code blocks to 11/12ths the size of the container (w-11/12), centers the code blocks (!mx-auto), and adds a shadow to the code blocks (!shadow-sm). The third line sets the text color to gray-800 (!text-gray-800) for code blocks (prose-code), the text size to xs (!text-xs) on small screens (max-md:), and adds line numbers to the code blocks (line-numbers). The fourth line targets the images inside the prose contained in the markdown file, ensuring block-level display (block), centered alignment (mx-auto), responsive width (html: w-1/3 on small screens and w-1/2 on medium+ screens), and auto-scaling of height (h-auto). The fifth line applies medium text shadows (text-shadow-md), centers headings (text-center), and adds padding (p-4) to all heading elements. The sixth line specifically styles the h4 heading with underlines (underline), left alignment (text-left), removes left-padding (pl-0), sets the text size to the xl (!text-xl), and sets the font weight to semibold (!font-semibold). The seventh line uses tailwinds arbitrary selectors on the line numbers ([&_pre.line-numbers_.line-numbers-rows]) to hide (hidden) them when they appear in the code blocks (:hidden)on screens that are smaller than 48rem (max-d). Together, the selectors et all establish a structured, styled, and interactive interface for rendering articles, balancing error handling, browser detection, and enhanced formatting features.
The next part of the article.component.html file sets the styling for the table of contents used under the initial header, the article title, at the top of the markdown page. This table of contents integrates the marked-gfm-heading-id dependency to scroll to the correct heading when a link is clicked.
...
<div *ngIf="isBrowser && markdown" appMathKatex class="
... (omitted for brevity) ...
[&_h1+ul]:text-left [&_h1+ul]:rounded-xl [&_h1+ul]:border [&_h1+ul]:border-neutral-200
[&_h1+ul]:w-fit [&_h1+ul]:mx-auto [&_h1+ul]:my-5 [&_h1+ul]:!px-10 [&_h1+ul]:!py-5 [&_h1+ul]:bg-[rgba(244,242,240)] [&_h1+ul]:shadow-sm
[&_h1+ul]:list-[upper-roman] [&_h1+ul]:!list-inside [&_h1+ul]:marker:!text-blue-800
[&_h1+ul_a]:!no-underline [&_h1+ul_a]:!text-blue-800
[&_h1+ul_a:hover]:!underline [&_h1+ul_a:hover]:!text-blue-400
">
<markdown [data]="markdown" (ready)="onMarkdownReady()"></markdown>
</div>
There are three chunks of class selectors in this part of the styling that use tailwind arbitrary selectors to stylize the an informal table of contents page. When the links are clicked, Angular applies the marked-gfm-heading-id dependency to scroll to the correct heading. In the markdown article themselves, this table of contents is placed under the first heading, identified by the # and the * tags or delimiters, which are equivalent to the h1 and ul elements in html. Markdown also uses a [ ](...) syntax to create links to other headings in the article, where the text within the square brackets is the link text and the text within the parentheses is the link destination. This syntax is equivalent to the a element in html. As such, when using tailwind's arbitrary selectors to stylize the table of contents part of the markdown file, the arbitrary variant used is [&_h1+ul]:. This tells tailwind, "when this element has an h1 immediately followed by a ul inside it, target that ul element." This arbitrary selector is used to stylize the layout of the table of contents, the marker used next to the table of contents, and the links within the table of contents. For the layout, the first chunk applies a left alignment (text-left), rounds the corners of the table of contents (rounded-xl), and adds a border (border) and with a neutral tone (border-neutral-200). It also sets the width of the table of contents to fit the content (w-fit), centers the table of contents (mx-auto), adds a margin (my-5), adds padding (!px-10 and !py-5), adds a small shadow (shadow-sm), and adds a background color (bg-[rgba(244,242,240)]), which is the same background color of the code blocks. The second chunk applies uppercase roman numerals (list-[upper-roman]) to the list, renders the list inside the content box (list-inside), and applies a blue-800 text color (!text-blue-800) to the marker (:marker:). For the links, the third chunk applies a no underline (!no-underline), a blue-800 text color (!text-blue-800), and, when hovering (:hover) over the text with the cursor, it underlines the text (!underline) and lightens the text color to a blue-400 (!text-blue-400). Together, these three chunks of Tailwind arbitrary selectors transform the generated list beneath the first heading into a well-styled, functional table of contents that both matches the article’s design language and provides clear, accessible navigation for readers.
Fixing Scrolling and Sanitization Behavior
At the present moment, the marked-gfm-heading-id dependency does not work correctly with default HTML sanitization. In order to get the scrolling to work correctly, in app.module.ts, we had to set the security context to SecurityContext.NONE, as illustrated below.
@NgModule({
... (omitted for brevity) ...
imports: [
...
MarkdownModule.forRoot({
sanitize: SecurityContext.NONE,
...
Setting the security context to none leaves the site open to vulnerabilities, such as cross-site scripting (XSS) or injection attacks. While the prior section mentioned creating one's own HTML sanitization rules, an easier solution is searching the raw HTML document for elements matching a CSS selector string and converting them text inside the selector to the scrollable ids that get stripped by the default HTML sanitization. So, the first step is re-enabling the default HTML sanitization, as illustrated below.
@NgModule({
... (omitted for brevity) ...
imports: [
...
MarkdownModule.forRoot({
sanitize: SecurityContext.HTML,
...
As explained, this ruins the scrolling but all is not lost. Previously, in article.component.ts, we created a custom method onMarkdownReady(...) that contains a constant heads that retrieved all the headings, including the text between the element selectors, in the markdown article. By creating another method that transposes that text into ids that are compatible with the gfmHeadingId module from the marked-gfm-heading-id dependency package, we can use the heads constant to get the scrolling to work correctly. The code block below illustrates the new method.
private slugifyGitHub(text: string): string {
return text.toLowerCase().trim()
.replace(/[\u2000-\u206F\u2E00-\u2E7F'"“”‘’.:,!?/\\`~@#$%^&*()+={}\[\]|;<>]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
}
onMarkdownReady(): void {
const heads = Array.from(document.querySelectorAll('.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6')) as HTMLElement[];
const seen = new Map<string, number>();
for (const h of heads) {
if (!h.id || !h.id.trim()){
const base = this.slugifyGitHub(h.textContent ?? '');
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
h.id = n ? `${base}-${n}` : base;
}
}
...
In the above code block, the slugifyGitHub(...) method is used to transposes the text into new ids. The first replacement removes punctuation and symbols that GitHub ignores. The second replacement collapses whitespaces to dashes. The third replacement collapses multiple dashes. The result turns text such as "Dependencies" into "dependencies", "Adding a Plugin to Tailwind" into "adding-a-plugin-to-tailwind", "tailwind.config.js" into "tailwindconfigjs", and so forth. These are all then set as the id of the heading element in the line h.id = n ? `${base}-${n}` : base;. By re-enabling default HTML sanitization and programmatically restoring safe GitHub-style IDs after render, we preserve protection against XSS while still allowing reliable fragment-based scrolling within markdown articles.
Summary
Integrating markdown into an Angular frontend requires more than simply rendering text; it involves orchestrating a collection of dependencies, extensions, and directives that work together to create a seamless reading experience. By installing and configuring libraries such as ngx-markdown, PrismJS, and KaTeX, and complementing them with custom extensions like inlineLangCodespan and directives like MathKatexDirective, we ensure that articles are displayed with accurate formatting, clear syntax highlighting, and proper mathematical rendering. Service classes and DTOs provide a structured pathway for fetching and managing data from the backend, while components such as ProjectsComponent and ArticleComponent tie that logic into user-facing views. Styling decisions, from Tailwind’s typography plugin to custom SCSS rules, refine the presentation and usability of rendered markdown content. Finally, by addressing challenges such as safe sanitization and reliable fragment scrolling, the implementation balances security with functionality. Together, these steps form a robust and extensible foundation for integrating rich markdown articles into an Angular-based portfolio site.