DEV Community

Warren Jitsing
Warren Jitsing

Posted on

Vanilla HTML, CSS, JavaScript Markdown, Syntax Highlighting, Mathjax Article Viewer

GitHub: https://github.com/InfiniteConsult/0003_html_css_javascript_article_viewer/


Introduction

Writing deeply technical content for general-purpose platforms often feels like a compromise. Code snippets become unreadable blocks of poorly formatted text, complex mathematical equations are impossible to typeset, and the overall presentation can lack the clarity that the subject matter deserves. These limitations aren't just an inconvenience; they are a barrier to effective communication.

This article rejects that compromise. Our goal is to build a high-quality, self-hosted, single-page application from first principles, giving us complete control over the reading experience. We will construct a professional-grade article viewer that renders Markdown, code, and mathematics exactly as they were intended to be seen.

To achieve this, we will use a carefully selected, modern toolchain. For the backend, we'll use FastAPI, a high-performance Python framework perfect for building robust and efficient APIs. For the frontend, we will use vanilla HTML, CSS, and JavaScript. This framework-free approach will allow us to create a lean, fast, and universally understandable user interface while focusing on the fundamental building blocks of the web.

Why Not Use an Existing Tool?

Before building a system from scratch, it's worth asking if a tool already exists for the job. The closest solutions are static site generators, and for a Python developer, the most well-known is Sphinx.

While Sphinx is the gold standard for Python project documentation, it's a heavyweight tool designed primarily for reStructuredText (rST). For our purposes, it presents a few challenges: rendering from Markdown requires extensions like MyST, and configuring beautiful math typesetting can be complex. Other excellent tools like MkDocs, Jekyll, or the incredibly fast Hugo are Markdown-native but still introduce their own build steps, plugins, and thematic abstractions.

The goal of this article is not just to create a viewer, but to understand how such a system works from first principles. By building our own, we gain complete control and deconstruct the fundamental mechanics of a modern, API-driven web application, a skill that is far more valuable than learning the configuration for any single tool.

Foundations

The Anatomy of a Modern Web Application

Before we write a single line of code, it's essential to understand the architectural patterns that govern modern web applications. Our viewer is built on three fundamental concepts: the client-server model, the single-page application, and the core technologies of the frontend.

The Client-Server Model

At its heart, our application uses a client-server model. The easiest way to visualize this is with an analogy:

  • The backend (our FastAPI server) is the librarian. It sits in a vast library, managing the entire collection of books (the articles). The librarian doesn't care how the books are read; its sole job is to protect the collection, know where every book is, and provide a specific one when an authorized request is made.
  • The frontend (our browser application) is the reading room. It has the tables, chairs, and lights—the entire user interface—but it has no books of its own. To show something to the user, it must send a request to the librarian.

This separation is powerful. It allows the backend to focus purely on data management and business logic, while the frontend is free to focus entirely on creating the best possible user experience.

The Single-Page Application (SPA)

In a traditional website, clicking a link causes the browser to request an entirely new HTML page from the server, resulting in a full, disruptive page reload. Our viewer, however, is a Single-Page Application (SPA).

This means the user loads a single, lightweight HTML "shell" only once. From that point on, JavaScript takes over. When you click on a different article, the JavaScript code intercepts the action. Instead of requesting a whole new page, it makes a quiet, background data request to our backend API. When the new content arrives, the JavaScript dynamically rewrites the relevant parts of the current page. The result is a fast, fluid experience with no jarring reloads, feeling more like a native desktop application than a traditional website.

The Frontend Trinity: HTML, CSS, and JavaScript

The entire user interface—our "reading room"—is constructed from three core technologies. The best way to understand their distinct roles is with the "House Analogy."

HTML: The Structure & Framing

HTML (HyperText Markup Language) is the blueprint and the physical structure of the house. It defines all the essential components and their hierarchy: the foundation, the walls that form the rooms, the ceilings, the doorways, and the window openings. It’s the raw, unadorned structure—the nouns of the house. Without it, there's nothing to see or interact with.

CSS: The Presentation & Styling

CSS (Cascading Style Sheets) is the interior and exterior design. It’s the paint on the walls, the type of flooring, the style of the window frames, the furniture, the light fixtures, and the landscaping outside. It makes the structure visually appealing but doesn't change what the structure is. You can completely redecorate (change the CSS) without knocking down any walls (changing the HTML).

JavaScript: The Interactivity & Utilities

JavaScript is the electricity, plumbing, and appliances. It’s what makes the house functional and interactive. It’s the light switches, the running water, the garage door opener, the security system, and the dishwasher. It allows the inhabitants to do things that change the state of the house—opening doors, turning on lights, and making things happen in response to an action. It's the verbs that bring the house to life.

Digging Deeper into the Frontend

HTML: The Vocabulary of Structure

HTML tags are the vocabulary we use to describe the structure and meaning of a document. If HTML is the blueprint for our house, these tags are the specific labels for components like "wall," "window," and "door." They give the browser the instructions it needs to assemble the page correctly.

The Document Shell

Every HTML document has a fundamental structure.

  • <html>: The root of the entire document. It's the plot of land upon which our entire house is built.
  • <body>: This tag contains all the content that is visible to the user—the text, images, and links. In our house analogy, this is the entire physical house that sits on the plot of land.

Metadata and External Resources

These tags typically live inside a <head> tag (which is implicitly present) and provide information about the page or link to external files. They are like the house's blueprints, address, and utility connections—essential for the house to function but not part of the visible structure itself.

  • <meta>: Provides metadata about the page, like its character encoding (charset="UTF-8") and how it should be displayed on mobile devices (viewport).
  • <title>: Sets the title of the browser tab. It's the name on the mailbox.
  • <link>: Connects external resources. In our project, we use it exclusively to link our CSS stylesheet—the "interior design" plans for our house.
  • <script>: Loads and executes scripts. We use this to bring in our "electricity and plumbing"—the external JavaScript libraries (marked.js, highlight.js) and our own app.js.

Semantic Sections

These tags define the major, meaningful regions of our user interface, much like naming the rooms in a house.

  • <nav>: Represents a section dedicated to navigation. In our viewer, it's the sidebar containing the list of articles. It's the "hallway" that connects to all the other rooms.
  • <main>: Represents the primary, dominant content of the page. For us, this is the area where the rendered article is displayed. It's the "living room" of our house.

Text and Content Structuring

These are the block-level tags used to organize the text and content that the user reads.

  • <h1>, <h2>, <h3>: Heading tags. They create a document outline and a visual hierarchy, acting as the chapter titles and section headings of a book.
  • <p>: The paragraph tag, used for standard blocks of text.
  • <ul>, <ol>, and <li>: Used to create lists. <ul> is an unordered list (bullet points), while <ol> is an ordered list (numbers). In both cases, <li> represents a single list item.
  • <hr>: A horizontal rule, used to create a thematic break or a dividing line between sections of content.

Code and Preformatted Text

These tags are specialized for displaying source code.

  • <code>: An inline tag that semantically marks a piece of text as being a snippet of computer code.
  • <pre>: A block-level tag that represents preformatted text. The browser will render the content inside it exactly as it is written, preserving all whitespace, line breaks, and indentation. The combination <pre><code>...</code></pre> is the standard way to display a block of code.

Generic Containers

Sometimes, we need to group elements for styling or layout purposes without implying any specific meaning.

  • <div>: The generic block-level container. It's an "empty box" used to group larger sections of content. Our two-column layout is created by placing the <nav> and <main> elements inside a parent <div>.
  • <span>: The generic inline container. It's used to wrap a small piece of text within a larger block (like a paragraph) to apply specific styling, without creating a line break. It's like using a highlighter on a single word in a sentence.

CSS: The Language of Presentation

If HTML provides the structure of our house, CSS provides the interior and exterior design. It's a set of rules that tells the browser how every element—from the largest <div> to the smallest <span>—should look. A CSS rule has two main parts: a selector to target an element, and a set of declarations (properties and their values) to style it.

Selectors: Targeting the Right Elements

Before you can style something, you must select it. We've used simple selectors like tag names (body), IDs (#content), and classes (.container), but CSS also provides powerful "pseudo-selectors" that target elements based on their state or position.

  • :hover (Pseudo-class): This applies styles only when the user's cursor is over an element. It's the primary way to create interactive feedback, like changing a link's color when you mouse over it.

    a:hover {
        color: var(--link-hover-color);
    }
    
  • :nth-child(n) (Pseudo-selector): This selects elements based on their order within a parent. It's incredibly powerful for styling lists or tables without adding extra classes. For example, you could style every even-numbered list item:

    li:nth-child(even) {
        background-color: #333;
    }
    

The Box Model: The Foundation of All Layout

Every single element on a webpage is a rectangular box. The CSS Box Model is the rule that governs how the size of that box and the space around it are calculated. Understanding this is the key to mastering CSS layout.

Imagine a framed picture on a wall. The box is made of four layers, from the inside out:

  1. Content: The actual picture (or text/image). Its size is controlled by width and height.
  2. padding: The transparent space between the content and its border. This is the matting inside the picture frame. We use properties like padding, padding-top, padding-left, etc.
  3. border: A line that goes around the padding. This is the physical picture frame itself. We use properties like border, border-left, border-radius, etc.
  4. margin: The transparent space outside the border. This is what pushes other elements away. It's the space between this picture frame and the other frames on the wall. We use properties like margin, margin-top, etc.

Custom Properties (CSS Variables)

CSS Variables are a modern feature that allows us to define reusable values, which is essential for creating clean and maintainable stylesheets. In our style.css, we define them in the :root selector, making them globally available.

  • Defining a variable: You use a double-dash prefix, like --bg-color: #282c34;.
  • Using a variable: You use the var() function, like background-color: var(--bg-color);.

The primary benefit is theming. If we want to change the entire site's color scheme, we only need to edit the values in the :root block, and the changes will apply everywhere the variables are used.

Typography: Styling the Text

Typography is the art of arranging type to make written language legible, readable, and appealing when displayed. These CSS properties give you full control over the appearance of your text.

Core Font Properties

These properties define the fundamental characteristics of the font itself.

  • font-family: Sets the typeface for the text (e.g., Arial, Times New Roman). It's best practice to provide a comma-separated list, known as a "font stack." The browser will try the first font, and if it's not available, it will fall back to the next one in the list.
  • font-size: Controls the size of the text, typically set in pixels (px), ems (em), or rems (rem).
  • font-weight: Controls the thickness of the font characters. Common values are normal, bold, or numeric values like 400 (normal) and 700 (bold).
  • font-style: Used to set text to italic or oblique.
  • color: Sets the color of the text itself.

Paragraph and Spacing Properties

These properties control the arrangement and spacing of text within its container.

  • line-height: Controls the vertical distance between lines of text. A value of around 1.5 or 1.6 (meaning 1.5 times the font size) is generally considered optimal for readability.
  • text-align: Sets the horizontal alignment of text. Common values are left, right, center, and justify.
  • letter-spacing: Adjusts the space between individual characters.
  • word-spacing: Adjusts the space between whole words.
  • text-indent: Indents the first line of text in a block element.

Text Decoration and Transformation

These properties apply decorative effects to the text.

  • text-decoration: Adds a decorative line to text. It's most commonly used for underline, but can also be overline or line-through.
  • text-transform: Changes the capitalization of text without altering the source HTML. Values include uppercase, lowercase, and capitalize.

Layout and Positioning: Arranging the Boxes

These properties are the tools of the architect, used to define the floor plan of our webpage. They control how elements flow, where they are placed, and how they interact with each other.

The display Property

This is the most fundamental layout property. It dictates how an element should behave and how it interacts with the elements around it. The most common values are:

  • block: The element starts on a new line and takes up the full width available. Our <div>, <p>, and <h1> tags are block-level by default.
  • inline: The element sits within the flow of text and does not start on a new line. <span>, <a>, and <code> are inline elements.
  • grid: This turns an element into a grid container, allowing you to create complex, two-dimensional layouts. We use this on our main .container to create the sidebar and content areas.
  • flex: Another powerful value that turns an element into a "flexbox" container, designed for creating flexible, one-dimensional layouts.

CSS Grid

We use CSS Grid for our main page layout. When you set display: grid on a container, you unlock a suite of properties to control its children.

  • grid-template-columns: This property defines the number and width of the columns in the grid. In our stylesheet, grid-template-columns: 280px 1fr; creates two columns: the first is a fixed 280px wide, and the second (1fr) takes up the remaining "one fraction" of the available space.

The position Property

By default, all elements are position: static, meaning they sit in the normal document flow. The position property allows you to change this behavior.

  • relative: The element is positioned relative to its normal position. You can then use top, left, etc., to shift it without affecting the layout of other elements.
  • absolute: The element is completely removed from the normal flow and is positioned relative to its nearest positioned ancestor. This is the key to creating overlays, tooltips, and pop-ups.
  • fixed: The element is positioned relative to the browser viewport. It will stay in the same place even when the user scrolls the page, which is perfect for fixed headers or "Back to Top" buttons.
  • top, right, bottom, left: These properties are used to specify the exact coordinates for any element with a position other than static.
  • z-index: When elements overlap, z-index controls their stacking order (which one is on top). A higher z-index value brings an element closer to the viewer. It only works on positioned elements.

Visibility

  • visibility: Controls whether an element is visible. The value hidden makes the element invisible, but it still occupies its space in the layout. This is different from display: none, which completely removes the element from the page.

Historical Context: Older Layout Methods

Before Grid and Flexbox, layouts were built with clever workarounds. You will still encounter these in older code.

  • float: With values left or right, this property was used to push an element to one side of its container, allowing text and other elements to wrap around it. It was the primary method for creating columns for many years.
  • clear: This property was used to control the behavior of floats, forcing an element to appear "clear" of (below) any floated elements above it.

Visual Effects and Advanced Topics

These properties control the finer details of an element's appearance, interactivity, and how the browser should handle it.

Backgrounds, Borders, and Shadows

These properties style the "surface" of an element's box.

  • background-color: Sets a solid background color for an element.
  • background: A shorthand property that allows you to set all background styles (like background-color, background-image, etc.) in a single declaration.
  • border-radius: Used to round the corners of an element's border. A value of 50% on a square element will turn it into a circle.
  • box-shadow: Adds shadow effects around an element's frame, which can be used to create a sense of depth or to highlight an element.

Overflow and Clipping

These properties control what happens when an element's content is larger than its container.

  • overflow: Defines the behavior for overflowing content. The main values are:
    • visible: The default. The content overflows the box and is visible.
    • hidden: The content is clipped, and the overflowing part is invisible.
    • scroll: The content is clipped, and scrollbars are always visible, allowing the user to see the rest of the content.
    • auto: The browser decides. Scrollbars are only added if the content actually overflows.
  • overflow-x and overflow-y: Allow you to control overflow behavior independently for the horizontal and vertical axes.

Transitions and Animations

  • transition: Applies a smooth animation to a property change over a given duration. For example, instead of a link's color changing instantly on hover, a transition: color 0.2s; will make it fade smoothly between the two colors over 0.2 seconds.

Interactivity and User Experience

  • cursor: Controls the appearance of the mouse pointer when it is over an element. Common values include pointer (a hand), text (an I-beam), and wait (a loading icon).
  • user-select: Controls whether the user can select or highlight the text in an element. Setting it to none can be useful for interactive UI elements.
  • outline: An outline is a line drawn outside an element's border. It is most often used to show that an element (like a button or link) has keyboard focus, which is a critical accessibility feature.

A Note on Vendor Prefixes

There are several properties with prefixes that you may notice from inspection of the rendered webpage code like -webkit-, -moz-, and -ms-. These are known as vendor prefixes.

Years ago, browser makers (like Chrome/Safari using WebKit, Mozilla using Gecko) used these prefixes to implement experimental CSS features before they were officially standardized. For example, to use border-radius, you once had to write:

-webkit-border-radius: 10px;
-moz-border-radius: 10px;
border-radius: 10px;
Enter fullscreen mode Exit fullscreen mode

For modern web development, vendor prefixes are rarely needed. Properties like border-radius, box-shadow, and transition have long been standardized and are supported prefix-free in all current browsers. They are important to recognize when working on older codebases, but you should avoid using them for new projects unless you are using a truly cutting-edge, experimental feature.

JavaScript: Adding Interactivity

If HTML provides the structure and CSS the styling, JavaScript provides the interactivity. It is the engine that transforms our static document into a dynamic, responsive application. It handles user input, fetches data from our server, and updates the page in real-time.

While the JavaScript language is vast, our approach will be practical and focused. Rather than creating an exhaustive reference, the following five parts will deconstruct the specific concepts used to build our article viewer. We will journey from the fundamental grammar of the language to the powerful asynchronous patterns that define modern web applications, giving you a complete, first-principles understanding of how our code works.

Part 1: JavaScript First Principles: The Core Language

Before we can make a webpage interactive, we must first understand the fundamental grammar and building blocks of the JavaScript language itself. This section covers the core components required to write any basic script, focusing on how we define variables, what types of data they can hold, and how we can use functions and logic to manipulate them.

Variables and Constants

A variable is a named container for storing a value that can be used and changed throughout a program. In modern JavaScript, we primarily use const to declare variables.

  • const: Declares a constant, which is a variable whose value cannot be reassigned after it is first defined. This is a best practice for values that we don't intend to change, as it prevents accidental modifications and makes the code's intent clearer. In our app.js, we use it to store references to our HTML elements, which will not change:

    const sidebar = document.getElementById('article-list');
    
Data Types

Every value in JavaScript has a type. The basic "primitive" types include:

  • string: For text, enclosed in quotes (e.g., 'Hello World').
  • number: For all numbers, including integers and decimals (e.g., 42, 3.14).
  • boolean: Represents a logical value of either true or false. The typeof operator can be used to check a variable's type.
Objects and Arrays: Storing Collections of Data
  • Objects: An object is a collection of related data stored in key: value pairs. It's like a dictionary or a file cabinet drawer. In our code, each article's data is an object.

    // A conceptual article object
    {
      slug: "0001_basic_fastapi",
      title: "Basic Fastapi"
    }
    

    We access the data inside an object using dot notation, such as article.slug.

  • Arrays: An array is an ordered list of values. The list of articles we receive from our API is an array.

    // An array of article objects
    [ {slug: "...", title: "\"...\"}, {slug: \"...\", title: \"...\"} ]"
    
Control Flow: Making Decisions

Control flow statements allow us to make decisions in our code.

  • if statements: The most common control flow statement. It executes a block of code only if a specified condition evaluates to true. In our viewer, we use it to check if a slug was provided before trying to load an article:

    if (!slug) {
        // If no slug exists, show a message and stop.
        content.innerHTML = '<p>Select an article...</p>';
        return;
    }
    
Functions: Reusable Blocks of Code

A function is a named, reusable set of instructions designed to perform a specific task. You define a function once and can then "call" or "invoke" it as many times as needed. Our app.js is built around two main functions: loadArticleList() and loadArticleContent().

  • The return statement: This statement ends the execution of a function. As seen in the if statement example above, return; is used to exit the loadArticleContent function early if no slug is provided.
Scope: Where Variables Live

Scope determines the accessibility of variables. In JavaScript, variables declared inside a function are generally "local" to that function and cannot be accessed from the outside. This is a crucial feature that prevents different parts of a program from accidentally interfering with each other. For example, the articles variable inside our loadArticleList function is only accessible within that function.

Part 2: JavaScript in the Browser: The DOM and Events

Now that we understand the core grammar of JavaScript, we can explore how it breathes life into a static HTML file. This is achieved by interacting with a live representation of the webpage called the Document Object Model (DOM) and by reacting to user actions through events.

The Document Object Model (DOM)

When a browser loads your HTML file, it doesn't just display it as static text. It creates an in-memory, tree-like structure that represents the document. This live, interactive model is the DOM. Every HTML tag, attribute, and piece of text becomes an "object" that JavaScript can access and manipulate. This is the fundamental mechanism that allows us to dynamically change what the user sees without ever reloading the page.

Selecting and Manipulating Elements

To make a page dynamic, our script first needs to get a reference to the HTML elements it wants to change.

  • document.getElementById(): The most direct way to select a single element is by its unique id attribute. In our app.js, we use this to get a handle on our main layout components:

    const sidebar = document.getElementById('article-list');
    const content = document.getElementById('content');
    
  • document.createElement(): This creates a new HTML element object in memory, which is not yet visible on the page. We use this to build our list items for the sidebar: const li = document.createElement('li');.

  • .textContent vs. .innerHTML: These properties control the content inside an element.

    • .textContent sets or gets only the raw text. It is a safe and efficient way to change text.
    • .innerHTML sets or gets the full HTML structure within an element. We use this to render the article content returned by marked.parse().
  • .appendChild(): This method takes an element we created in memory and attaches it as a new child to an existing element in the DOM, making it visible on the page. We use this to add each new list item to our sidebar.

Storing Data on Elements

Sometimes we need to associate data with an element for our script to use later.

  • .href: This is a standard property of an anchor (<a>) tag that gets or sets the URL it points to. We set this to a hash fragment like href = '#0001_basic_fastapi'.
  • .dataset: This provides access to custom data-* attributes in HTML. It is a clean, modern way to store data directly on an element. We use it to keep a reference to an article's slug for our click handler.
Event Handling: Reacting to the User

A dynamic webpage must respond to user actions. These actions—like mouse clicks, key presses, or the page finishing its initial load—are called events.

  • addEventListener(): This is the primary method for listening for events on an element. It takes the name of the event (e.g., 'click' or 'DOMContentLoaded') and a "callback function" to execute when that event occurs.
  • event.preventDefault(): When an event occurs, the browser often has a default action. For a click on a link (<a>), the default action is to navigate to the href URL. event.preventDefault() is a command we use inside our event listener to stop this default browser behavior, allowing our script to take full control and handle the navigation dynamically.

Part 3: Modern Web Applications: Asynchronous JavaScript and APIs

The defining feature of a modern web application is its ability to communicate with a server in the background to fetch or send data without interrupting the user. This is made possible by JavaScript's asynchronous capabilities. Understanding this concept is the key to building fast, responsive applications.

Asynchronous by Nature

JavaScript in the browser runs on a single thread. If it were to execute tasks synchronously, a long-running operation like a network request would freeze the entire user interface until it completed. The user wouldn't be able to click, scroll, or interact with the page.

To prevent this, JavaScript is asynchronous and non-blocking. It can start a time-consuming task (like fetching an article), and while it waits for the result, it can continue to handle other tasks, like responding to user input.

Fetching Data with Promises

The modern browser API for making network requests is fetch(). When you call fetch(), it does not immediately return the data. Instead, it returns a Promise.

A Promise is a placeholder object representing the eventual result of an asynchronous operation. It's an "IOU" from the browser that says, "I've started your request, and I promise to give you a value back in the future." A Promise can be in one of three states: pending (the operation hasn't finished), fulfilled (it succeeded), or rejected (it failed).

  • fetch(): Initiates a network request and returns a Promise that resolves to a Response object.
  • response.ok: A simple boolean property on the Response object that is true if the HTTP status code was successful (e.g., 200 OK).
  • .json() and .text(): These methods are used to read the body of the response. They are also asynchronous and return a new Promise that resolves with the content parsed as either JSON or plain text.
async/await: Writing Readable Asynchronous Code

While you can work with Promises directly, modern JavaScript provides a much cleaner syntax called async/await.

  • async: Placing this keyword before a function declaration allows that function to use await.
  • await: This keyword can be placed before any expression that returns a Promise. It pauses the execution of the async function until the Promise is fulfilled and "unwraps" the value, allowing you to write asynchronous code that looks and reads like synchronous code. Our loadArticleList function is a perfect example:

    async function loadArticleList() {
        const response = await fetch('/api/articles');
        const articles = await response.json();
        // ...
    }
    

Without async/await, this would require more complex, nested code.

Graceful Error Handling

Things can go wrong, especially with network requests. async/await allows us to handle these errors using the standard try...catch block.

  • try...catch: You wrap the code that might fail in a try block. If an error occurs (e.g., the network is down, or the server returns an error), the code execution jumps to the catch block, where you can handle the error gracefully instead of crashing the application.
  • throw new Error(): We can manually trigger an error. In our code, if (!response.ok) we throw a new Error, which will immediately be caught by our catch block.
  • console.error(): This is the standard way to log error messages to the browser's developer console, which is an invaluable tool for debugging.

Part 4: Writing Clean Code: Modern ES6+ Syntax and Idioms

The previous sections covered the core mechanics of how JavaScript works. This section focuses on the modern syntax—often called "syntactic sugar"—that allows us to write that same logic in a cleaner, more expressive, and more efficient way. These features were introduced in a major update to the language called ES6 (ECMAScript 2015) and are now standard in all modern browsers.

Template Literals

Template literals provide an enhanced way to create strings, using backticks (`) instead of single or double quotes. Their main advantage is string interpolation.

This allows you to embed variables and expressions directly into a string using the ${...} syntax. This is much cleaner than traditional string concatenation with the + operator. We use this in our error handling:

// The modern way with template literals
throw new Error(`HTTP error! Status: ${response.status}`);

// The old way with concatenation
throw new Error("HTTP error! Status: " + response.status);
Enter fullscreen mode Exit fullscreen mode
Arrow Functions

Arrow functions (=>) offer a more concise syntax for writing function expressions. They are especially useful for short, anonymous functions, such as the callbacks used in event listeners or array methods.

Consider the event listener in our app.js:

// The modern way with an arrow function
a.addEventListener('click', (event) => {
    event.preventDefault();
    loadArticleContent(article.slug);
});

// The old way with a traditional function expression
a.addEventListener('click', function(event) {
    event.preventDefault();
    loadArticleContent(article.slug);
});
Enter fullscreen mode Exit fullscreen mode

The arrow function is more compact and is the standard in modern JavaScript codebases.

The Ternary Operator

The ternary operator is a compact, inline if/else statement. It takes three operands: a condition, an expression to execute if the condition is true, and an expression to execute if the condition is false.

The syntax is: condition ? expressionIfTrue : expressionIfFalse.

While not used in our app.js, it is common in the libraries we depend on and is useful for simple conditional assignments. For example:

// Using an if/else statement
let message;
if (isLoggedIn) {
  message = "Welcome back";
} else {
  message = "Please log in";
}

// Using the equivalent ternary operator
let message = isLoggedIn ? "Welcome back" : "Please log in";
Enter fullscreen mode Exit fullscreen mode
Modern Array Methods

Instead of using traditional for loops to iterate over arrays, modern JavaScript provides a powerful set of built-in methods that are more declarative and readable.

  • .forEach(): This method executes a provided function once for each element in an array. It is a clean way to loop over an array when you want to perform an action for each item but do not need to create a new array. We use this to build our sidebar navigation:

    articles.forEach(article => {
        // Create and append an <li> for each article
    });
    

Other common methods include .map() (which creates a new array by transforming each element) and .filter() (which creates a new array containing only the elements that pass a certain test).

Part 5: Under the Hood: How Professional Libraries are Built

Our application's ability to render Markdown, code, and math is powered by sophisticated third-party libraries. By briefly examining the patterns used to build these tools, we can gain insight into how robust, portable, and professional JavaScript is written.

Leveraging the Ecosystem: Using Third-Party Libraries

A core principle of modern development is to not "reinvent the wheel." Complex, solved problems like parsing Markdown are best handled by specialized, community-vetted tools. In our code, we leverage this principle by delegating tasks to these libraries:

content.innerHTML = marked.parse(markdown); // Handles Markdown parsing
hljs.highlightAll();                       // Handles syntax highlighting
MathJax.typeset();                         // Handles math typesetting
Enter fullscreen mode Exit fullscreen mode

Knowing when to build from scratch and when to use a well-tested library is a critical skill for any developer.

Code Organization and Portability

When you include multiple scripts on a webpage, they all share the same "global namespace." If two different libraries create a variable with the same name, they can conflict and break the application. Professional libraries use several patterns to prevent this.

  • IIFE (Immediately Invoked Function Expression): Most libraries wrap their entire code in a special function that they immediately execute, like (function() { ... })();. This pattern creates a private scope for all the library's variables, preventing them from "leaking" out and polluting the global namespace.
  • UMD (Universal Module Definition): A library needs to work in different environments (like the browser, or in a backend Node.js server). The UMD pattern is a set of if/else checks that allows the code to detect which module system is being used (e.g., CommonJS, AMD, or none) and export its functionality in the correct way for that environment.
  • "use strict";: This is a directive placed at the top of a script that enables a stricter set of rules for the JavaScript interpreter. It helps prevent common coding errors by changing "silent errors" into thrown errors and disallowing the use of some unsafe language features.
Regular Expressions: The Language of Pattern Matching

Regular Expressions (or RegExp) are a powerful mini-language for finding and manipulating patterns in text. Libraries like marked.js are powered by complex regular expressions that can identify the syntax for headings, lists, links, code blocks, and other Markdown features. They can be created with a literal syntax (/pattern/) or a constructor (new RegExp()).

Advanced and Safe Object Handling

Libraries must be written defensively to handle any kind of input without crashing.

  • Object.prototype.hasOwnProperty.call(obj, 'prop'): This is the safest way to check if an object truly has its own property. It is used instead of the simpler obj.hasOwnProperty() to protect against edge cases where an object might have been created with a conflicting property of the same name.
  • Object.defineProperty(): This method gives a developer precise control over the properties of an object. It can be used to create read-only properties, prevent properties from being deleted, or hide them from loops, which is essential for creating robust APIs.

Digging Deeper into the Backend

Our previous article on FastAPI established the first principles of building high-performance APIs. In this section, we will apply those core concepts to build the backend for our article viewer.

We will demonstrate how to use foundational features like the lifespan handler for startup logic and then expand on that knowledge to tackle new, practical challenges, such as discovering content from the filesystem and serving a complete frontend application with Jinja2 templates.

Part 1: Architecting the Application: Serving a Full Frontend

A modern web application server must often do more than just respond to data queries with JSON. It also needs to deliver the client-side application itself—the initial HTML document, the CSS stylesheets, and the JavaScript logic. This section covers the high-level FastAPI features that allow our server to act as both a data API and a web server.

Serving Static Assets with StaticFiles

Our frontend application consists of several "static" files that do not change, such as style.css and app.js. The browser needs a way to request these files. The most efficient way to handle this in FastAPI is to "mount" an entire directory of files to a specific URL path.

# In server.py
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
Enter fullscreen mode Exit fullscreen mode

This single line of code instructs FastAPI to handle any incoming request whose URL begins with /static. The request is passed to a special StaticFiles application, which knows how to securely find and serve the corresponding file from the STATIC_DIR on our filesystem.

Serving the HTML Shell with Jinja2Templates

In addition to assets, we must serve the main index.html file, which is the entry point for our single-page application. While we could use a simple FileResponse, a more powerful and professional pattern is to use a templating engine.

# In server.py
templates = Jinja2Templates(directory=TEMPLATES_DIR)

@app.get("/")
async def serve_frontend(request: Request):
    """Serves the main index.html single-page application."""
    return templates.TemplateResponse({"request": request}, "index.html")
Enter fullscreen mode Exit fullscreen mode

While our current index.html is a simple static file, using a templating engine like Jinja2 is a deliberate choice for future-proofing. It establishes a pattern that would allow us to easily inject dynamic, server-generated data into our HTML if the application's requirements were to grow. The TemplateResponse renders the specified file and returns it as a standard HTML response.

Managing Application State with app.state

Our server needs to know which articles are available, but scanning the filesystem on every single API request would be extremely inefficient. The ideal solution is to scan the filesystem once when the server starts and then store that list of articles in memory.

FastAPI provides a clean, dedicated object for this purpose: app.state.

# From the lifespan function in server.py
@asynccontextmanager
async def lifespan(app: FastAPI):
    # ... logic to scan filesystem and build 'articles' list ...
    app.state.articles_catalog = {article["slug"]: article for article in articles}
    yield
Enter fullscreen mode Exit fullscreen mode

The app.state object is a simple namespace that persists for the entire lifecycle of the application. By attaching our articles_catalog to it during the startup phase of the lifespan handler, we make this data instantly and efficiently available to all subsequent API requests without any performance penalty.

Part 2: Advanced Routing and Responses

With the overall application structure in place, we can now zoom in on the specific techniques used within our API endpoints. These features allow us to handle more complex URL structures and provide the browser with precise information about the data being served, which are hallmarks of a robust API.

Capturing Complex URL Paths with Path Converters

A standard URL path parameter in FastAPI, like {param}, will match any text up to the next slash (/). This presents a problem for our static file endpoint, as a file could be nested in a subdirectory (e.g., images/diagram.png).

To solve this, FastAPI supports path converters.

# In server.py
@app.get("/articles/{article_slug}/static/{file_path:path}")
Enter fullscreen mode Exit fullscreen mode

By adding :path after the parameter name (file_path:path), we instruct FastAPI to match the entire remaining URL segment, including any slashes it may contain. This allows our endpoint to correctly capture the full relative path to any static file, no matter how deeply it is nested.

Accessing the Request Object

While endpoints primarily deal with processing and returning data, they sometimes need access to information about the incoming HTTP request itself, such as its headers or the client's IP address. FastAPI provides this through the Request object.

# In server.py
async def serve_frontend(request: Request):
    return templates.TemplateResponse({"request": request}, "index.html")
Enter fullscreen mode Exit fullscreen mode

By adding a parameter to our endpoint and type-hinting it as request: Request, FastAPI will automatically "inject" the full request object into that parameter. In our case, this is a requirement for the Jinja2 templating engine, which needs the request context to function correctly.

Controlling Response Headers with media_type

Every HTTP response includes a Content-Type header (also known as a MIME type). This header is the "label on the box" that tells the browser what kind of data it is receiving (e.g., text/html, image/png, application/json).

While FastAPI is excellent at inferring the correct Content-Type, sometimes we need to be explicit. When we serve our raw README.md files, we want to ensure the browser interprets them as plain text intended as Markdown. FileResponse allows us to set this header directly.

# In server.py
return FileResponse(file_path, media_type="text/markdown; charset=utf-8")
Enter fullscreen mode Exit fullscreen mode

The media_type argument gives us precise control over the response headers. This ensures that clients, browsers, and proxies all handle the data correctly, which is a fundamental aspect of building a well-behaved and predictable web server.

Part 3: Pythonic Power-Ups

Beyond the web framework, the elegance of our backend comes from leveraging modern Python features and its powerful standard library. This section highlights a few of these "Pythonic" patterns that help us write clean, readable, and efficient code.

Advanced Filesystem Navigation with pathlib

Instead of working with filesystem paths as simple strings, modern Python encourages the use of the pathlib library, which provides an object-oriented interface for paths.

# In server.py
HOME_DIR = Path.home()
# ...
for item in sorted(ARTICLES_DIR.iterdir()):
    # ...
Enter fullscreen mode Exit fullscreen mode

Two key features are used here:

  • Path.home(): A clean, cross-platform method to get the path to the current user's home directory.
  • .iterdir(): An elegant way to iterate over all items within a directory. It returns Path objects, which we can then easily inspect with methods like .is_dir() and .is_file().

Powerful Text Manipulation with the re Module

For complex text processing, Python's built-in re module provides access to regular expressions. We use this in our format_title function to create clean titles from directory names.

# In server.py
cleaned_slug = re.sub(r"^\d+_", "", slug)
Enter fullscreen mode Exit fullscreen mode

The function re.sub(pattern, replacement, string) finds all substrings that match the pattern and replaces them. The pattern r"^\d+_" is a regular expression that means:

  • ^: Match at the beginning of the string.
  • \d+: Match one or more digits (0-9).
  • _: Match a literal underscore. This pattern finds and removes the 0001_ prefix from our article slugs.

Elegant Data Transformation with List Comprehensions

List comprehensions are a concise and highly readable way to create a list based on an existing iterable. They are a hallmark of idiomatic Python code. We use one to prepare our article index for the API response.

# In server.py
return [
    {"slug": data["slug"], "title": data["title"]}
    for data in app.state.articles_catalog.values()
]
Enter fullscreen mode Exit fullscreen mode

This single line of code iterates through all the article data stored in our application state and builds a new, cleaned-up list of dictionaries. This is a much more compact and expressive alternative to a traditional for loop.

Safe Attribute Access with hasattr

hasattr() is a defensive programming function that checks if an object has a given attribute before you try to access it, preventing potential AttributeError exceptions.

# In server.py
if not hasattr(app.state, "articles_catalog") or not app.state.articles_catalog:
    return []
Enter fullscreen mode Exit fullscreen mode

In our get_articles_index endpoint, we use hasattr() to safely check that the lifespan function has successfully created the articles_catalog on our app.state object. This makes the code more robust against potential startup issues.


The Blueprint: A Dual-Component Architecture

Before implementing our application, we'll first create its architectural blueprint. This is a high-level plan that defines the responsibilities of each component and the contract for how they will communicate.

The core of our design is a decoupled architecture. The backend server will act as a standalone data provider, and the frontend will be a standalone data consumer, responsible for all presentation logic. This separation of concerns is a fundamental principle that makes the application clean, scalable, and easy to maintain.

Part I: The Backend Blueprint (The Librarian)

The FastAPI server's primary job is to find content on the filesystem and expose it to the world through a well-defined API.

  • Dynamic Content Discovery: The server will not have a hardcoded list of articles. Instead, on startup (using the lifespan handler), it will dynamically scan the filesystem for any directories that match the 0xxx_<name> pattern and contain a README.md file. The metadata for these articles will be loaded into an in-memory "catalog" for high-performance access.

  • The API Contract: The server will provide three crucial endpoints:

    1. Article Index (GET /api/articles): This endpoint will return a JSON list of all discovered articles, containing only the slug and title for each.
    2. Article Content (GET /api/articles/{slug}): This endpoint will return the raw, unmodified Markdown content for a specific article.
    3. Article-Specific Assets (GET /articles/{slug}/static/{path}): This endpoint is our solution for handling relative image paths. It will serve static files (like images) from within a specific article's own static sub-directory.
  • The Frontend Serving Role: In addition to its data API, the backend is also responsible for serving the initial index.html shell and the core static assets (app.js, style.css) that make up the frontend application.

Part II: The Frontend Blueprint (The Reading Room)

The client-side application's primary job is to provide the user interface, fetch data from the backend, and render it for the user.

  • The Application Shell: The frontend is a Single-Page Application (SPA) built from a single, static index.html file. This file provides the basic layout structure—a sidebar and a main content area—and uses <script> tags to load all necessary third-party libraries and our own application logic.

  • Dynamic Navigation: On page load, the frontend will first make an API call to the GET /api/articles endpoint. It will then use the returned JSON data to dynamically build the navigation links in the sidebar. This ensures the viewer automatically updates whenever new articles are added to the repository.

  • The Rendering Pipeline: When a user selects an article, the frontend will execute a precise, four-step rendering pipeline:

    1. Fetch: Make an API call to GET /api/articles/{slug} to retrieve the raw Markdown text.
    2. Parse: Pass the Markdown string to the marked.js library to convert it into an HTML string. We will configure marked.js to correctly resolve relative image and link paths using our dedicated asset endpoint.
    3. Inject: Place the generated HTML into the main content area of the page.
    4. Enhance: After the new content is in the DOM, make two final calls: first to highlight.js to apply syntax highlighting to all code blocks, and second to MathJax to find and typeset any mathematical formulas.

Environment Setup

Before we dive into the implementation, we must prepare a clean, isolated Python environment for our viewer application. All of the following commands should be run from a terminal inside the running Docker container.

The goal is to use Python's built-in venv module to create a self-contained environment. This ensures that the packages we install for this project will not conflict with system-wide packages or other projects you may be working on inside the container.

1. Define Project Dependencies

First, we will list all the required Python packages in a requirements.txt file. This file allows us to install all dependencies with a single command and ensures a reproducible setup.

Action: Create the file articles/0003_html_css_javascript_article_viewer/requirements.txt with the following content.

fastapi
uvicorn
httpx
pytest
pytest-cov
jinja2
python-multipart
pytest-playwright
Enter fullscreen mode Exit fullscreen mode

2. Create and Activate the Virtual Environment

We will create our virtual environment inside the articles/0003_html_css_javascript_article_viewer directory using the custom-built Python 3.12 that exists in our container.

Action: Navigate to the articles/0003_html_css_javascript_article_viewer directory and run the following commands.

# Navigate to the live application's directory
cd articles/0003_html_css_javascript_article_viewer

# Create a virtual environment named .venv using our custom Python 3.12
python3.12 -m venv .venv

# Activate the environment. Your shell prompt should now change.
source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

3. Install Python Dependencies

With the virtual environment active, we can now install all the packages from our requirements.txt file.

Action: Run the following command.

python3 -m pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

A Note on python3 -m pip: Using python3 -m pip is a robust and explicit way to run pip. It guarantees that we are using the pip executable associated with the python3 interpreter from our currently active virtual environment, which prevents common issues related to system path configurations.

4. Install Playwright Browsers and Dependencies

The pytest-playwright package requires a final setup step to download the browser binaries (Chromium, Firefox, WebKit) and their system-level dependencies. We can encapsulate this in a simple shell script.

Action: Create the file playwright-install.sh and then run it.

#!/usr/bin/env bash
. .venv/bin/activate
playwright install-deps
playwright install
Enter fullscreen mode Exit fullscreen mode

Now, make the script executable and run it:

chmod +x playwright-install.sh
bash playwright-install.sh
Enter fullscreen mode Exit fullscreen mode

This script will first activate our virtual environment, then run playwright install-deps (which may ask for your sudo password to install system libraries), and finally run playwright install to download the browser binaries.

With our isolated environment fully configured and all dependencies installed, we are now ready to proceed with the implementation.

Implementation

With our architectural blueprint and environment complete, we can now translate it into functional code. This section will walk through the implementation of each file, connecting the code directly back to the design decisions we made. We will build the application from the backend to the frontend.

Part I: The FastAPI Backend (server.py)

Imports and Configuration

Our entire backend logic is contained within a single file, server.py. We will begin by examining its foundational components: the necessary imports, path configurations, and helper functions that make the application aware of its environment.

import re
import uvicorn
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

# --- Configuration ---
# All paths are relative to the user's home directory inside the container.
HOME_DIR = Path.home()
ARTICLES_DIR = HOME_DIR / "articles"
VIEWER_DIR = HOME_DIR / "articles" / "0003_html_css_javascript_article_viewer"
DATA_DIR = HOME_DIR / "data"

TEMPLATES_DIR = VIEWER_DIR / "templates"
STATIC_DIR = VIEWER_DIR / "static"
CERT_FILE = DATA_DIR / "cert.pem"
KEY_FILE = DATA_DIR / "key.pem"


def format_title(slug: str) -> str:
    """Converts a directory slug into a human-readable title."""
    # Remove leading digits and underscores, replace underscores with spaces, and title case.
    # e.g., "0001_basic_fastapi" -> "Basic Fastapi"
    cleaned_slug = re.sub(r"^\d+_", "", slug)
    return cleaned_slug.replace("_", " ").title()
Enter fullscreen mode Exit fullscreen mode

Deconstruction: Imports and Configuration

  • Imports: We begin by importing all the necessary tools. From Python's standard library, we import re for regular expressions and pathlib for an object-oriented way of handling filesystem paths. From our installed packages, we import FastAPI itself, along with specific classes for handling responses (FileResponse), static files, and templates (Jinja2Templates).
  • Path Constants: We define a set of global constants for all important directory paths. Using the pathlib library instead of simple strings is a modern best practice that makes path manipulation safer and more readable. Path.home() provides a reliable way to get the current user's home directory, which serves as the root for all our operations inside the container.
  • The format_title Helper: This utility function is a practical example of text manipulation. It takes a directory name like 0001_basic_fastapi and uses a regular expression (re.sub(r"^\d+_", "", slug)) to strip the leading numbers and underscore. It then replaces the remaining underscores with spaces and title-cases the result to produce a clean, human-readable title like "Basic Fastapi".

Lifespan

We will now examine the lifespan handler, which is the heart of the server's content discovery mechanism.

This function implements the "Dynamic Content Discovery" from our blueprint. Its purpose is to scan the filesystem once when the server starts, creating an in-memory catalog of all available articles. This is far more efficient than re-scanning the disk every time a user makes a request.

@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    On startup, scan the articles directory and build a catalog of available articles.
    This catalog is stored in the application's state for quick access.
    """
    print("Server is starting up, building article catalog...")
    articles = []
    if ARTICLES_DIR.is_dir():
        for item in sorted(ARTICLES_DIR.iterdir()):
            if item.is_dir() and (item / "README.md").is_file():
                slug = item.name
                articles.append({
                    "slug": slug,
                    "title": format_title(slug),
                    "path": item / "README.md"
                })
    app.state.articles_catalog = {article["slug"]: article for article in articles}
    print(f"Found {len(articles)} articles.")
    yield
    print("Server is shutting down.")


# --- FastAPI Application ---
app = FastAPI(lifespan=lifespan)
Enter fullscreen mode Exit fullscreen mode

Deconstruction: The lifespan Handler

A lifespan handler is a special function that FastAPI executes during its startup and shutdown sequence.

  • @asynccontextmanager: This decorator from Python's standard library turns our function into an "asynchronous context manager," which is the required format for a lifespan handler.
  • Startup Logic: All the code before the yield statement is executed once when the server starts. Our logic here iterates through the ARTICLES_DIR, validates that each item is a directory containing a README.md file, and builds a list of dictionaries containing the article metadata.
  • Storing the Catalog: The crucial line is app.state.articles_catalog = .... This takes our list of articles and stores it in the app.state object, a shared space where we can hold data for the entire lifetime of the application.
  • The yield Statement: This is where the lifespan handler pauses. The server is now running and will process requests until it receives a shutdown signal.
  • Shutdown Logic: All the code after the yield statement is executed once when the server is shutting down. While we only have a print statement, this is the correct place for cleanup logic, such as closing database connections.
  • Connecting to the App: Finally, we pass our function to the FastAPI constructor: app = FastAPI(lifespan=lifespan). This tells FastAPI to use our handler to manage its lifecycle.

Application and API Endpoints

With the article catalog loaded into app.state by the lifespan handler, we can now define the application's core logic. This involves two primary responsibilities: serving the frontend application itself (the HTML, CSS, and JavaScript files) and exposing the data API that the frontend will use to fetch article content.

Serving the Frontend Application

First, we must configure the server to deliver the static files that constitute our single-page application.

# Mount the static directory to serve CSS and JavaScript files.
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

# Configure Jinja2 for HTML templating.
templates = Jinja2Templates(directory=TEMPLATES_DIR)


# --- Frontend Serving ---
@app.get("/")
async def serve_frontend(request: Request):
    """Serves the main index.html single-page application."""
    return templates.TemplateResponse({"request": request}, "index.html")
Enter fullscreen mode Exit fullscreen mode
Deconstruction: Serving the Frontend
  • app.mount(): This operation attaches a self-contained application to a specific URL path. Here, we mount a StaticFiles instance to the /static path. This tells FastAPI that any request starting with /static should be handled by serving a file directly from our STATIC_DIR on the filesystem.
  • Jinja2Templates: This line initializes the Jinja2 templating engine, telling it where to find our HTML files.
  • @app.get("/"): This is the root endpoint for our application. When a user navigates to the base URL, this function is called. It uses templates.TemplateResponse to render our index.html file and send it to the browser. This single file is the entire shell for our application.

The Data API

Next, we define the data endpoints that our JavaScript frontend will call to get the article index and content.

# --- API Endpoints ---
@app.get("/api/articles")
async def get_articles_index():
    """Returns a list of all discovered articles (slug and title)."""
    if not hasattr(app.state, "articles_catalog") or not app.state.articles_catalog:
        return []
    # Return a list of dicts, excluding the server-side file path.
    return [
        {"slug": data["slug"], "title": data["title"]}
        for data in app.state.articles_catalog.values()
    ]


@app.get("/articles/{article_slug}/static/{file_path:path}")
async def serve_article_static(article_slug: str, file_path: str):
    """Serves a static file from within a specific article's directory."""
    if article_slug not in app.state.articles_catalog:
        raise HTTPException(status_code=404, detail="Article not found")

    # Construct the full path to the static file
    article_dir = app.state.articles_catalog[article_slug]["path"].parent
    static_file_path = article_dir / "static" / file_path

    if not static_file_path.is_file():
        raise HTTPException(status_code=404, detail="Static file not found")

    return FileResponse(static_file_path)


@app.get("/api/articles/{article_slug}")
async def get_article_content(article_slug: str):
    """Returns the raw Markdown content of a specific article."""
    if article_slug not in app.state.articles_catalog:
        raise HTTPException(status_code=404, detail="Article not found")

    file_path = app.state.articles_catalog[article_slug]["path"]
    return FileResponse(file_path, media_type="text/markdown; charset=utf-8")
Enter fullscreen mode Exit fullscreen mode
Deconstruction: The Data API
  • /api/articles: This endpoint serves as the index. It reads the article catalog from app.state, uses a list comprehension to format it into a clean list of slugs and titles, and returns it as a JSON response.
  • /api/articles/{article_slug}: This endpoint retrieves the content for a single article. It looks up the provided article_slug in our in-memory catalog to find the path to the README.md file and then uses FileResponse to efficiently stream that file's content to the client.
  • /articles/{article_slug}/static/{file_path:path}: This is the endpoint that solves our relative image path problem. It takes both an article_slug and a file_path (which, thanks to the :path converter, can contain slashes) to construct the exact location of a static asset within an article's subdirectory and serves it.

The Main Entry Point

The final piece of our server.py file is a special block that allows the application to be run directly as a script. This makes our server self-contained and easy to launch.

# --- Main Entry Point ---
if __name__ == "__main__":
    """
    Allows the server to be run with `python3 server.py`.
    Configures Uvicorn to use the shared TLS certificates.
    """
    uvicorn.run(
        "server:app",
        host="0.0.0.0",
        port=8889,  # Matches the port exposed in the Dockerfile
        reload=True,
        ssl_keyfile=str(KEY_FILE),
        ssl_certfile=str(CERT_FILE),
    )
Enter fullscreen mode Exit fullscreen mode

Deconstruction: The __main__ Block

  • if __name__ == "__main__":: This is a standard Python construct. The code inside this if block will only run when the script is executed directly from the command line (e.g., python3 server.py). It will not run if the script is imported as a module into another file, such as our test suite.

  • uvicorn.run(...): This function programmatically starts the Uvicorn ASGI server, which is responsible for running our FastAPI application. We provide it with several key configuration arguments:

    • "server:app": This string tells Uvicorn how to find our application. It means: "in the file named server.py, find the instance of the FastAPI application named app."
    • host="0.0.0.0": This is a critical setting for running inside a Docker container. It tells the server to listen for connections on all available network interfaces, which is necessary for the port mapping from the host machine to reach the container.
    • reload=True: This is a convenience feature for development. It tells Uvicorn to monitor the source files for changes and automatically restart the server when a file is saved.
    • ssl_keyfile and ssl_certfile: These arguments enable HTTPS by pointing Uvicorn to the TLS certificate and private key files that our entrypoint.sh script generates.

This concludes the deconstruction of our backend server. We now have a complete understanding of how it discovers content, serves a data API, and delivers the frontend application.

Part II: The Vanilla JS Frontend

The frontend is responsible for everything the user sees and interacts with. It's the "reading room" of our application, built from three core files: index.html for structure, style.css for presentation, and app.js for interactivity.

The HTML Shell (index.html)

The index.html file is the skeleton of our single-page application. Its primary role is not to contain the article content itself, but to provide the basic page structure and to load all the CSS and JavaScript assets that will bring the application to life.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Article Viewer</title>

    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/marked-base-url"></script>

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
    <script>
        MathJax = { /* ... configuration ... */ };
    </script>
    <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>

    <link rel="stylesheet" href="/static/style.css">
    <script src="/static/app.js" defer></script>
</head>
<body>
    <div class="container">
        <nav id="sidebar">
            <h1>Articles</h1>
            <ul id="article-list">
                </ul>
        </nav>
        <main id="content">
            <p>Select an article from the sidebar to begin reading.</p>
        </main>
    </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Deconstruction: The HTML Shell

  • The <head> Section: This section is the "control panel" for our page. It contains metadata and, most importantly, links to all the external resources our application needs.
    • <link> Tags: These pull in our stylesheets. We load the atom-one-dark.min.css theme for our code blocks, followed by our own style.css for the main layout.
    • <script> Tags: These load the JavaScript. The order is critical: the third-party libraries (marked, highlight.js, MathJax, etc.) must be loaded before our own app.js script, which depends on them. The defer attribute on app.js tells the browser to wait until the HTML document is fully parsed before executing our script.
  • The <body> Section: The body defines the visible structure. It is kept minimal and semantic.
    • <nav id="sidebar"> and <main id="content">: We define two primary containers for our layout. Their id attributes are the crucial "hooks" that our app.js script will use to select these elements and dynamically inject content into them. They begin with simple placeholder text, which will be replaced as soon as our JavaScript runs.

The CSS Styling (style.css)

The style.css file is the "interior design" for our HTML structure. Its purpose is to control the layout, colors, and typography, transforming the raw HTML into a clean, readable, and polished user interface. Our stylesheet is built on a few key, modern CSS principles.

1. Theming with CSS Variables

At the top of the file, we define all our recurring colors and fonts inside a :root block.

:root {
    --bg-color: #282c34;
    --sidebar-bg: #21252b;
    --text-color: #abb2bf;
    --header-color: #ffffff;
    --link-color: #61afef;
    --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

This is a powerful feature known as CSS Custom Properties, or variables. By defining these values once and reusing them throughout the stylesheet (e.g., color: var(--text-color);), we make our application's theme incredibly easy to manage. To change the entire color scheme, we only need to edit the values in this single location.

2. Two-Column Layout with CSS Grid

The core of our page layout is achieved with a simple but powerful CSS Grid definition.

.container {
    display: grid;
    grid-template-columns: 280px 1fr;
    height: 100vh;
}
Enter fullscreen mode Exit fullscreen mode

By setting display: grid on our main container, we unlock the two-dimensional layout capabilities of CSS Grid. The grid-template-columns property defines our two columns: the first is a fixed 280px wide (for the sidebar), and the second (1fr) is a flexible unit that automatically takes up the remaining "one fraction" of the available space.

3. Handling Overflow and Scrolling

A crucial detail for a good user experience is managing content that is longer than the screen.

#sidebar {
    overflow-y: auto;
}
#content {
    overflow-y: auto;
}
Enter fullscreen mode Exit fullscreen mode

By setting overflow-y: auto on both our sidebar and main content areas, we ensure that if either the navigation list or the article content becomes too long, it will get its own independent, vertical scrollbar without breaking the overall page layout.

4. Responsive Content

To prevent large images from breaking our layout, we apply a simple, global rule for all images within the content area.

#content img {
    max-width: 100%;
    height: auto;
}
Enter fullscreen mode Exit fullscreen mode

This rule ensures that an image will never be wider than its container. If a large image is loaded, it will automatically scale down to fit, while height: auto maintains its original aspect ratio.

The JavaScript Brain (app.js)

This file contains all the client-side logic that transforms our static HTML shell into a dynamic application. It is responsible for fetching data from our backend API, manipulating the DOM to display that data, and responding to user interaction.

Initialization and Event Listeners

The script begins by setting up a primary event listener that waits for the entire HTML document to be loaded and parsed before any of our code runs.

document.addEventListener('DOMContentLoaded', () => {
    const sidebar = document.getElementById('article-list');
    const content = document.getElementById('content');

    // ... all functions are defined here ...

    // --- Initial Load ---
    loadArticleList();

    const initialSlug = window.location.hash.substring(1);
    if (initialSlug) {
        loadArticleContent(initialSlug);
    }
});
Enter fullscreen mode Exit fullscreen mode
Deconstruction: Initialization
  • DOMContentLoaded: By wrapping our code in this event listener, we ensure that we don't try to access elements like #sidebar before they are available, which prevents errors.
  • Initial Calls: Once the page is ready, the script immediately calls loadArticleList() to populate the navigation menu. It then checks if the URL has a hash fragment (e.g., #0001_basic_fastapi) and, if so, calls loadArticleContent() to support deeplinking.

Building the Navigation (loadArticleList)

This function is responsible for communicating with our server to discover which articles are available and then building the sidebar navigation.

async function loadArticleList() {
    try {
        const response = await fetch('/api/articles');
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status}`);
        }
        const articles = await response.json();

        sidebar.innerHTML = ''; // Clear any existing content
        articles.forEach(article => {
            const li = document.createElement('li');
            const a = document.createElement('a');
            a.href = `#${article.slug}`;
            a.textContent = article.title;

            a.addEventListener('click', (event) => {
                event.preventDefault();
                window.location.hash = article.slug;
                loadArticleContent(article.slug);
            });

            li.appendChild(a);
            sidebar.appendChild(li);
        });
    } catch (error) {
        // ... error handling ...
    }
}
Enter fullscreen mode Exit fullscreen mode
Deconstruction: Building Navigation

Using async/await, the function first fetches the article index from our /api/articles endpoint and parses the json response. It then iterates over the returned array using forEach, dynamically creating an <li> and <a> element for each article. A 'click' event listener is attached to each link, which prevents the default browser navigation and instead calls our loadArticleContent function.

The Rendering Pipeline (loadArticleContent)

This function is the core of the user experience. It takes an article slug, fetches its content, and orchestrates the multi-step process of rendering it to the screen.

async function loadArticleContent(slug) {
    if (!slug) { /* ... */ return; }
    try {
        content.innerHTML = '<p>Loading...</p>';
        const response = await fetch(`/api/articles/${slug}`);
        const markdown = await response.text();

        // The Rendering Pipeline
        marked.use(markedBaseUrl.baseUrl(`/articles/${slug}/`));
        content.innerHTML = marked.parse(markdown);

        hljs.highlightAll();
        MathJax.typeset();

    } catch (error) {
        // ... error handling ...
    }
}
Enter fullscreen mode Exit fullscreen mode
Deconstruction: The Rendering Pipeline
  1. Fetch: It makes an async call to /api/articles/{slug} to get the raw Markdown text.
  2. Configure: It calls marked.use(markedBaseUrl.baseUrl(...)) to configure the renderer. This crucial step ensures that any relative image paths in the Markdown are correctly rewritten to point to our article-specific asset endpoint.
  3. Parse & Inject: It calls marked.parse(markdown) to convert the Markdown to an HTML string and immediately injects it into the main content area's innerHTML.
  4. Enhance: After the new HTML is in the DOM, it makes two final calls: hljs.highlightAll() to apply syntax highlighting to any code blocks, and MathJax.typeset() to find and render any LaTeX formulas.

