Build Carbonmade
KINDAback to the verdict
A static portfolio generator whose hard problems are an image pipeline that respects the originals and the phone, a publish that either completes or does not happen, and a URL you can keep sending to clients after you rename a project.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the generator, the admin and the image pipeline.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree
Why The whole derivative pipeline rests on it, and the ones that quietly drop orientation are the reason portfolios launch sideways.
Get it Choose a maintained native library for your runtime and read its documentation on metadata handling before you build the ladder.
Verify
A deliberately rotated fixture comes out upright with no orientation tag - accountusually free at portfolio traffic; the domain is the real cost
Why Publishing is a file upload, and the domain is the thing that makes the portfolio yours rather than a tenant of someone else's brand.
Get it Any static host or object storage with a CDN in front, plus your own domain pointed at it.
Verify
A published build is reachable at your domain over https - have readyfree
Why A pipeline tested on three tidy exports fails on the panorama, the portrait scan and the thirty megabyte camera file that are already in your archive.
Get it Copy a representative selection into the content directory, including at least one rotated photograph and one very large file.
Verify
A full build completes and reports derivative sizes per project - decidefree
Why The priority image rule needs a definition, and deciding it per page after the fact means deciding it inconsistently.
Get it Write down the rule, for example the project cover on a project page and the first grid item on the index.
Verify
The rule is in the README and enforced by the build
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- projects(slug, title, year, client, summary, body_md, cover_media_id, ordinal, status) · status is draft or published - media(id, project_id, original_path, original_bytes, width, height, exif_orientation, sha256, alt_text, ordinal) - derivatives(media_id, width, format, path, bytes) · regenerated from the original, never edited in place, safe to delete - pages(slug, title, body_md, ordinal) · the about and contact pages every portfolio has - builds(id, started_at, finished_at, output_dir, content_hash, status, bytes, page_count, lcp_ms) - redirects(from_path, to_path, code, created_at) · written automatically whenever a published slug changes - alt_text is a column rather than an optional front matter key, and a published image without one fails the build.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
CONTENT_DIR | required | ./content | Project folders and originals. This is the portfolio; back it up first. |
BUILD_DIR | required | ./builds | Numbered build outputs. The previous one is kept for rollback. |
DERIVATIVE_WIDTHS | required | 480,960,1440,2048 | The ladder. Changing it invalidates the derivative cache. |
DERIVATIVE_FORMAT | required | avif | Primary output format. A fallback is always emitted alongside. |
SITE_URL | required | https://example.com | Used for canonical links, the sitemap and redirect targets. |
BUILDS_KEPT | required | 3 | How many previous builds stay on disk for rollback. |
The build, in order
Content on disk that outlives the generator
Everything the site is made of lives in plain files you could edit with any text editor.
Your content should not need this program to be readable.
Files
src/content/scan.tstests/rebuild.test.tsFolders get tidied; URLs you sent people do not.
Files
src/content/schema.tsA missing image is usually a typo, not a decision.
Files
src/content/scan.ts
done when · tick each as it passeswatch out- Keeping the canonical content in a database and the images in an uploads folder means the day you abandon this generator your portfolio is trapped in a schema, which is precisely the situation you left the paid product to avoid. Keep the folders canonical, treat the database as derived, and prove it by deleting the database and rebuilding byte-identical output.
Derivatives from originals nobody touches
Every displayed image is generated from an untouched original at a fixed ladder of widths.
Otherwise the browser rotates an already rotated image.
Files
src/images/derive.tstests/orientation.test.tsA rebuild of an unchanged portfolio should be fast.
Files
src/images/cache.tsThis is the file you cannot get back.
Files
tests/originals-immutable.test.ts
done when · tick each as it passeswatch out- Resizing with a library that ignores the orientation tag turns a third of a photographer's portfolio sideways, and nobody notices before launch because the operating system's viewer applies the tag and shows the originals correctly. Read the tag, bake the rotation into the derivative, strip the tag from the output so the browser does not rotate it a second time, and put a deliberately rotated photograph in the fixtures.
- Writing the resized version over the original saves disk and destroys the one artefact you cannot regenerate, usually in a script somebody ran to fix something else. Make the originals directory read only in the pipeline and assert it in a test.
The page that loads fast on a phone
Each page declares exactly one priority image and everything else defers, with the build enforcing it.
The hero is the number that decides how the site feels.
Files
src/render/image.tstests/priority-image.test.tsReflow is the other half of a slow-feeling page.
Files
src/render/image.tsA number nobody records is a regression nobody notices.
Files
scripts/measure.mjssrc/build/record.ts
done when · tick each as it passeswatch out- Adding loading lazy to every image because it is the obvious optimisation puts the hero, which on a portfolio is almost always the largest contentful paint element, behind layout and the published guidance is explicit that an above the fold image with lazy loading will likely have a large negative effect on that metric. Choose one priority image per page, give it fetchpriority high and no lazy attribute, and lazy load only what is genuinely below the fold.
- Omitting width and height means the page reflows as each image arrives, which on a slow phone connection is the difference between a portfolio and a slideshow of jumping boxes. Emit intrinsic dimensions from the stored values on every single image, and fail the build when one is missing.
Editing without breaking the site
A local admin edits metadata, reorders images and previews, and cannot publish something invalid.
Two renderers means two behaviours and one surprise.
Files
src/admin/preview.tsIt is the only accessibility rule that matters here.
Files
src/build/validate.tstests/alt-text.test.tsThe folders are canonical, including the order.
Files
src/admin/reorder.ts
done when · tick each as it passeswatch out- A preview served by a different code path from the build is the reason things look right locally and wrong live, and you only find out after you have sent the link. Run the real build into a temporary directory and serve that, even though it is slower, because the alternative is a class of bug you cannot reproduce.
- Making alt text optional means it is never written, and a portfolio is the one kind of site that is entirely images. Block publishing on a missing alt text, name the image, and let the project stay in draft as long as it needs to.
Publish, roll back, keep the links workingproduct builder
Publishing either completes or leaves the old site untouched, and no URL you have ever sent someone breaks.
Nobody should ever see a half published portfolio.
Files
scripts/publish.shtests/publish.test.tsYou will not remember to do this by hand.
Files
src/build/redirects.tsA rollback you have never run is a plan, not a command.
Files
tests/rollback.test.ts
done when · tick each as it passeswatch out- Uploading file by file over the live site means a visitor during the upload gets new HTML pointing at images that are not there yet, and a failure halfway leaves that state permanently. Build complete, verify complete, swap complete, and keep the previous directory so rollback is a rename rather than a rebuild.
- Renaming a project to fix a typo changes its URL and every link in every email you sent a client goes to a 404, which you discover when one of them mentions it months later. Diff published slugs against the last build, write the redirect yourself, and block a publish that would orphan a slug with no redirect.
That is the whole plan for Carbonmade. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- A general purpose visual editor.
- Client collaboration and approvals.
- Hosted forms and analytics.
- A template marketplace.
- general-purpose visual editing
- large template and component libraries
- CMS collaboration and client handoff
- managed hosting, forms, and analytics
- high-fidelity color, format, and export handling
- Case study layouts with per-project template overrides, once the base renderer is stable enough to be worth overriding
- A password-protected draft URL for work you cannot show publicly yet, which is a hosting feature more than a build one
- An archive page generated from the builds table, so the portfolio's own history is a page rather than a folder
Need the files? The project pack on the verdict page hands your agent the whole brief · more website builders.