Just the theme, ma'am.

This commit is contained in:
Ken Sternberg
2026-06-16 12:21:27 -07:00
parent 3149080d47
commit 614aa6e420
22 changed files with 3518 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
README.md
node_modules
_media
!.github/README.md
+13
View File
@@ -0,0 +1,13 @@
# Prettier Ignorefile
## Node
node_modules
## Static Files
**/LICENSE
./README.md
## Build asset directories
coverage
dist
out
+18
View File
@@ -0,0 +1,18 @@
The MIT License (MIT)
Copyright (c) 2026 Authentik Security, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
/**
* @file ESLint Configuration
*
* @import { Config } from "eslint/config";
*/
import { createESLintPackageConfig } from "@goauthentik/eslint-config";
import { defineConfig } from "eslint/config";
// @ts-check
/**
* @type {Config[]}
*/
const eslintConfig = defineConfig(
createESLintPackageConfig({
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
}),
{
rules: {
"no-console": "off",
},
files: ["shared/**"],
},
);
export default eslintConfig;
+13
View File
@@ -0,0 +1,13 @@
/**
* @file Public entry point for `@goauthentik/theme`.
*
* Importing this module registers every token against the shared styleframe
* instance and re-exports the handles + primitives consumers need to author
* component styles or build CSS.
*
* Browser-safe: no Node-only dependencies. Build helpers that touch the
* filesystem live in `./lib/node.js` (exposed via the `./build` subpath).
*/
export { instance, ref, selector, theme, variable } from "./lib/shared.js";
export * from "./lib/tokens/index.js";
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Node-only build helpers for `@goauthentik/theme`.
*
* Re-exports everything the browser entry exports, plus filesystem-touching
* helpers that wrap styleframe's `transpile()` for use from build scripts.
*
* @import { OutputFile } from "@styleframe/transpiler";
*/
/// <reference types="../types/node.js" />
import { writeFile } from "node:fs/promises";
import { instance } from "./shared.js";
import { transpile } from "@styleframe/transpiler";
// Re-export everything the browser entry exposes so consumers can do either
// `import { variable } from "@goauthentik/theme"` (browser-safe) or
// `import { build } from "@goauthentik/theme/build"` (Node-only) from the
// same package without juggling subpaths.
export * from "./shared.js";
export * from "./tokens/index.js";
/**
* @typedef {object} BuildOptions
* @property {string} [outFile] If provided, the generated CSS is written to
* this absolute path in addition to being returned.
*/
/**
* @typedef {object} BuildResult
* @property {string} css The full CSS string emitted by styleframe.
* @property {OutputFile[]} files Raw transpile output for callers that want
* to inspect every file styleframe produced.
*/
/**
* Transpile the configured token tree to CSS.
*
* Equivalent to `npx styleframe build` for the css output type, but returns
* the string directly so build scripts can post-process before writing.
*
* @param {BuildOptions} [options]
* @returns {Promise<BuildResult>}
*/
export async function build(options = {}) {
const output = await transpile(instance, { type: "css" });
const cssFile = output.files.find((file) => file.name.endsWith(".css"));
const css = cssFile?.content ?? "";
if (options.outFile) {
await writeFile(options.outFile, css, "utf-8");
}
return { css, files: output.files };
}
+58
View File
@@ -0,0 +1,58 @@
/**
* @file Styleframe instance and shared primitives for the authentik theme.
*
* This module configures one global {@link Styleframe} instance and re-exports
* its primitives ({@link variable}, {@link theme}, {@link ref}, {@link selector},
* etc.) for use by the per-category token modules under `./tokens/`.
*
* The instance is configured so that:
*
* - Variable names use the `--ak-*` prefix authentik components and brand custom
* CSS expect. Source tokens are written in dot-notation (`color.primary`)
* and the configured name function rewrites that to `ak-color-primary` before
* styleframe prepends the leading `--`.
* - The theme selector matches the existing `html[data-theme="..."]` convention
* used across the authentik stylesheets.
*
* @import { Styleframe, StyleframeOptions } from "@styleframe/core";
*/
import { styleframe } from "styleframe";
/**
* Authentik-specific styleframe configuration.
*
* @type {StyleframeOptions}
*/
export const authentikStyleframeOptions = {
variables: {
name: ({ name }) => "ak-" + name.replace(/\./g, "-"),
},
themes: {
selector: ({ name }) => `html[data-theme="${name}"]`,
},
};
/**
* Singleton styleframe instance used by every token module.
*
* Importing any module under `./tokens/` triggers the side-effects that
* register variables and themes against this instance.
*
* @type {Styleframe}
*/
export const instance = styleframe(authentikStyleframeOptions);
export const {
variable,
theme,
ref,
selector,
atRule,
keyframes,
media,
css,
utility,
modifier,
recipe,
} = instance;
+56
View File
@@ -0,0 +1,56 @@
/**
* @file Color tokens — semantic surface, text, state, and brand colors.
*
* Light values are declared via `variable()`. Dark values are declared inside
* the `dark` theme block so they emit under `html[data-theme="dark"]`.
*
* Link tokens are wired through `ref()` so brand overrides to `color.primary`
* cascade to links without separate overrides. The dark theme intentionally
* re-points links to their own oklab values rather than chaining through
* primary because dark mode links need higher luminance than primary buttons.
*
* `warning` and `danger` deliberately stay on light values in dark mode — state
* colors keep consistent intensity across themes so warnings read as urgent.
*/
import { ref, theme, variable } from "../shared.js";
export const colorAccent = variable("color.accent", "#fd4b2d");
export const colorPrimary = variable("color.primary", "oklab(0.522 -0.0434 -0.1717)");
export const colorPrimaryHover = variable("color.primary-hover", "oklab(0.3763 -0.0324 -0.1182)");
export const colorText = variable("color.text", "oklab(0.1957 -0 0)");
export const colorTextMuted = variable("color.text-muted", "oklab(0.5364 -0.0026 -0.0089)");
export const colorLink = variable("color.link", ref(colorPrimary));
export const colorLinkHover = variable("color.link-hover", ref(colorPrimaryHover));
export const colorLinkVisited = variable("color.link-visited", "oklab(0.3679 0.0535 -0.1797)");
export const colorSurface = variable("color.surface", "oklab(1 -0 0)");
export const colorSurfaceMuted = variable("color.surface-muted", "oklab(0.9551 -0 0)");
export const colorSurfaceRaised = variable("color.surface-raised", "oklab(0.9851 -0 0)");
export const colorBorder = variable("color.border", "#d2d2d2");
export const colorBorderStrong = variable("color.border-strong", "#8a8d90");
export const colorInfo = variable("color.info", "oklab(0.6689 -0.0608 -0.1513)");
export const colorSuccess = variable("color.success", "oklab(0.5549 -0.1071 0.0852)");
export const colorWarning = variable("color.warning", "oklab(0.7864 0.0298 0.1604)");
export const colorDanger = variable("color.danger", "oklab(0.5331 0.1798 0.1034)");
// Dark theme overrides. Surface values are pinned near PatternFly 4's
// BackgroundColor--100 (#151515) and the legacy drawer surface (#18191a) — the
// earlier oklab(0.23) value visibly brightened every PF-backed dark panel.
theme("dark", (ctx) => {
ctx.variable(colorText, "oklab(0.9067 -0 0)");
ctx.variable(colorTextMuted, "oklab(0.7407 -0.0007 -0.0017)");
ctx.variable(colorLink, "oklab(70.367% -0.07498 -0.139)");
ctx.variable(colorLinkHover, "oklab(0.7706 -0.0485 -0.1012)");
ctx.variable(colorLinkVisited, "oklab(0.7137 0.0522 -0.151)");
ctx.variable(colorSurface, "oklab(0.183 -0 0)");
ctx.variable(colorSurfaceMuted, "oklab(0.14 -0 0)");
ctx.variable(colorSurfaceRaised, "oklab(0.225 -0 0)");
ctx.variable(colorBorder, "oklab(0.3906 0.0001 -0.0052)");
ctx.variable(colorBorderStrong, "oklab(0.4602 -0.0003 -0.0034)");
ctx.variable(colorInfo, "oklab(0.7706 -0.0485 -0.1012)");
ctx.variable(colorSuccess, "oklab(0.6488 -0.1066 0.0846)");
});
+15
View File
@@ -0,0 +1,15 @@
/**
* @file Re-exports every public token handle.
*
* Importing this module ensures all token modules execute their side-effects
* — registering variables and theme overrides against the singleton styleframe
* instance in `../shared.js` — before any consumer reaches `transpile()`.
*/
export * from "./color.js";
export * from "./typography.js";
export * from "./spacing.js";
export * from "./shape.js";
export * from "./shadow.js";
export * from "./motion.js";
export * from "./z-index.js";
+34
View File
@@ -0,0 +1,34 @@
/**
* @file Motion tokens — duration and easing.
*
* Reduced motion is expressed two ways. Both override `--ak-duration-normal`
* to `0ms`:
*
* 1. `@media (prefers-reduced-motion: reduce)` — triggers automatically from
* the operating system's accessibility preference. This is the path most
* consumers actually hit.
* 2. `html[data-theme="reduced"]` — opt-in via data attribute, for explicit
* forced-reduced-motion or for previewing the reduced state. This block
* exists primarily so the styleframe DTCG export can represent reduced
* motion as a theme modifier — DTCG has no native concept for media
* queries.
*
* The two emissions are independent and don't conflict; either one alone is
* enough to zero the duration. Keeping both lets us roundtrip the reduced
* state through DTCG-aware tooling without losing the automatic OS trigger.
*/
import { media, theme, variable } from "../shared.js";
export const durationNormal = variable("duration.normal", "250ms");
export const easingStandard = variable("easing.standard", "cubic-bezier(0.645, 0.045, 0.355, 1)");
media("(prefers-reduced-motion: reduce)", (ctx) => {
ctx.selector(":root", (root) => {
root.variable(durationNormal, "0ms");
});
});
theme("reduced", (ctx) => {
ctx.variable(durationNormal, "0ms");
});
+46
View File
@@ -0,0 +1,46 @@
/**
* @file Shadow tokens — sm..xl drop shadows and an inset variant.
*
* Dark theme uses higher opacity to read against the deeper backgrounds. The
* inset shadow uses a near-solid color in dark mode rather than a faded rgba.
*/
import { theme, variable } from "../shared.js";
export const shadowSm = variable(
"shadow.sm",
"0 0.0625rem 0.125rem 0 rgba(3, 3, 3, 0.12), 0 0 0.125rem 0 rgba(3, 3, 3, 0.06)",
);
export const shadowMd = variable(
"shadow.md",
"0 0.25rem 0.5rem 0rem rgba(3, 3, 3, 0.12), 0 0 0.25rem 0 rgba(3, 3, 3, 0.06)",
);
export const shadowLg = variable(
"shadow.lg",
"0 0.5rem 1rem 0 rgba(3, 3, 3, 0.16), 0 0 0.375rem 0 rgba(3, 3, 3, 0.08)",
);
export const shadowXl = variable(
"shadow.xl",
"0 0.75rem 1.5rem 0 rgba(3, 3, 3, 0.2), 0 0 0.5rem 0 rgba(3, 3, 3, 0.1)",
);
export const shadowInset = variable("shadow.inset", "inset 0 0 0.625rem 0 rgba(3, 3, 3, 0.25)");
theme("dark", (ctx) => {
ctx.variable(
shadowSm,
"0 0.0625rem 0.125rem 0 rgba(3, 3, 3, 0.48), 0 0 0.125rem 0 rgba(3, 3, 3, 0.24)",
);
ctx.variable(
shadowMd,
"0 0.25rem 0.5rem 0rem rgba(3, 3, 3, 0.48), 0 0 0.25rem 0 rgba(3, 3, 3, 0.24)",
);
ctx.variable(
shadowLg,
"0 0.5rem 1rem 0 rgba(3, 3, 3, 0.64), 0 0 0.375rem 0 rgba(3, 3, 3, 0.32)",
);
ctx.variable(
shadowXl,
"0 0.75rem 1.5rem 0 rgba(3, 3, 3, 0.8), 0 0 0.5rem 0 rgba(3, 3, 3, 0.4)",
);
ctx.variable(shadowInset, "inset 0 0 0.625rem 0 #030303");
});
+12
View File
@@ -0,0 +1,12 @@
/**
* @file Shape tokens — border radii and stroke widths.
*/
import { variable } from "../shared.js";
export const radiusSm = variable("radius.sm", "3px");
export const radiusPill = variable("radius.pill", "30rem");
export const borderWidthSm = variable("border-width.sm", "1px");
export const borderWidthMd = variable("border-width.md", "2px");
export const borderWidthLg = variable("border-width.lg", "3px");
+14
View File
@@ -0,0 +1,14 @@
/**
* @file Spacing tokens — single scale from xs (4px) to 4xl (80px) at 16px base.
*/
import { variable } from "../shared.js";
export const spaceXs = variable("space.xs", "0.25rem");
export const spaceSm = variable("space.sm", "0.5rem");
export const spaceMd = variable("space.md", "1rem");
export const spaceLg = variable("space.lg", "1.5rem");
export const spaceXl = variable("space.xl", "2rem");
export const space2xl = variable("space.2xl", "3rem");
export const space3xl = variable("space.3xl", "4rem");
export const space4xl = variable("space.4xl", "5rem");
+31
View File
@@ -0,0 +1,31 @@
/**
* @file Typography tokens — font families, sizes, line heights, weights.
*
* Font families fall through to generic CSS `var(...)` references defined
* upstream by the existing `fonts.css`. Sizes follow a modular xs..4xl scale.
* `semi-bold` is deliberately omitted from the public surface because
* PatternFly's `--pf-global--FontWeight--semi-bold` collapses to 700 (same as
* bold) unless the Overpass font scale is active.
*/
import { variable } from "../shared.js";
export const fontFamilyBody = variable("font.family-body", "var(--ak-font-family-sans-serif)");
export const fontFamilyHeading = variable("font.family-heading", "var(--ak-generic-display)");
export const fontFamilyCode = variable("font.family-code", "var(--ak-font-family-monospace)");
export const fontSizeXs = variable("font.size-xs", "0.75rem");
export const fontSizeSm = variable("font.size-sm", "0.875rem");
export const fontSizeMd = variable("font.size-md", "1rem");
export const fontSizeLg = variable("font.size-lg", "1.125rem");
export const fontSizeXl = variable("font.size-xl", "1.25rem");
export const fontSize2xl = variable("font.size-2xl", "1.5rem");
export const fontSize3xl = variable("font.size-3xl", "1.75rem");
export const fontSize4xl = variable("font.size-4xl", "2.25rem");
export const fontWeightLight = variable("font.weight-light", 300);
export const fontWeightNormal = variable("font.weight-normal", 400);
export const fontWeightBold = variable("font.weight-bold", 700);
export const lineHeightSm = variable("line-height.sm", 1.3);
export const lineHeightMd = variable("line-height.md", 1.5);
+16
View File
@@ -0,0 +1,16 @@
/**
* @file Z-index tokens — xs..2xl tiers matching PatternFly 4's scale.
*
* Spelled out as `z-index.*` rather than `z.*` so the emitted CSS
* (`--ak-z-index-md`) is unambiguous in brand custom CSS and IDE
* autocomplete.
*/
import { variable } from "../shared.js";
export const zIndexXs = variable("z-index.xs", 100);
export const zIndexSm = variable("z-index.sm", 200);
export const zIndexMd = variable("z-index.md", 300);
export const zIndexLg = variable("z-index.lg", 400);
export const zIndexXl = variable("z-index.xl", 500);
export const zIndex2xl = variable("z-index.2xl", 600);
+2656
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
{
"name": "@goauthentik/theme",
"version": "1.0.0",
"description": "Styleframe-based design system for authentik.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/goauthentik/authentik.git",
"directory": "packages/theme"
},
"scripts": {
"build": "npm run build:types && npm run build:assets && npm run build:dtcg && npm run build:format",
"build:assets": "node scripts/build.mjs",
"build:dtcg": "styleframe dtcg export --config styleframe.config.mjs --output dist/tokens.dtcg.json --collection authentik",
"build:format": "node scripts/format-dist.mjs",
"build:types": "tsc -p .",
"lint": "eslint --fix .",
"lint-check": "eslint --max-warnings 0 .",
"prepare": "npm run build",
"prettier": "prettier --write .",
"prettier-check": "prettier --check .",
"watch": "tsc -p . --watch"
},
"type": "module",
"types": "./out/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./out/index.d.ts",
"node": "./lib/node.js",
"browser": "./index.js",
"import": "./index.js"
},
"./build": {
"types": "./out/lib/node.d.ts",
"import": "./lib/node.js"
},
"./index.css": "./dist/index.css",
"./color.css": "./dist/color.css",
"./typography.css": "./dist/typography.css",
"./spacing.css": "./dist/spacing.css",
"./shape.css": "./dist/shape.css",
"./shadow.css": "./dist/shadow.css",
"./motion.css": "./dist/motion.css",
"./z-index.css": "./dist/z-index.css",
"./tokens.dtcg.json": "./dist/tokens.dtcg.json",
"./tokens.dtcg.resolver.json": "./dist/tokens.dtcg.resolver.json"
},
"dependencies": {
"@styleframe/core": "^3.5.0",
"@styleframe/dtcg": "^1.1.0",
"@styleframe/theme": "^3.7.0",
"@styleframe/transpiler": "^3.3.0",
"styleframe": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@goauthentik/prettier-config": "../prettier-config",
"@goauthentik/eslint-config": "../eslint-config",
"@goauthentik/tsconfig": "../tsconfig",
"@styleframe/cli": "^4.0.0",
"@types/node": "^25.7.0",
"eslint": "^9.39.3",
"pino": "^10.3.1",
"pino-pretty": "^13.1.2",
"prettier": "^3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3"
},
"files": [
"./index.js",
"lib/**/*",
"out/**/*",
"dist/**/*"
],
"engines": {
"node": ">=24",
"npm": ">=11.14.1"
},
"devEngines": {
"runtime": {
"name": "node",
"version": ">=24",
"onFail": "ignore"
},
"packageManager": {
"name": "npm",
"version": ">=11.14.1",
"onFail": "ignore"
}
},
"prettier": "@goauthentik/prettier-config",
"overrides": {
"@typescript-eslint/eslint-plugin": {
"typescript": "$typescript"
},
"@typescript-eslint/parser": {
"typescript": "$typescript"
},
"format-imports": {
"eslint": "$eslint"
},
"typescript-eslint": {
"typescript": "$typescript"
}
},
"peerDependenciesMeta": {
"pino": {
"optional": true
},
"pino-pretty": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* @file Build script for `@goauthentik/theme`.
*
* Runs the styleframe transpiler against the configured token tree, then
* fans out the single emitted CSS string into one file per category
* (color, typography, spacing, shape, shadow, motion, z-index) and a top-
* level `index.css` that `@import`s them in deterministic order.
*
* Consumers can import the whole surface
* (`@import "@goauthentik/theme/index.css"`) or cherry-pick a category
* (`@import "@goauthentik/theme/color.css"`).
*
* Invoked by `npm run build:assets` and chained from `npm run build`.
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "../lib/node.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = resolve(__dirname, "..");
const OUT_DIR = resolve(PACKAGE_ROOT, "dist");
/**
* @typedef {object} Category
* @property {string} name Slug used for the output filename.
* @property {string[]} prefixes
* Token-name prefixes (after the `--ak-` strip) that belong to this category.
*/
/** @type {Category[]} */
const CATEGORIES = [
{ name: "color", prefixes: ["color-"] },
{
name: "typography",
prefixes: ["font-family-", "font-size-", "font-weight-", "line-height-"],
},
{ name: "spacing", prefixes: ["space-"] },
{ name: "shape", prefixes: ["radius-", "border-width-"] },
{ name: "shadow", prefixes: ["shadow-"] },
{ name: "motion", prefixes: ["duration-", "easing-"] },
{ name: "z-index", prefixes: ["z-index-"] },
];
const HEADER = [
"/*",
" * ⚠️ GENERATED FILE — do not edit directly.",
" *",
" * Source: packages/theme/lib/tokens/*.js",
" * Built: packages/theme/scripts/build.mjs",
" */",
"",
].join("\n");
/**
* One emitted block within the styleframe CSS output. `header` is the line
* that opens the block (`:root {`, `@media (...) {`, `html[data-theme="..."] {`).
* `prefix` is whitespace that should precede declarations inside the block,
* preserved verbatim from the source. `closer` is the closing brace(s).
*
* @typedef {object} ParsedBlock
* @property {string} header
* @property {string[]} declarations
* @property {string} closer
*/
/**
* Split the styleframe CSS output into top-level blocks. Each block is one of:
*
* :root { … }
* html[data-theme="…"] { … }
* @media (…) { :root { … } }
*
* Nested `:root` inside `@media` is preserved as part of the block — the
* inner declarations are kept as a flat list and re-wrapped on emit.
*
* @param {string} css
* @returns {ParsedBlock[]}
*/
function parseBlocks(css) {
/** @type {ParsedBlock[]} */
const blocks = [];
// Top-level blocks separated by blank lines in styleframe's output.
// Each block ends with a balanced closing brace at column zero. We don't
// need a real CSS parser — the styleframe emitter is regular enough that
// a small line-based state machine handles it.
const lines = css.split("\n");
let i = 0;
while (i < lines.length) {
while (i < lines.length && (lines[i] ?? "").trim() === "") i++;
if (i >= lines.length) break;
const opener = lines[i] ?? "";
const header = opener.trim();
if (!header.endsWith("{")) {
i++;
continue;
}
const openerIndent = opener.match(/^\s*/)?.[0] ?? "";
i++;
/** @type {string[]} */
const innerLines = [];
while (i < lines.length) {
const line = lines[i] ?? "";
const trimmed = line.trim();
const indent = line.match(/^\s*/)?.[0] ?? "";
if (trimmed === "}" && indent === openerIndent) {
i++;
break;
}
innerLines.push(line);
i++;
}
blocks.push({
header,
declarations: innerLines,
closer: "}",
});
}
return blocks;
}
/**
* Extract every `--ak-*: …;` declaration from a flat list of lines (which may
* include a nested `:root { … }` wrapper from an `@media` block).
*
* @param {string[]} lines
* @returns {string[]}
*/
function flattenDeclarations(lines) {
return lines.filter((line) => /^\s*--ak-[a-z0-9-]+\s*:/.test(line));
}
/**
* Build the CSS for one category by filtering each parsed block to that
* category's declarations and re-wrapping them.
*
* @param {Category} category
* @param {ParsedBlock[]} blocks
*/
function buildCategoryFile(category, blocks) {
/** @param {string} line */
const matches = (line) => {
const declaration = line.match(/--ak-([a-z0-9-]+):/);
if (!declaration || !declaration[1]) return false;
const name = declaration[1];
return category.prefixes.some((prefix) => name.startsWith(prefix));
};
/** @type {string[]} */
const sections = [];
for (const block of blocks) {
if (block.header.startsWith("@media")) {
// Drill into the inner `:root { … }` wrapper.
const inner = flattenDeclarations(block.declarations).filter(matches);
if (inner.length === 0) continue;
sections.push(
`${block.header}\n\t:root {\n${inner
.map((line) => "\t\t" + line.trim())
.join("\n")}\n\t}\n}`,
);
continue;
}
const filtered = block.declarations.filter(matches);
if (filtered.length === 0) continue;
sections.push(
`${block.header}\n${filtered.map((line) => "\t" + line.trim()).join("\n")}\n}`,
);
}
if (sections.length === 0) return null;
return HEADER + sections.join("\n\n") + "\n";
}
/**
* Build the index.css that `@import`s every emitted category file in order.
*
* @param {string[]} emittedNames
*/
function buildIndex(emittedNames) {
const imports = emittedNames.map((name) => `@import "./${name}.css";`).join("\n");
return HEADER + imports + "\n";
}
await mkdir(OUT_DIR, { recursive: true });
const { css } = await build();
const blocks = parseBlocks(css);
const emitted = [];
for (const category of CATEGORIES) {
const content = buildCategoryFile(category, blocks);
if (content === null) {
console.warn(`⚠️ No declarations found for category ${category.name}; skipping.`);
continue;
}
await writeFile(resolve(OUT_DIR, `${category.name}.css`), content, "utf-8");
emitted.push(category.name);
}
await writeFile(resolve(OUT_DIR, "index.css"), buildIndex(emitted), "utf-8");
console.log(`✅ Wrote dist/index.css + ${emitted.length} category files (${emitted.join(", ")})`);
+56
View File
@@ -0,0 +1,56 @@
/**
* @file Post-build prettier pass over `dist/`.
*
* Both the styleframe transpiler and the styleframe DTCG CLI emit valid but
* not-particularly-pretty output (tab indentation, long lines that aren't
* wrapped, mixed spacing in shadow values). Running prettier afterwards
* gives anyone inspecting the published artefacts — `dist/index.css`,
* `dist/color.css`, `dist/tokens.dtcg.json`, etc. — a consistent and
* readable shape.
*
* Run as the last step of `npm run build` so every file consumers see is
* formatted with the repository's own prettier config.
*/
import { readdir, readFile, writeFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { AuthentikPrettierConfig } from "@goauthentik/prettier-config";
import prettier from "prettier";
const __dirname = resolve(fileURLToPath(import.meta.url), "..");
const PACKAGE_ROOT = resolve(__dirname, "..");
const DIST_DIR = resolve(PACKAGE_ROOT, "dist");
/** Parsers prettier should pick for each extension. */
const PARSER_BY_EXT = /** @type {const} */ ({
".css": "css",
".json": "json",
});
const entries = await readdir(DIST_DIR, { withFileTypes: true });
let formatted = 0;
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = extname(entry.name);
const parser = PARSER_BY_EXT[/** @type {keyof typeof PARSER_BY_EXT} */ (ext)];
if (!parser) continue;
const path = resolve(DIST_DIR, entry.name);
const raw = await readFile(path, "utf-8");
const output = await prettier.format(raw, {
...AuthentikPrettierConfig,
filepath: path,
parser,
});
if (output !== raw) {
await writeFile(path, output, "utf-8");
}
formatted++;
}
console.log(`✅ Formatted ${formatted} file(s) in dist/`);
+18
View File
@@ -0,0 +1,18 @@
/**
* @file Styleframe CLI entry point.
*
* The styleframe CLI (used by `npm run build:dtcg`) loads this file via jiti
* and expects a default export that is a configured {@link Styleframe} instance
* with all variables/themes already registered.
*
* Importing `./lib/tokens/index.js` triggers the side-effects that register
* every token against the shared instance; we re-export `instance` as the
* default export so the CLI's `dtcg export` and `build` commands operate on
* the same tree the rest of the package uses.
*/
import "./lib/tokens/index.js";
import { instance } from "./lib/shared.js";
export default instance;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"lib": ["ESNext"],
"resolveJsonModule": true,
"checkJs": true,
"emitDeclarationOnly": true
},
"exclude": [
// ---
"**/out/**/*"
]
}
+27
View File
@@ -0,0 +1,27 @@
declare module "process" {
import { Level } from "pino";
global {
namespace NodeJS {
interface ProcessEnv {
/**
* An environment variable used to determine
* whether Node.js is running in production mode.
*
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
*/
readonly NODE_ENV?: "development" | "production";
/**
* Whether or not we are running on a CI server.
*/
readonly CI?: string;
/**
* The application log level.
*/
readonly AK_LOG_LEVEL?: Level;
}
}
}
}