This completes the walkthrough of our application's implementation. We have seen how the Python backend and vanilla JavaScript frontend work together to create a seamless user experience.

Testing and Verification

A Professional's Workflow: Why We Test

Writing code that works is only the first step. Professional software engineering requires us to ensure that the code is correct and, just as importantly, that it stays correct as we add new features or refactor it in the future. Automated testing is the practice of writing code to verify our application's code, creating a safety net that catches regressions before they reach users.

Our project employs a comprehensive, two-tiered testing strategy:

  1. Backend Unit Tests: To verify the API logic in a fast, isolated environment.
  2. Frontend End-to-End Tests: To verify the complete user experience in a real browser.

Part I: Backend Unit Testing with pytest (test_server.py)

The goal of our backend tests is to confirm that each API endpoint behaves as expected—returning the correct data for valid requests and the correct errors for invalid ones. We achieve this without needing to run a real web server or browser.

The Tools and The Challenge of Isolation

Our primary tools are pytest, a powerful and popular Python test runner, and FastAPI's built-in TestClient. The TestClient allows us to send simulated HTTP requests directly to our application in memory, which is incredibly fast and efficient.

However, this presents a challenge: our server is designed to scan a real filesystem directory (~/articles) to discover content. How can we test this logic without our tests becoming fragile and dependent on the actual articles in the repository? If we added a new article, we wouldn't want our existing tests to suddenly fail. The solution is to create a temporary, isolated "laboratory" environment for each test.

