HTML Module 1 – Getting started with HTML
Set up an editor, a local server and DevTools
Save HTML files the way servers expect, preview them over http://localhost with Python, inspect the DOM in DevTools and check pages with two validators.
What you will learn
- Create, name and save an HTML file as UTF-8 and open it in a browser
- Run Python's built-in web server to serve a folder over http://localhost
- Inspect and live-edit the DOM and the accessibility tree in browser DevTools
- Run the Nu Html Checker or html-validate on a local file
Before you start
What you’ll learn · Before you start
What you will learn
- Create, name and save an HTML file as UTF-8 and open it in a browser
- Run Python's built-in web server to serve a folder over http://localhost
- Inspect and live-edit the DOM and the accessibility tree in browser DevTools
- Run the Nu Html Checker or html-validate on a local file
Before you start
On this page
To write HTML you need a text editor to type it, a browser to look at it, and two tools that make the work much easier: a local web server, which sends your files to the browser the way a real website would, and the browser’s developer tools (DevTools), which show the page as the browser actually built it. A validator then tells you about mistakes the browser repaired without saying a word. All of these cost nothing, and most are already on your computer.
This lesson sets each one up and runs it on real pages: Python’s built-in server answering requests, a module script
that only works over http://, recorded in three browser engines, and two validators checking the same faulty page.
Write and save your first file
Any editor that saves plain text works. A code editor, such as Visual Studio Code or the one that comes with your system, adds colour, auto-completion and the closing of tags you have opened. Word processors do not work, because they save their own document format around your text.
Three habits save trouble later:
- Save as UTF-8. It is the only encoding the HTML Standard allows, and most editors use it by default; check the status bar or the “Save as” options if you are unsure. The next lesson shows what happens to text when a browser guesses the encoding wrong.
- Use lowercase names with hyphens:
index.html,about-us.html,contact.html. The path part of a web address is case-sensitive under RFC 3986, and so are the file systems of many web servers, while Windows and macOS usually ignore case. A link toAbout-Us.htmlthat works on your laptop can fail once the site is published. Spaces turn into%20in addresses, so avoid them too. - Call the main page of each folder
index.html. When a browser asks for a folder, such ashttp://localhost:8000/, web servers look for that name and send it; Python’s server does exactly that.
Make a folder called my-site, save a page in it as index.html, and double-click the file. It opens in your
browser at an address that starts with file://.
Open the file, or serve the folder
A file:// address is fine for plain HTML and CSS. Some features, though, only work on a page that comes from a
web server, because the browser’s security rules are written in terms of a page’s origin, its scheme, host and
port, and a page opened from a file has no ordinary origin. Module scripts are the first such feature most people
meet. This page loads one:
module-check/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Does the module run?</title>
<script type="module" src="app.mjs"></script>
</head>
<body>
<p id="status">The module did not run.</p>
</body>
</html> module-check/app.mjs
document.getElementById('status').textContent = 'The module ran.'; It was opened in three browser engines, once from a file:// address and once from a local server:
// Prints whether the module script of module-check/index.html ran in each engine, opened from a file:// address and
// over http://127.0.0.1 (recorded by record.mjs), and what the console said when it did not.
import { readFileSync } from 'node:fs';
const recorded = JSON.parse(readFileSync('recorded.json', 'utf8'));
const result = (id, engine) => recorded.checks.find((c) => c.id === id).results[engine];
const ran = (r) => (r.value === 'The module ran.' ? 'ran' : 'did not run');
console.log('module-check/index.html, opened two ways:\n');
console.log(`${'engine'.padEnd(26)}${'from file://'.padEnd(18)}over http://127.0.0.1`);
for (const { engine, version } of recorded.engines) {
console.log(`${`${engine} ${version}`.padEnd(26)}${ran(result('module-from-file', engine)).padEnd(18)}${ran(result('module-over-http', engine))}`);
}
for (const { engine } of recorded.engines) {
const messages = result('module-from-file', engine).console ?? [];
if (messages.length) {
console.log(`\n${engine}'s console, from file://:`);
for (const m of messages) console.log(` ${m}`);
}
} Output
module-check/index.html, opened two ways: engine from file:// over http://127.0.0.1 Chromium 153.0.8010.12 did not run ran Firefox 155.0 ran ran WebKit 21624.5.1.11.3 did not run ran Chromium's console, from file://: Access to script at 'file:///…/module-check/app.mjs' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-untrusted, data, http, https. Failed to load resource: net::ERR_FAILED
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node file_or_server.mjs
From the file, Chromium and WebKit refused to load app.mjs and Firefox ran it. Through the server, all three ran
it. A page that works in one browser from a file and fails in another is confusing, so make serving the folder your
habit from the start.
Opening a page from a file, or through a local server
Text description of the diagram
The diagram shows two ways for a browser to get the same page from a folder on your computer.
- With a file:// address, the browser reads site/index.html from the folder itself. No server is involved, so there are no HTTP headers and the page has no web origin.
- With http://localhost:8000/, the browser sends a GET request over HTTP to python3 -m http.server, which runs on your computer. The server reads index.html from the folder and sends it back with a Content-Type: text/html header, the way a real web server would.
Python comes with a small web server. If python3 --version prints a version in your terminal, you have it; on
Windows the command is usually py. Run these commands in the folder you want to serve:
# Go to the folder that holds your index.html (here a folder called my-site):
cd my-site
# Serve it on port 8000, to this computer only. On Windows, type py instead of python3.
python3 -m http.server 8000 --bind 127.0.0.1
# Now open http://localhost:8000/ in your browser. Press Ctrl+C in this terminal to stop the server. The --bind 127.0.0.1 part matters. Without it, Python’s server listens on every network your computer is
connected to, so anyone on the same café or college Wi-Fi could request your files. Python’s documentation also
warns that http.server is not meant for production, because it only implements basic security checks; use it to
preview your work, and stop it with Ctrl+C when you are done.
What does the server actually send? This program hands three requests to the same request handler that
python3 -m http.server uses, without opening a network port, and prints the start of each answer:
"""Ask Python's built-in web server for three addresses, without opening a network port.
python3 -m http.server answers each request with SimpleHTTPRequestHandler. Here the same handler answers
requests that are handed to it in memory, so we can print the status line and the headers it sends back.
The folder it serves is site/, which holds index.html and app.mjs.
"""
import http.server
import io
class InMemoryConnection:
"""Stands in for a network socket: the request is read from bytes, the response is collected."""
def __init__(self, request: bytes):
self.incoming = io.BytesIO(request)
self.outgoing = bytearray()
def makefile(self, mode, *args, **kwargs):
return self.incoming
def sendall(self, data):
self.outgoing += data
class QuietHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass # the real server prints one line per request, with the time, to the terminal
def fetch(path: str) -> None:
connection = InMemoryConnection(f"GET {path} HTTP/1.1\r\nHost: localhost:8000\r\n\r\n".encode())
QuietHandler(connection, ("127.0.0.1", 50000), None, directory="site")
head, _, body = bytes(connection.outgoing).partition(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
print(f"GET {path}")
print(" ", lines[0])
for line in lines[1:]:
name = line.split(":", 1)[0]
if name.lower() in ("content-type", "content-length"):
print(" ", line)
print(f" body: {len(body)} bytes, starts with {body[:15]!r}")
fetch("/")
fetch("/app.mjs")
fetch("/menu.html") Output
GET / HTTP/1.0 200 OK Content-type: text/html Content-Length: 175 body: 175 bytes, starts with b'<!DOCTYPE html>' GET /app.mjs HTTP/1.0 200 OK Content-type: text/javascript Content-Length: 73 body: 73 bytes, starts with b'document.queryS' GET /menu.html HTTP/1.0 404 File not found Content-Type: text/html;charset=utf-8 Content-Length: 460 body: 460 bytes, starts with b'<!DOCTYPE HTML>'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 what_the_server_sends.py
Three things in that output come up again in this track. The page is sent as text/html with no character set, so
the browser has to find the encoding in the page itself, which the next lesson covers. The .mjs file is sent as
text/javascript, and that matters: the HTML Standard runs a module script only when it arrives with a JavaScript
MIME type, so a server that sends .mjs files as plain text breaks every module. And a page that does not exist gets
the status 404 File not found, which is what your visitors see after a broken link.
Look inside the page with DevTools
Every desktop browser has developer tools. In Chrome, Edge and Firefox press F12, or Ctrl+Shift+I on Windows and Linux and Cmd+Option+I on macOS. You can also right-click any part of a page and choose Inspect. Safari hides its tools until you choose Safari, then Settings, then Advanced, and switch on “Show features for web developers”.
The tools you will use most for HTML:
- The Elements panel (Inspector in Firefox) shows the DOM, the tree the browser built, not your file. Click an element on the page to find it in the tree. You can double-click text or an attribute to change it and watch the page update; the change lives only in the open tab and is gone when you reload.
- View Source shows the text exactly as the server sent it. Right-click the page and choose View Page Source, or press Ctrl+U on Windows and Linux.
- The Console lists errors and warnings, such as the blocked module script above.
- The accessibility tree shows each element’s role and name, which is what a screen reader announces. In Chrome it is the Accessibility tab of the Elements panel (it may sit behind the “More tabs” button), with a switch that shows the whole tree; Firefox has an Accessibility panel, where an image without alt text shows up with no name.
The difference between View Source and the Elements panel is easiest to see on a page with a mistake in it. Here a list was put inside a paragraph, and the image has no alt text:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Opening hours</title>
</head>
<body>
<h1>Opening hours</h1>
<p>We are open:
<ul>
<li>Monday to Friday, 8 am to 8 pm</li>
<li>Saturday, 9 am to 2 pm</li>
</ul>
</p>
<img src="shopfront.jpg">
</body>
</html> // Prints the body that three engines built from opening-hours.html, and what two validators said about the file
// (both recorded by record.mjs).
import { readFileSync } from 'node:fs';
const recorded = JSON.parse(readFileSync('recorded.json', 'utf8'));
const bodies = Object.values(recorded.checks.find((c) => c.id === 'opening-hours').results).map((r) => r.value);
const engines = recorded.engines.map((e) => e.engine).join(', ');
console.log(`The body that ${engines} built (${new Set(bodies).size === 1 ? 'the same in all three' : 'NOT the same'}):\n`);
// Remove the indentation the lines share, and the empty lines the source had around the elements.
const lines = bodies[0].split('\n').filter((line) => line.trim());
const indent = Math.min(...lines.map((line) => line.length - line.trimStart().length));
console.log(lines.map((line) => line.slice(indent)).join('\n'));
const { versions } = recorded.validators;
console.log('\nWhat the validators report (line:column):');
for (const name of ['html-validate', 'Nu Html Checker']) {
console.log(`\n${name} ${versions[name]}`);
for (const m of recorded.validators[name]['opening-hours.html']) {
console.log(` ${`${m.line}:${m.column}`.padEnd(7)}${(m.rule ?? m.type).padEnd(19)}${m.message}`);
}
} Output
The body that Chromium, Firefox, WebKit built (the same in all three):
<h1>Opening hours</h1>
<p>We are open:
</p><ul>
<li>Monday to Friday, 8 am to 8 pm</li>
<li>Saturday, 9 am to 2 pm</li>
</ul>
<p></p>
<img src="shopfront.jpg">
What the validators report (line:column):
html-validate 11.16.1
9:6 no-implicit-close Element <p> is implicitly closed by adjacent <ul>
14:6 close-order Stray end tag '</p>'
15:6 wcag/h37 <img> is missing required "alt" attribute
Nu Html Checker 26.10.2
14:5 error No “p” element in scope but a “p” end tag seen.
15:5 error An “img” element must have an “alt” attribute, except under certain conditions. For details, consult guidance on providing text alternatives for images.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node opening_hours.mjs
A ul cannot sit inside a p, so the parser closed the paragraph as soon as the list began. The </p> that
followed the list then had no paragraph to close, and the parser turned it into a new, empty paragraph. View Source
still shows the list inside the paragraph; the Elements panel shows the repaired tree. Any CSS written for “the list
inside the paragraph” would match nothing.
Check your page with a validator
Browsers repair mistakes silently, so you need a tool that reports them. Two validators are worth knowing:
- The Nu Html Checker checks a page against the HTML Standard. Use it online at
validator.w3.org/nu, or runvnu.jaron your own files withjava -jar vnu.jar index.html; it needs Java 17 or later, and thevnu-jarpackage from npm contains the same file. - html-validate runs on Node.js: install it with
npm install --save-dev html-validateand check a file withnpm exec html-validate index.html. Its default preset,html-validate:recommended, checks the standard plus accessibility and best-practice rules, andhtml-validate:documentadds the rules that need a complete page.
In the run above, both found the stray </p> and the missing alt text, and only html-validate pointed at line 9,
where the list closed the paragraph early, which is where the mistake really is. The Nu Html Checker, the checker that runs at
validator.w3.org/nu, follows the conformance rules of the standard closely, so running both catches the most. You can also check a page
in your browser:
Test on a phone
StatCounter’s monthly figures for India put mobile phones at about three page views in every four, so check every
page on a phone, not only on a laptop. DevTools can imitate a phone screen (the device toolbar in Chrome, Responsive
Design Mode in Firefox), but a real phone shows real fonts, touch and network speed. To reach your local server from
a phone on the same Wi-Fi, start it without --bind 127.0.0.1 and open your computer’s network address on the
phone; do that only on a network you trust, and stop the server afterwards.
Try it
- Make the folder
my-sitewith anindex.htmllike the one in the previous lesson, saved as UTF-8. - Serve it with
python3 -m http.server 8000 --bind 127.0.0.1and openhttp://localhost:8000/. - Open DevTools, find your
h1in the Elements panel, change its text there, and reload to see the change vanish. - Look at the same
h1in the accessibility tree and note its role and name. - Copy
opening-hours.htmlfrom above into your folder, check it with a validator, and fix it until the validator reports no errors: close the paragraph before the list (or drop it), remove the stray</p>, and give the image an alt text that describes the shop front.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Why serve a page from a local web server instead of opening the HTML file directly?
A file opened from a file:// address has no normal web origin, and browsers apply their security rules by origin,
so some features behave differently or not at all. Module scripts are the common case: when index.html loaded
app.mjs from a file, Chromium and WebKit blocked the request and Firefox ran it. A local server such as
python3 -m http.server --bind 127.0.0.1 gives the page a real origin, http://localhost:8000, sends each file with
a Content-Type header and serves index.html for folders, as a real host does. Pages then behave the same in every
browser and the same as after publishing, and relative links and fetch requests work as they will online.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A student's site works when they double-click index.html on their laptop, but after uploading it to a host, the home page loads while the logo and the About page return 404. What are the likely causes, and how do you find and fix them?
-
Fresher to mid
A developer starts python3 -m http.server in their home folder to show a colleague a page over the office Wi-Fi. What risks does that create, and how should they preview and share the page instead?
Key takeaways
- Save pages as UTF-8 with lowercase, hyphenated names, and call each folder’s main page
index.html. - A page opened from
file://has no ordinary origin: in the recorded run a module script was blocked from a file in Chromium and WebKit. Preview through a local server instead. python3 -m http.server 8000 --bind 127.0.0.1serves a folder to your own computer only; it is not a production server.- DevTools’ Elements panel shows the DOM, View Source shows the file, and the accessibility tree shows roles and names.
- Validators report what browsers repair silently; the Nu Html Checker and html-validate catch different things, so use both.
References
- http.server: HTTP servers (Python 3.14 documentation) (Python Software Foundation)
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax (IETF)
- HTML Standard: Scripting (fetching module scripts) (WHATWG)
- Open Chrome DevTools (Google (Chrome for Developers))
- Accessibility features reference (Chrome DevTools) (Google (Chrome for Developers))
- Accessibility Inspector (Firefox DevTools User Docs) (Mozilla)
- Enabling features for web developers (Safari) (Apple)
- The Nu Html Checker (v.Nu) (The Nu Html Checker project)
- html-validate usage (html-validate)
- html-validate presets (html-validate)
- Desktop vs Mobile vs Tablet Market Share India (StatCounter)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress