Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/stack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from .secrets import TomlSecretStore
from .hooks import HookResolver, StackContext, build_hook_ctx
from .output import SilentOutput, CollectorOutput
from .users import user_id
from .users import user_id, family_display_name, family_plural
from .ai import resolve_model
from . import docker
from .cli import CLI
Expand All @@ -29,6 +29,8 @@
"SilentOutput",
"CollectorOutput",
"user_id",
"family_display_name",
"family_plural",
"resolve_model",
"docker",
"CLI",
Expand Down
7 changes: 3 additions & 4 deletions lib/stack/installer_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
clear, nl, out, dim, bold, done, warn,
heading, section, banner, rule, Spinner, ask, confirm,
)
from .users import family_plural


# ── Helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -393,8 +394,7 @@ def wizard():
nl()
rule()
nl()
plural = family_name if family_name.lower().endswith("s") else family_name + "s"
bold(f"The {ORANGE}{plural}{RESET}")
bold(f"The {ORANGE}{family_plural(family_name)}{RESET}")
nl()
for u in users:
uid = user_id(u)
Expand Down Expand Up @@ -507,8 +507,7 @@ def wizard():
clear()
nl()
out(f"{ORANGE}{BOLD}famstack{RESET}")
plural = family_name if family_name.lower().endswith("s") else family_name + "s"
out(f"{GREEN}The {plural} are online{RESET}")
out(f"{GREEN}The {family_plural(family_name)} are online{RESET}")

from .users import user_id as uid2
admin_id = uid2(admin)
Expand Down
10 changes: 9 additions & 1 deletion lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,16 @@ def _build_template_vars(self) -> dict:
# Tech admin — internal service account for all stacklets
from .users import (
TECH_ADMIN_USERNAME, TECH_ADMIN_EMAIL,
get_admin_password, load_users, user_id,
family_display_name, get_admin_password, load_users, user_id,
)

# What the household calls itself ("The Simpsons"), for surfaces
# the family actually reads. Empty on instances installed before
# stack_owner existed, so anything using it needs a fallback.
template_vars["family_display_name"] = family_display_name(
self._cfg("core", "stack_owner", "")
)

template_vars["admin_username"] = TECH_ADMIN_USERNAME
template_vars["admin_email"] = TECH_ADMIN_EMAIL
template_vars["admin_password"] = get_admin_password(self.secrets) or ""
Expand Down
34 changes: 34 additions & 0 deletions lib/stack/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@
TECH_ADMIN_EMAIL = "stackadmin@home.local"


# ── Naming the household ──────────────────────────────────────────────
#
# stack.toml's [core] stack_owner is the surname the installer asked
# for. It reaches the family on the installer's closing line and in the
# title of their wiki, so both go through here and spell it the same.
#
# "Family name" gets answered two ways: one person types "Simpson", the
# next types "Simpsons". Both mean the same household, and only one of
# them needs an s adding.


def family_plural(owner: str | None) -> str:
"""The surname as you would address the whole household: "Simpsons".

Returns "" when no owner is configured, so callers can fall back to
something generic rather than render a name with a hole in it.
Instances predating stack_owner still run, so that is a live path
rather than a hypothetical.
"""
name = (owner or "").strip()
if not name:
return ""
return name if name.lower().endswith("s") else name + "s"


def family_display_name(owner: str | None) -> str:
"""The household as it appears on screen: "The Simpsons".

Empty when no owner is configured. See `family_plural`.
"""
plural = family_plural(owner)
return f"The {plural}" if plural else ""


def load_users(root: Path) -> list[dict]:
"""Load all users from users.toml."""
path = root / "users.toml"
Expand Down
38 changes: 38 additions & 0 deletions stacklets/memory/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,50 @@ WORKDIR /app
RUN git clone --depth 1 --branch "${QUARTZ_TAG}" https://github.com/jackyzha0/quartz.git . \
&& npm ci

# Mermaid, served from our own host. Upstream's diagram renderer
# imports itself from cdnjs at page-view time; the overlay below points
# it at /static/mermaid instead, and this is where that comes from.
# Same version upstream pins, so the swap is location-only.
#
# Only the minified ESM graph is copied. The full dist is 61MB, mostly
# type declarations, docs and source maps a browser never asks for; the
# entry plus its chunks is 2.5MB, and the browser fetches only the
# chunks a given diagram type actually needs.
RUN npm install --no-save --no-audit --no-fund mermaid@11.4.0 \
&& mkdir -p quartz/static/mermaid/chunks \
&& cp node_modules/mermaid/dist/mermaid.esm.min.mjs quartz/static/mermaid/ \
&& cp -r node_modules/mermaid/dist/chunks/mermaid.esm.min quartz/static/mermaid/chunks/ \
&& find quartz/static/mermaid -name '*.map' -delete