The Solution: The mock_fs Fixture

To solve the isolation problem, we use pytest's powerful fixture system. A fixture is a function that runs before each test function, providing it with data or setting up a specific state. Our mock_fs fixture creates a completely separate, temporary "laboratory" filesystem for each test to run in.

@pytest.fixture
def mock_fs(tmp_path, monkeypatch):
    """
    A pytest fixture to create a temporary, isolated filesystem for testing.
    It mocks the directory structure and patches the constants in the server
    module to use these temporary paths. This ensures tests are hermetic.
    """
    # 1. Create mock directories based on the real structure
    mock_home = tmp_path / "home" / "testuser"
    mock_articles_dir = mock_home / "articles"
    mock_viewer_dir = mock_articles_dir / "0003_html_css_javascript_article_viewer"
    mock_templates_dir = mock_viewer_dir / "templates"

    mock_articles_dir.mkdir(parents=True)
    mock_viewer_dir.mkdir()
    mock_templates_dir.mkdir()

    # 2. Monkeypatch the server's global path constants to use our mock paths
    monkeypatch.setattr(viewer_server, "HOME_DIR", mock_home)
    monkeypatch.setattr(viewer_server, "ARTICLES_DIR", mock_articles_dir)
    monkeypatch.setattr(viewer_server, "VIEWER_DIR", mock_viewer_dir)
    monkeypatch.setattr(viewer_server, "TEMPLATES_DIR", mock_templates_dir)

    # 3. Create a dummy index.html needed by the serve_frontend endpoint
    (mock_templates_dir / "index.html").write_text("<html>Hello Test</html>")

    return mock_articles_dir
