@stnd/ayout
Definitive root layouts and base components for Standard websites.
The <html> wrapper every Standard page shares: the <head> metadata, the header/footer, the “restore theme before paint” script. Most pages just want Base.astro; Encrypted.astro is the special case for a password-gated page.
---
import Base from "@stnd/layout/Base";
---
<Base title="My Page"><h1>Hello</h1></Base>
A caveat on Encrypted.astro — it’s a casual visitor gate, not real cryptographic access control: the password check is a SHA-256-derived XOR keystream, and both the ciphertext and the decryption code ship to every visitor’s browser. Predictable HTML structure (<!DOCTYPE html> at a known offset) makes a known-plaintext attack on the keystream straightforward. Fine for “keep casual visitors out”; not a substitute for real server-side auth if the content actually matters.
Overview
@stnd/layout provides the foundational structure for all websites in the Standard ecosystem. It handles essential infrastructure such as meta tags, SEO, favicons, headers, footers, and system-wide initializations.
Key Layouts
Base Layout (Base.astro)
The primary layout for most pages. It includes:
- HTML structure with theme support.
- Meta tags for SEO and Open Graph.
- Site-wide Header and Footer.
- Global styling injection.
- System initializers (
StndInit.astro).
Encrypted Layout (Encrypted.astro)
A specialized layout for password-protected content. It uses client-side encryption to ensure privacy while maintaining a consistent design.
Components
Header.astro: A highly configurable header with support for branding, navigation, and custom actions.Footer.astro: A standard footer with links, copyright information, and slot-based extensions.Meta.astro: Manages all<head>metadata, including social sharing images and Favicons.StndInit.astro: Handles client-side system setup, such as theme restoration and e-ink detection.
Usage
In your Astro page:
---
import Base from "@stnd/layout/Base";
---
<Base title="My Page" description="A well-made page.">
<h1>Hello Standard!</h1>
</Base>
Configuration
Layouts inherit site-wide settings from the config.nav and config.title defined in your module manifests or astro.config.mjs.
Future & Roadmap Layouts
These layouts are parked here for future use (Phase 4 of the Standard Roadmap).
1. Column Layout (ColumnLayout.astro — Astro Port, WIP)
A multi-column masonry-like grid layout based on BaseLayout.astro.
View ColumnLayout.astro Code
---
import BaseLayout from "./BaseLayout.astro";
export interface Props {
title: string;
showTitle?: boolean;
}
const { title, showTitle = true } = Astro.props;
---
<BaseLayout title={title}>
<div class="column-layout">
<hr />
{
showTitle && (
<header class="title-row">
<h1>{title}</h1>
</header>
)
}
<div class="content-row">
<div class="content-grid">
<div class="content-section">
<slot />
</div>
</div>
</div>
</div>
</BaseLayout>
2. Column Template (column.njk — 11ty, Archive)
The Eleventy-era column layout using a 4-column r-grid with text-replacement helpers for styling HTML content blocks.
View column.njk Code
---
layout: default
---
{% include "partial/header.njk" %}
<r-grid columns=4>
<hr>
<r-cell span=row><h1>{{ page.title }}</h1></r-cell>
<r-cell span=row></r-cell>
<r-cell span=row flow-cols-s=1 flow-cols=2 flow-cols-l=3>
<article style="grid-template-columns:none;">
{% set content = content | replace("<ul>", "<ul class='tight'>") %}
{% set content = content | replace("<h2", "</article></r-cell><r-cell span=row><h2") %}
{% set content = content | replace("</h2>", "</h2></r-cell><r-cell span=row flow-cols-s=1 flow-cols=2 flow-cols-l=3><article style='grid-template-columns:none;'>") %}
{{ content }}
{% include "partial/backlinks.njk" %}
</article>
</r-cell>
</r-grid>
{% include "partial/footer.njk" %}
3. Encrypted Template (encrypted.njk — 11ty, Archive)
The Eleventy-era password-protected layout utilizing client-side XOR decryption and a stylized prompt, now succeeded by Encrypted.astro.
View encrypted.njk Code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cette page est protégée</title>
<style>
:root {
--primary-color: #d78a5a;
--error-color: #dc3545;
--rhythm-block: #ffffff;
--text-color: #333333;
--color-border: #ddd;
--font-family: InterVariable, system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
}
body {
font-family: var(--font-family);
margin: 0;
padding: 40px 20px;
background-color: var(--rhythm-block);
color: var(--text-color);
line-height: 1.6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 400px;
width: 100%;
padding: 2rem;
background: white;
}
h2 {
margin: 0 0 1.5rem 0;
text-align: center;
color: var(--text-color);
font-size: 1.5rem;
font-weight: 900;
font-feature-settings:
"dlig", "case", "kern", "cv01", "cv02", "cv03", "cv04", "cv05", "cv06",
"cv09", "cv10", "cv11", "cv12", "cv13";
font-variation-settings: "";
letter-spacing: -0.065em;
line-height: 0.75em;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
letter-spacing: -0.065em;
color: var(--text-color);
}
input[type="password"] {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--color-border);
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
font-weight: 600;
letter-spacing: -0.065em;
transition: border-color 0.2s ease;
text-align:center;
}
input[type="password"]:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
}
button {
width: 100%;
background: var(--primary-color);
color: white;
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 900;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease;
font-family: inherit;
}
button:hover {
background: color-mix(in srgb, var(--primary-color) 40%, var(--text-color));
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error {
color: var(--error-color);
text-align: center;
margin-top: 1rem;
padding: 0.75rem;
background: rgba(220, 53, 69, 0.1);
border-radius: 4px;
display: none;
}
.loading {
display: none;
text-align: center;
margin-top: 1rem;
color: var(--primary-color);
}
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('https://rsms.me/inter/font-files/InterVariable.woff2?v=4.0') format('woff2');
}
@font-face {
font-family: InterVariable;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('https://rsms.me/inter/font-files/InterVariable-Italic.woff2?v=4.0') format('woff2');
}
/* legacy name "Inter var" (Oct 2023) */
@font-face { font-family:'Inter var'; font-style:normal; font-weight:100 900; font-display:swap; src: url('https://rsms.me/inter/font-files/InterVariable.woff2?v=4.0') format('woff2'); }
@font-face { font-family:'Inter var'; font-style:italic; font-weight:100 900; font-display:swap; src: url('https://rsms.me/inter/font-files/InterVariable-Italic.woff2?v=4.0') format('woff2'); }
/* static fonts */
@font-face { font-family:Inter; font-style:normal; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Thin.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ThinItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraLight.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraLightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Light.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-LightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Regular.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Italic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Medium.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-MediumItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-SemiBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Bold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-BoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-ExtraBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-Black.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/Inter-BlackItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Thin.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:100; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ThinItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraLight.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:200; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraLightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Light.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:300; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-LightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Regular.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:400; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Italic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Medium.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:500; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-MediumItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-SemiBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:600; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-SemiBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Bold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:700; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-BoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:800; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-ExtraBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-Black.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:900; font-display:swap; src:url("https://rsms.me/inter/font-files/InterDisplay-BlackItalic.woff2?v=4.0") format("woff2"); }
</style>
</head>
<body>
<div class="container">
<h2>Cette page est protégée par un mot de passe</h2>
<form id="decrypt-form">
<div class="form-group">
<input
type="password"
id="password"
placeholder="Mot de passe"
required
autocomplete="current-password"
>
</div>
<button type="submit" id="unlock-btn">
<span class="btn-text">Déverrouiller</span>
<span class="btn-loading" style="display: none;">Déverrouillage...</span>
</button>
</form>
<div id="error" class="error">Mot de passe incorrect. Veuillez réessayer.</div>
<div id="loading" class="loading">Déchiffrement du contenu...</div>
</div>
<script>
const encryptedData = "{{ encryptedData }}";
const maxAttempts = 5;
let attempts = 0;
const form = document.getElementById('decrypt-form');
const passwordInput = document.getElementById('password');
const unlockBtn = document.getElementById('unlock-btn');
const errorDiv = document.getElementById('error');
const loadingDiv = document.getElementById('loading');
const btnText = unlockBtn.querySelector('.btn-text');
const btnLoading = unlockBtn.querySelector('.btn-loading');
form.addEventListener('submit', async function(e) {
e.preventDefault();
const password = passwordInput.value;
if (!password) return;
await attemptDecryption(password);
});
function setLoading(loading) {
unlockBtn.disabled = loading;
btnText.style.display = loading ? 'none' : 'inline';
btnLoading.style.display = loading ? 'inline' : 'none';
loadingDiv.style.display = loading ? 'block' : 'none';
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
// Add shake animation
errorDiv.style.animation = 'shake 0.5s ease-in-out';
setTimeout(() => {
errorDiv.style.animation = '';
}, 500);
}
function hideError() {
errorDiv.style.display = 'none';
}
async function decrypt(encryptedData, password) {
try {
// Simple XOR decryption matching server-side implementation
const encrypted = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
// Generate key from password using SHA-256
const encoder = new TextEncoder();
const passwordBytes = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', passwordBytes);
const key = new Uint8Array(hashBuffer);
// XOR decrypt
const decrypted = new Uint8Array(encrypted.length);
for (let i = 0; i < encrypted.length; i++) {
decrypted[i] = encrypted[i] ^ key[i % key.length];
}
const result = new TextDecoder().decode(decrypted);
// Basic validation that decryption worked
if (!result.includes('<!DOCTYPE') && !result.includes('<html')) {
throw new Error('Invalid decryption result');
}
return result;
} catch (error) {
throw new Error('Decryption failed');
}
}
// Check for password in URL hash on page load
function checkHashPassword() {
const allowHashPassword = true;
if (!allowHashPassword) {
passwordInput.focus();
return;
}
const hash = window.location.hash;
if (hash && hash.length > 1) {
const passwordFromHash = hash.substring(1); // Remove the # character
if (passwordFromHash) {
// Clear the hash from URL for security (if enabled)
const clearHashOnLoad = true;
if (clearHashOnLoad) {
history.replaceState(null, null, window.location.pathname + window.location.search);
}
// Auto-decrypt with hash password
attemptDecryption(passwordFromHash);
return;
}
}
// Auto-focus password input if no hash password
passwordInput.focus();
}
async function attemptDecryption(password) {
if (attempts >= maxAttempts) {
showError('Too many failed attempts. Please refresh the page.');
return;
}
setLoading(true);
hideError();
try {
const decrypted = await decrypt(encryptedData, password);
// Replace the current page with decrypted content
document.open();
document.write(decrypted);
document.close();
} catch (error) {
attempts++;
const remaining = maxAttempts - attempts;
if (remaining > 0) {
showError(`Mot de passe incorrect. Veuillez réessayer. (${remaining} tentatives restantes)`);
} else {
showError('Too many failed attempts. Please refresh the page.');
unlockBtn.disabled = true;
}
passwordInput.value = '';
passwordInput.focus();
} finally {
setLoading(false);
}
}
// Check for hash password on page load
checkHashPassword();
// Add CSS animation for shake effect
const style = document.createElement('style');
style.textContent = `
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
`;
document.head.appendChild(style);
</script>
</body>
</html>
Notes / Observations
Encrypted.astro‘s SHA-256+XOR scheme is identical to the archived
encrypted.njkabove — it wasn’t rewritten during the Astro port, weakness and all.
Todo
- [ ] Decide
Encrypted.astro’s real threat model — if it’s ever meant to
gate something that actually matters (not just “keep casual visitors out”), the XOR keystream needs replacing with real crypto (e.g. a server-verified password, or AES-GCM with a per-page random IV) — known-plaintext against predictable HTML headers breaks the current scheme. If “casual gate” is genuinely the whole intent, just say so in the docs instead of “ensure privacy”.