# Overlay our config on top of the upstream defaults. `quartz/*.ts`
# in this stacklet directory is the source of truth for site title,
# theme, plugins, and the "edit on Forgejo" link template.
COPY quartz/quartz.config.ts ./quartz.config.ts
COPY quartz/quartz.layout.ts ./quartz.layout.ts

# The famstack look. `custom.scss` is Quartz's sanctioned override slot
# (upstream ships it near-empty for exactly this) and `fonts.scss`
# carries the @font-face rules, because `fontOrigin: "local"` makes
# Quartz emit no font CSS of its own. `static/` holds the woff2 files,
# which the Static emitter copies into the built site.
COPY quartz/custom.scss ./quartz/styles/custom.scss
COPY quartz/fonts.scss ./quartz/styles/fonts.scss
COPY quartz/static/ ./quartz/static/

# Two upstream components we keep our own copy of, each to remove a
# third-party request no config option can reach. Both carry a comment
# saying what changed and why. On a QUARTZ_TAG bump, re-copy them from
# the new tag and re-apply the edit rather than merging into these.
COPY quartz/components/Head.tsx ./quartz/components/Head.tsx
COPY quartz/components/scripts/mermaid.inline.ts ./quartz/components/scripts/mermaid.inline.ts

# Our own components: the sidebar lockup and the home-page greeting.
# These are additions, not overrides, so a tag bump leaves them alone.
# quartz.layout.ts imports them directly, which is why upstream's
# components/index.ts does not need overlaying.
COPY quartz/components/FamstackTitle.tsx ./quartz/components/FamstackTitle.tsx
COPY quartz/components/Welcome.tsx ./quartz/components/Welcome.tsx

# The entrypoint serves the vault with Quartz — a pure view. The
# curator sidecar owns the git pull. See quartz/entrypoint.sh.
COPY quartz/entrypoint.sh /usr/local/bin/wiki-entrypoint.sh
Expand Down
3 changes: 3 additions & 0 deletions stacklets/memory/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ services:
WIKI_HOST: ${WIKI_HOST}
WIKI_IP: ${WIKI_IP}
WIKI_PORT: "42070"
# The household's own name for the site. Blank is expected on
# instances predating stack_owner, and quartz.config.ts falls back.
WIKI_TITLE: ${WIKI_TITLE:-}
ports:
# Container's Quartz preview server listens on 8080; we publish
# on the stacklet's declared port (42070). PORT_BIND_IP is set
Expand Down
111 changes: 111 additions & 0 deletions stacklets/memory/quartz/components/FamstackTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { pathToRoot } from "../util/path"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"

// NEW COMPONENT (not an upstream override) — the sidebar lockup that
// replaces PageTitle.
//
// Two names, deliberately in this order. The wiki belongs to the
// family, so its own name leads; famstack is the software underneath
// and sits below in small type, the way a maker's mark does. Getting
// that backwards would put our branding on their memories.
//
// The wordmark repeats famstack.dev's: "fam" in slate, "stack" in lava,
// with the a lifted onto two teal dots. It is built from styled spans
// rather than an image so it inherits the page's colours and stays
// sharp at any zoom, and so there is no asset to keep in sync.
const FamstackTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
const baseDir = pathToRoot(fileData.slug!)
return (
<div class={classNames(displayClass, "famstack-title")}>
<h2 class="page-title">
<a href={baseDir}>{title}</a>
</h2>
<span class="fs-brandmark" aria-label="famstack">
fam
<span class="fs-brand-accent">
st<span class="fs-brand-a">a</span>ck
</span>
</span>
</div>
)
}

FamstackTitle.css = `
.famstack-title {
display: flex;
flex-direction: column;
/* Room for the title's underline to sit clear of the wordmark. */
gap: 0.5rem;
}

.famstack-title .page-title {
font-size: 1.6rem;
margin: 0;
font-family: var(--titleFont);
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.1;
}

/* Underlined, because it is the way back to the front page from
anywhere and should look like somewhere you can go. Drawn as a
border rather than text-decoration so it sits clear of the
descenders in a name like Simpsons. */
.famstack-title .page-title > a {
border-bottom: 2px solid var(--secondary);
padding-bottom: 2px;
transition: border-color 0.2s ease;
}

.famstack-title .page-title > a:hover {
border-bottom-color: var(--tertiary);
}

.famstack-title .fs-brandmark {
font-family: var(--bodyFont);
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.01em;
line-height: 1;
color: var(--darkgray);
user-select: none;
}

.famstack-title .fs-brand-accent {
color: var(--tertiary);
}

/* The raised a, standing on two teal dots. */
.famstack-title .fs-brand-a {
position: relative;
display: inline-block;
vertical-align: baseline;
top: -0.2em;
}

.famstack-title .fs-brand-a::before,
.famstack-title .fs-brand-a::after {
content: "";
position: absolute;
width: 0.15em;
height: 0.15em;
border-radius: 50%;
background: var(--secondary);
bottom: -0.1em;
}

.famstack-title .fs-brand-a::before { left: 0.08em; }
.famstack-title .fs-brand-a::after { right: 0.1em; }

/* On mobile the sidebar becomes a header row and space is tight, so
the maker's mark steps aside and the wiki name carries it alone. */
@media all and (max-width: 800px) {
.famstack-title .fs-brandmark { display: none; }
.famstack-title .page-title { font-size: 1.3rem; }
}
`

export default (() => FamstackTitle) satisfies QuartzComponentConstructor
120 changes: 120 additions & 0 deletions stacklets/memory/quartz/components/Head.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { i18n } from "../i18n"
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { unescapeHTML } from "../util/escape"
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"

// OVERLAY — a verbatim copy of quartz/components/Head.tsx at v4.5.2
// with exactly one line removed:
//
// <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
//
// It sat outside every conditional, so it fired on every page load of
// the family wiki no matter how the theme was configured. A preconnect
// is not a passive hint: it opens a real TCP and TLS connection, which
// tells Cloudflare the household's IP address and when somebody is
// reading. No config switch reaches it, so the file is the only lever.
//
// Copying an upstream component means owning it. When QUARTZ_TAG moves,
// re-copy this file from the new tag and re-apply the deletion rather
// than merging into this copy — the diff is one line and it is easier
// to redo than to reconcile.
export default (() => {
const Head: QuartzComponent = ({
cfg,
fileData,
externalResources,
ctx,
}: QuartzComponentProps) => {
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description =
fileData.frontmatter?.socialDescription ??
fileData.frontmatter?.description ??
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)

const { css, js, additionalHead } = externalResources

const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
const iconPath = joinSegments(baseDir, "static/icon.png")

// Url of current page
const socialUrl =
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)

const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
(e) => e.name === CustomOgImagesEmitterName,
)
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`

return (
<head>
<title>{title}</title>
<meta charSet="utf-8" />
{cfg.theme.cdnCaching && cfg.theme.fontOrigin === "googleFonts" && (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
{cfg.theme.typography.title && (
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
)}
</>
)}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<meta name="og:site_name" content={cfg.pageTitle}></meta>
<meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="og:description" content={description} />
<meta property="og:image:alt" content={description} />

{!usesCustomOgImage && (
<>
<meta property="og:image" content={ogImageDefaultPath} />
<meta property="og:image:url" content={ogImageDefaultPath} />
<meta name="twitter:image" content={ogImageDefaultPath} />
<meta
property="og:image:type"
content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`}
/>
</>
)}

{cfg.baseUrl && (
<>
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
<meta property="og:url" content={socialUrl}></meta>
<meta property="twitter:url" content={socialUrl}></meta>
</>
)}

<link rel="icon" href={iconPath} />
<meta name="description" content={description} />
<meta name="generator" content="Quartz" />

{css.map((resource) => CSSResourceToStyleElement(resource, true))}
{js
.filter((resource) => resource.loadTime === "beforeDOMReady")
.map((res) => JSResourceToScriptElement(res, true))}
{additionalHead.map((resource) => {
if (typeof resource === "function") {
return resource(fileData)
} else {
return resource
}
})}
</head>
)
}

return Head
}) satisfies QuartzComponentConstructor
Loading
Loading