Enter fullscreen mode Exit fullscreen mode

Deconstruction: How the Fixture Works

This fixture uses two powerful, built-in pytest fixtures to achieve its goal:

  1. Creating a Temporary Filesystem with tmp_path: The tmp_path fixture provides a pathlib.Path object pointing to a unique temporary directory. This directory is created before the test runs and is completely destroyed after it finishes. We use this to build a clean, predictable mock directory structure (mock_home, mock_articles_dir, etc.) from scratch.

  2. Rerouting the Server with monkeypatch: The monkeypatch fixture allows us to safely modify variables, functions, or classes during a test and automatically restores the original state afterward. The crucial line is monkeypatch.setattr(viewer_server, "ARTICLES_DIR", mock_articles_dir). This line dynamically changes the ARTICLES_DIR constant inside our server.py module, effectively "tricking" the server into looking at our temporary directory instead of the real one.

By combining these two tools, our mock_fs fixture provides every test function with a perfectly clean, isolated, and predictable environment. This makes our tests fast, reliable, and completely independent of any external state.

Deconstructing the Test Cases

With the mock_fs fixture handling the complex setup, our test functions become clean, readable, and focused. Each test follows the standard Arrange-Act-Assert pattern.

Example 1: The Success Case

This test verifies that the main /api/articles endpoint works correctly when there are valid articles on the filesystem.

def test_get_articles_index_success(mock_fs):
    """Tests that the index endpoint returns a correctly formatted list of articles."""
    # Arrange: Create some dummy articles in the mocked filesystem
    (mock_fs / "0002_second_article").mkdir()
    (mock_fs / "0002_second_article" / "README.md").touch()
    (mock_fs / "0001_first_article").mkdir()
    (mock_fs / "0001_first_article" / "README.md").touch()

    with TestClient(viewer_server.app) as client:
        # Act
        response = client.get("/api/articles")

        # Assert
        assert response.status_code == 200
        data = response.json()
        assert len(data) == 2
        assert data[0]["slug"] == "0001_first_article"
        assert data[0]["title"] == "First Article"
Enter fullscreen mode Exit fullscreen mode
Deconstruction:
  • Arrange: This is the setup phase. We take the empty mock_fs directory provided by our fixture and populate it with the specific subdirectories and dummy README.md files that this particular test requires.
  • Act: This is where we perform the action we want to test. We instantiate the TestClient and send a GET request to the /api/articles endpoint. When the TestClient starts, it triggers our server's lifespan function, which now scans our temporary, arranged directory.
  • Assert: This is the verification phase. We check that the response.status_code is 200 OK, that the returned JSON data contains two articles, and that their content (slug and title) is correctly formatted and sorted.

Example 2: The Failure Case

It is just as important to test that our application handles errors correctly. This test verifies that requesting a non-existent article returns a 404 Not Found error.

def test_get_article_content_not_found(mock_fs):
    """Tests that requesting a non-existent article slug returns a 404."""
    with TestClient(viewer_server.app) as client:
        response = client.get("/api/articles/non_existent_slug")
        assert response.status_code == 404
Enter fullscreen mode Exit fullscreen mode
Deconstruction:

This test is simpler. The Arrange step is handled entirely by the mock_fs fixture, which provides a clean slate. The Act step is to request a slug that we know does not exist. The Assert step is a single, clear check that the server responded with the correct 404 status code.

The rest of the tests in test_server.py follow these same patterns, covering every success and failure branch for each endpoint. This gives us high confidence in our backend's reliability.

Part II: Frontend End-to-End Testing with Playwright (test_e2e.py)

While our backend unit tests give us confidence in our API's logic, they don't tell us if the application actually works from a user's perspective. To verify the complete system—from the JavaScript in the browser to the Python on the server—we use End-to-End (E2E) tests.

The goal of E2E testing is to write a script that automates a real web browser to simulate a user's journey. Our tool for this is Playwright, a modern browser automation framework that we can control entirely from Python.

The Challenge of a Live Server

Unlike the TestClient which runs our app in memory, a browser-based test needs to connect to a real, live web server over the network. Our test suite needs a way to start our server before the tests run and shut it down cleanly afterward.

The Solution: The live_server_process Fixture

We solve this by creating a session-scoped pytest fixture that manages the server's lifecycle.

@pytest.fixture(scope="session")
def live_server_process():
    """
    A session-scoped fixture that starts the server in a detached tmux
    session and uses a robust polling loop to wait for it to be ready.
    """
    start_cmd = "tmux new -s test_server -d '. .venv/bin/activate && python3 server.py'"
    try:
        subprocess.run(start_cmd, shell=True, check=True, cwd="..")

        # Wait for the server to be ready with a robust polling loop.
        host, port = "127.0.0.1", 8889
        for _ in range(100):  # Poll for up to 10 seconds
            try:
                with socket.create_connection((host, port), timeout=0.1):
                    break
            except (ConnectionRefusedError, socket.timeout):
                time.sleep(0.1)
        else:
            pytest.fail(f"Server at {host}:{port} did not start within 10 seconds.")

        yield BASE_URL  # The tests run at this point

    finally:
        # Teardown: Cleanly kill the tmux session.
        kill_cmd = "tmux kill-session -t test_server"
        subprocess.run(kill_cmd, shell=True, stderr=subprocess.DEVNULL)
Enter fullscreen mode Exit fullscreen mode

Deconstruction:

  • scope="session": This decorator tells pytest to run this fixture only once for the entire test session, which is efficient.
  • subprocess and tmux: The fixture uses Python's subprocess library to execute a tmux command, which starts our server.py in a detached, background session.
  • The Polling Loop: Because the server takes a moment to start, the script immediately enters a polling loop. It repeatedly tries to open a socket connection to 127.0.0.1:8889. This loop continues until a connection is successful, proving the server is up and ready. This is a robust way to handle process startup.
  • yield and Teardown: The yield keyword passes control to the tests. The finally block guarantees that after all tests have completed, the tmux kill-session command is run to cleanly terminate the server process.

Deconstructing an E2E Test Case

With the server running, an E2E test is simply a script that gives the browser instructions.

def test_article_click_and_render(live_server_process):
    """Tests clicking an article and verifying its content is rendered."""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        context = browser.new_context(ignore_https_errors=True)
        page = context.new_page()
        page.goto(BASE_URL)

        page.get_by_role("link", name="Docker Dev Environment").click()

        content_heading = page.locator("#content h2", has_text="A Primer on Isolation")
        expect(content_heading).to_be_visible()
        context.close()
        browser.close()
Enter fullscreen mode Exit fullscreen mode

Deconstruction:

  • with sync_playwright()...: This block manages the browser lifecycle, starting it and ensuring it closes correctly.
  • page.goto(...): This command navigates the automated browser to our running server's URL.
  • page.get_by_role(...).click(): This is the core of the user simulation. Playwright finds an element just like a user would (in this case, a link with the text "Docker Dev Environment") and simulates a click.
  • expect(locator).to_be_visible(): This is the assertion. Playwright's expect function has a crucial feature: auto-waiting. It will automatically wait for a few seconds for the content to appear on the screen before checking the assertion, which makes the test extremely reliable and free of the flaky timing issues that plague older testing tools.

Conclusion

In this article, we've journeyed from a simple problem—the poor formatting of technical content on the web—to a complete, first-principles solution. We have successfully architected and built a production-grade, self-hosted article viewer, complete with a high-performance FastAPI backend, a dynamic vanilla JavaScript frontend, and a comprehensive, two-tiered automated testing suite to guarantee its reliability.

More importantly than the final application, however, is the methodology we followed. By deliberately choosing a lean stack and deconstructing each component—from the server's lifespan handler to the frontend's rendering pipeline and the isolated test fixtures—we have revealed the fundamental patterns that power modern web development. You now have not just a functional application, but a deep, practical understanding of the client-server model, API design, the SPA lifecycle, and professional verification workflows.

This blueprint is now yours to build upon. With this foundation, you have the knowledge to extend this viewer with new features, adapt its architecture for your own projects, or confidently build entirely new applications from scratch. What will you build next?

Markdown and MathJax Feature Demo

This section demonstrates the rendering capabilities of this article viewer. It includes various levels of headings, text formatting, lists, links, images, code blocks with syntax highlighting, and mathematical equations rendered with MathJax.

Text Formatting and Headings

This is a level 2 heading. You can create headings by starting a line with one or more # characters.

Level 3 Heading

Here is some standard paragraph text. Text can be formatted in various ways. For example, you can make text bold, italic, or even bold and italic. You can also use strikethrough.

Level 4 Heading

This is a blockquote. It's useful for quoting text from another source or for emphasizing a particular passage. Blockquotes can span multiple lines.

Level 5 Heading

And a final, level 6 heading is below.

Level 6 Heading

This demonstrates the full hierarchy of available headings.


Lists and Links

You can create both unordered and ordered lists.

  • This is an item in an unordered list.
    • You can nest lists by indenting.
    • This is another nested item.
  • This is a second top-level item.
  1. This is an item in an ordered list.
  2. The numbering is handled automatically.
  3. Here is a link to the official Marked.js documentation.

Images and Code

Images with relative paths are handled correctly by the server.

Inline code, like const app = FastAPI(), is rendered with a distinct background. For larger blocks of code, fenced code blocks with syntax highlighting are used.

Python Example

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def read_root():
    """A simple root endpoint."""
    return {"message": "Server is running"}
Enter fullscreen mode Exit fullscreen mode

JavaScript Example

document.addEventListener('DOMContentLoaded', () => {
    console.log('The document is ready!');
});
Enter fullscreen mode Exit fullscreen mode

Shell/Bash Example

# Update all packages and clean up
apt-get update && apt-get install -y \
    cowsay \
    && rm -rf /var/lib/apt/lists/*

cowsay "Hello World"
Enter fullscreen mode Exit fullscreen mode

C++ Example

#include <iostream>
#include <vector>
#include <string>

int main() {
    std::vector<std::string> msg {"Hello", "C++", "World", "from", "a", "container!"};
    for (const std::string& word : msg) {
        std::cout << word << " ";
    }
    std::cout << std::endl;
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Tables

Markdown tables are also supported.

Feature Status Implemented By
Markdown Rendering Complete marked.js
Syntax Highlighting Complete highlight.js
Math Typesetting Complete MathJax

Mathematical Equations with MathJax

Thanks to MathJax, we can render complex mathematical formulas written in LaTeX. This can be done inline, such as Einstein's famous equation, $E = mc^2$.

For larger, display-style equations, we can use block-level rendering. Here is the probability density function for the normal distribution:

$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} \exp\left( -\frac{(x - \mu)^2}{2\sigma^2} \right)$$

And here is the integral form of the Fourier Transform:

$$X(\omega) = \int_{-\infty}^{\infty} x(t) e^{-j\omega t} dt$$

Top comments (6)

Collapse
 
maame-codes profile image
Maame Afua A. P. Fordjour

I love the 'Librarian vs. Reading Room' analogy for the client-server model, it makes the architectural transition from traditional sites to SPAs so much easier to visualize. I'm definitely going to try and deploy this on my local Linux setup as I work through my LFS101 course. Thanks for the detailed blueprint!

Collapse
 
warren_jitsing_dd1c1d6fc6 profile image
Warren Jitsing

Awesome. If you build the FromFirstPrinciples Dockerfile and run in (maybe through windows subsystems for Linux) it should get automatically deployed and look like this

Collapse
 
maame-codes profile image
Maame Afua A. P. Fordjour

Noted 💯

Collapse
 
acessays profile image
Anna

This is super useful! I love how you combined vanilla JS with Markdown and MathJax—it keeps things lightweight but powerful. Syntax highlighting looks clean too. Definitely giving me ideas for my own article viewer projects!

Collapse
 
warren_jitsing_dd1c1d6fc6 profile image
Warren Jitsing

Thank you! Enjoy!

Collapse
 
warren_jitsing_dd1c1d6fc6 profile image
Warren Jitsing