The data-driven resume behind www.douglaskent.com. All content lives in
src/static/resume.json (validated against src/static/schema.json); the application is the renderer.
Aurelia 2 and TypeScript, bundled by Vite.
This project was bootstrapped by aurelia/new, updated to Aurelia version rc.2.
Node ^20.19.0 || >=22.12.0, as required by Vite 7.
| Command | Purpose |
|---|---|
npm start |
Dev server on port 9000, opens a browser |
npm run debug |
Same dev server, without opening a browser |
npm run admin |
Same dev server, opening the resume editor at /admin (see below) |
npm run build |
Production build into dist/ |
npm run preview |
Serve the built dist/ to check it before deploying |
npm run typecheck |
tsc --noEmit, using the TypeScript version this project pins |
npm run lint |
eslint, htmlhint and sass-lint (lint:js, lint:html, lint:css individually) |
npm run lint:js.fix |
eslint with --fix |
npm run prerender:create |
Capture a JavaScript-free copy of the resume into index-prerender.html (see below) |
npm run prerender:insert |
Splice that copy into the built dist/index.html |
npm run prerender |
Both of the above, in order |
npm run clean |
Delete node_modules and the lockfile, then reinstall |
npm run deploy |
FTP upload driven by ftpDeploy.txt |
| URL | Shows |
|---|---|
/ |
Redirects to /resume |
/resume |
The complete resume |
/resume/short |
The condensed resume: anything marked resume-type="complete" is hidden, and short-only passages appear instead |
/resume/expanded |
Starts with the collapsible sections already open: skills pills, the full Professional Experience, and publications |
/expanded |
Redirects to /resume/expanded |
/resume/short/expanded |
Redirects to /resume/expanded -- short and expanded are mutually exclusive |
/techresume |
Redirects to /resume |
/admin |
The resume editor -- dev server only, absent from a build. See Editing the resume |
| anything else | The not-found page, which reports the address that failed and links to the three real ones |
The canonical link, injected into the served HTML by resume-head-plugin.ts, is the apex,
https://www.douglaskent.com/. Every route points at it; none of them is separately indexed.
The canonical was /resume/expanded before that, and ?expanded=<anything non-empty> before
that -- the query string still turns expansion on, so links indexed against either earlier
address keep working, but only on the complete resume. On /resume/short it is ignored, the
same as any other unrecognised query parameter, because short and expanded are mutually
exclusive and the query string is not a way around that.
The root path is served by listing "" among the resume route's path array rather than by a
default attribute on <au-viewport>. A viewport default is an instruction the router
serialises back into the URL, so it rewrote / to /resume; a matched empty path serialises
to nothing, so / stays /. The router logs AUR3176 for an empty path, but the warning is
dev-only and its advice is exactly the behaviour being avoided.
resume/*rest looks heavier than resume/:option?, and the difference matters. The router
matches hierarchically: resume/:option? matches /resume/a/b as resume/a and leaves b
over, which it then tries to resolve as a child route of resume. resume.html has no child
viewport, so that throws AUR3401 and renders a blank page -- and it cannot be caught, because
the router deliberately does not raise a navigation-error event for an unknown route. A star
segment consumes every trailing segment, so nothing is ever left over, and Resume.canLoad
decides which values are valid and redirects the rest to not-found.
The short and complete variants are driven by the resume-type custom attribute in
src/resources/attributes/whichResumeOnly.ts.
npm run admin opens a GUI at /admin for the Professional Experience and the skills it depends on,
writing changes back to src/static/resume.json. Edit, save, then commit the file as usual.
| Tab | Edits |
|---|---|
| Companies | work -- add, duplicate, delete, reorder (order is display order, and the first three sit above the "Show the whole history" fold), plus highlights and per-company skills |
| Skills | skills -- name, priority, url, categories, aliases, hide |
| Categories | skillCategories -- add, rename, reorder, delete |
The three are edited together because they reference each other: a company names skills, and a skill names categories. The editor enforces that. A company that references a skill the list does not contain is an error and blocks saving, because the resume resolves those names with a non-null assertion and would otherwise render nothing for it. Renaming a category cascades into every skill that uses it, and a skill or category cannot be deleted while something still references it. Softer problems -- a category no skill uses, a name claimed by two skills, an out-of-shape date -- are warnings and do not block a save.
Dates are YYYY-MM, except endDate, which also accepts Present (any casing) for an ongoing
role. Nothing parses either field -- the view interpolates them as text -- so an odd value is
only ever a warning; the resume carries 2017-09 (intermittent) quite deliberately.
The site is static files on FTP hosting, so a deployed page has nothing to write to. The write
endpoint (GET/PUT /__resume) lives in resume-api-plugin.ts, a Vite plugin marked
apply: "serve" that only ever registers through configureServer, so it cannot exist in a
build. The route in src/pages/app/app.ts is spread in behind import.meta.env.DEV and loads
its component through a dynamic import(), so nothing under src/pages/admin is reachable from
the production bundle. npm run build output contains no editor code, symbols, route, or CSS.
Saving rewrites resume.json, which is in the module graph, so Vite reloads the page and the
editor comes back freshly loaded from disk -- whichever row was open closes.
npm run build writes dist/index.html (generated from the root index.html) plus hashed bundles in
dist/assets/. It also emits dist/stats.html, a bundle size report that is not deployed.
The root index.html is a build input and is never served. dist/index.html differs from it in four
ways: the src/main.ts script tag becomes the hashed bundles, base.css is folded into
dist/assets/*.css along with the component SCSS (so it is not deployed as a separate file), a
schema.org JSON-LD block is injected into <head> by resume-jsonld-plugin.ts, and the head SEO tags
-- <title>, <meta name="description">, the canonical link and the Open Graph and Twitter card tags
-- are injected by resume-head-plugin.ts. Both sets are generated from resume.json on every build.
None of those head tags is authored in index.html, the canonical link included: the router sets the
title at runtime, which is invisible to anything that does not run JavaScript, so the served HTML has
to carry them. resume-head-plugin.ts owns them, and hand-adding one to index.html would only
duplicate it.
The asset hashes change whenever their contents change, and dist/index.html is what points at them, so
always deploy index.html together with dist/assets -- shipping one without the other leaves the
site referencing bundles that are not there.
Runs ftp -i -s:ftpDeploy.txt: Windows ftp.exe driven by a script file, uploading into /douglask
on the server, which is the site root.
ftpDeploy.txt is gitignored, because it holds the FTP password in plain text. A fresh clone
therefore has no deploy script and cannot deploy until one is recreated.
The script names every file individually. There is no "upload dist/" step, and its only wildcard,
mput *.js *.css, runs after both ends have changed into assets/, so it covers nothing at the root.
A new file is not deployed until a line is added for it -- it will build correctly and silently
never go live. dist/stats.html is the standing example: emitted every build, deliberately never
uploaded.
What goes up, and from where:
| Local | Remote | |
|---|---|---|
favicon.ico |
/favicon.ico |
repo root, not dist/ |
src/static/resume.json |
/resume.json |
the source file itself -- see below |
dist/index.html |
/index.html |
|
dist/robots.txt |
/robots.txt |
copied into dist/ from public/ by the build |
dist/assets/*.js, *.css |
/assets/ |
Order matters. The resume.json line runs while the local directory is still the repo root, before
lcd dist; moving it below that line breaks the path. favicon.ico is uploaded from the repo root for
the same reason -- unlike robots.txt, it does not live in public/ and so never reaches dist/.
/resume.json publishes src/static/resume.json verbatim. It is the source file, not a filtered
export: every field is public, including basics.email, resumeFeedbackEmail, both phone numbers and
the street address, and every skill flagged hide. The JSON-LD block in index.html is the opposite --
derived, and deliberately carrying no contact details.
FTP uploads but never deletes, so a file dropped from the script stays on the server until it is removed
by hand. base.css is one: it was deployed separately before it was bundled.
This is a client-rendered SPA, so a reader that does not execute JavaScript receives an empty shell and
none of the resume. That is most of the crawlers the site cares about, the AI crawlers web.config opts
back in included. The snapshot gives them a complete, JavaScript-free copy of the full resume, spliced
into the served dist/index.html.
Two scripts, each doing one thing, and a third that composes them:
| Command | Does |
|---|---|
npm run prerender:create |
prerender-capture.mjs serves the built dist/, loads /resume/expanded in headless Chromium, strips the rendered DOM and writes index-prerender.html |
npm run prerender:insert |
prerender-insert.mjs takes the body of index-prerender.html and splices it into dist/index.html at the <!-- prerender:insert --> marker |
npm run prerender |
Both, in that order |
npm run build # 1. build the Aurelia app into dist/
npm run prerender # 2. capture from dist/, splice back into dist/index.html
npm run deploy # 3. uploadThe build must come first. The capture reads whatever is currently in dist/, so running it against
a stale build publishes the previous build's resume. build deliberately does not trigger the
prerender: it stays a plain Aurelia build, usable on its own.
Nothing enforces the sequence. npm run deploy runs only the web.config validation in its
predeploy hook; it does not prerender. A build followed straight by a deploy uploads the shell
with its <!-- prerender:insert --> marker unconsumed and no resume markup in it. The site still works
for anyone running JavaScript, so nothing looks wrong -- it just carries nothing for the readers the
snapshot exists for. Run step 2 every time.
To confirm before uploading, count the verification markers in the built shell:
grep -o "data-work-entry" dist/index.html | wc -lThat prints 0 on a shell that was never prerendered, and otherwise one per entry in resume.json's
work[]. Note grep -o ... | wc -l rather than grep -c: the inserted markup is almost entirely one
line, and grep -c counts matching lines, so it reports 1 for any prerendered shell however many
entries it holds.
Prerender once per build. prerender:insert throws on a shell that already carries
id="prerendered-resume", because a second insert would append a second copy of the resume. To
prerender again, run npm run build first for a clean shell.
index-prerender.html is written as a whole document, with a <head> carrying the built stylesheet, so
it can be opened in a browser and checked. That head is scaffolding for viewing only; the insert step
takes just the body.
classis kept, and the classes are the same ones the running app's own markup carries -- the snapshot is that markup, captured.dist/index.htmllinks the stylesheet whether or not JavaScript runs, so those classes resolve against exactly the rules the app would have used. A reader without JavaScript therefore sees a page presented very much like the real one, rather than unstyled markup. This is whyclasssurvives a pass that strips almost every other attribute.hrefis kept, so links remain links.- Every other attribute is stripped, ids included, along with every HTML comment.
data-work-entryis added to each work entry. It is a verification marker and nothing more: counting it in the served HTML confirms every entry survived the capture (see the command above). It has no runtime purpose, nothing in the app reads it, and it is gone from the live DOM the moment the app boots and removes the block.- Content hidden by CSS is removed. A crawler does not apply stylesheets, so anything left in the
DOM under
display: nonewould be read as ordinary text -- the short-resume variants and the sidebar's duplicate contact block among them. - The table of contents is excluded, deliberately. Every entry navigates through
click.trigger="goto(...)"with nohref, so none of it is usable without JavaScript.
Aurelia and TypeScript versions are pinned exactly rather than floating on latest, so an install cannot
change the framework version underneath the app.
Two things worth knowing before changing dependencies:
- Prefer
npm run cleanover an incremental install when Aurelia versions change. npm sometimes nests duplicate copies of the@aurelia/*packages under individual dependents instead of hoisting one copy. Two copies means two module instances, which breaks dependency injection at runtime while still building successfully. vite.config.tspassesuseDevto the Aurelia plugin deliberately. Without it, production builds resolve Aurelia'sdevelopmentexport condition and bundle the development builds, error message text and all. See aurelia/aurelia#2463; the comment invite.config.tsexplains it in place.
In this Aurelia application, data flows through a structured pipeline involving Views, ViewModels, Stores, and Services.
-
Role: The presentation layer of the application.
-
Interaction: Views are paired with ViewModels and bind to their public properties and methods using Aurelia’s binding system.
-
Responsibility:
- Handle display logic only.
- Apply formatting (e.g., currency, dates, numbers) to data provided by the ViewModel.
-
Role: UI controllers that connect Views to application logic.
-
Interaction: ViewModels are paired one-to-one with Views and are responsible for orchestrating UI behavior.
-
Responsibility:
- Inject Stores as needed.
- Delegate data retrieval, transformation, and business logic to Stores.
- Expose observable properties for the View to bind to.
- Handle user interaction and lifecycle events (
binding,attached, etc.).
-
Role: Centralized modules that manage state, business logic, and coordination of data.
-
Interaction: Injected into ViewModels (or other Stores if needed).
-
Responsibility:
- Act as the middle layer between ViewModels and Services.
- Call Services to fetch raw data.
- Apply business rules and transformations.
- Cache and manage shared application state.
-
Role: Interface with external resources like APIs, databases, or smart contracts.
-
Interaction: Injected into Stores.
-
Responsibility:
- Make HTTP requests or contract calls.
- Return raw, unformatted data.
- Remain stateless and reusable.
[ View ] → binds to → [ ViewModel ] → uses → [ Store ] → calls → [ Service ]
This separation supports:
- Reusability of Stores across different ViewModels
- Testability by isolating logic in Stores and Services
- Clean UI logic by keeping ViewModels slim and focused
| Layer | Location |
|---|---|
| Views and ViewModels | src/pages/, one folder per section under src/pages/resume/sections/ |
| Stores | src/stores/resume-store.ts |
| Services | src/services/resume-service.ts |
| Value converters and custom attributes | src/resources/ |
| Content | src/static/resume.json |
The model types are derived from the JSON rather than declared by hand: IResume is typeof resumeJson,
and the store exposes aliases off it such as ICompany = IResume["work"][0]. Adding a field to
resume.json therefore makes it available to the templates with no type changes. Because those types
describe stored data, view state has no place in them -- see ICompanyView in
src/pages/resume/sections/history/history.ts for how that is layered on.
Since the resume is a static JSON import, the Service layer here is a thin one: it imports the JSON and re-exports it along with its inferred types. It exists to keep the seam in place should the content ever move behind an API.