P69 allows compile time tokens to be used for CSS within Node based projects.
It scans CSS for placeholder tokens which are substituted for user defined values. It's just a glorified string.replace
.
This tool is straight up optimised for my tastes which means taking the light touch. In general, the design trade-offs lean towards simplicity, readability, and changability.
export default {
color: {
normal: 'burlywood',
highlight: 'crimson ',
},
font: {
size: {
sm: '0.8rem',
md: '1rem',
lg: '1.2rem',
},
},
hello: (name = 'Joe') => {
return '"Hello ' + name + '"'
},
}
.random-class {
color: $color.normal;
font-size: $font.size.md;
&:hover {
color: &color.highlight;
}
&:after {
content: $hello(); // "Hello Joe"
content: $hello("Jenny"); // "Hello Jenny"
}
}
See sveltekit-minimalist-template for example project usage.
{
"devDependencies": {
"p69": "3.x.x"
}
}
First create a map of your tokens in JavaScript. I recommend creating a file and exporting. Call it whatever you like.
There are no standards or conventions on how one should organise their tokens. Do what works, not what just happens to be trending!
Here's a rough example:
// tokens.js
import myColors from './my-colors.js'
export default {
// Used for creating string literals such as those
// containing '$'.
toString: (s = '') => s.toString(),
// Split out parts into meaningfully named files.
color: myColors,
// Create hierarchies to meaningfully structure tokens.
//
// However, if you employ a design system or design tokens
// then you should probably derive your structure from there.
font: {
family: {
helvetica: ['Helvetica', 'Arial', 'Verdana'], // $font.family.helvetica;
verdana: ['Verdana', 'Arial', 'Helvetica'], // $font.family.verdana;
},
size: {
sm: 12, // $font.size.sm;
md: 16, // $font.size.md;
lg: 20, // $font.size.lg;
xl: 24, // $font.size.xl;
},
},
}
Definition:
p69([...])
. Each map is checked in turn when attempting to resolve a token.Usage:
$
.$func(1, 2, 3)
.$func
== $func()
.Interesting useless side effect: you can pass arguments to a non-function; it's pointless however since they're not used in processing.
There's no escape character for the $
symbol. It's easy enough to write a token for it. A few possibilities:
export const escapeMethods = {
// The simplest approach is to just to use $$, $$$, $$$$, etc.
// Add more as you need.
$: '$',
$$: '$$',
$$$: '$$$',
// We can create a single function that handles all unbroken
// series of $.
//
// $$ => $
// $$(2) => $$
// $$(3) => $$$
$: (n = 1) => '$'.repeat(n),
// literal accepts a value and returns it. This allows values
// containing $ anywhere within to be escaped easily.
//
// $literal("$$$") => $$$
// $literal("$ one $$ two $$$ three") => $ one $$ two $$$ three
literal: (v = '') => v.toString(),
// The world's your Mollusc. You can create any kind of
// function to escape however you please. Here's a quotation
// function.
//
// $quote('Lots of $$$') => "Lots of $$$"
// $quote('Lots of $$$', '`') => `Lots of $$$`
quote: (v, glyph = '"') => glyph + v.toString() + glyph,
}
import { stringP69 } from 'p69'
const tokens = {
font: {
family: {
verdana: ['Verdana', 'Arial', 'Helvetica'],
},
},
}
const before = 'main { font-family: $font.family.verdana; }'
const after = stringP69(tokens, before)
// after: "main { font-family: Verdana, Arial, Helvetica; }"
stringP69(tokens, css, {
// ref is a useful identifer for when onError
// is called. The default onError will print it out.
// Typically a filename but any identifer you find
// meaningful will do.
ref: '¯\\_(ツ)_/¯',
// throwIfMissing will throw an error, after onError
// is called, if a style token can't be found in the
// provided mappings.
throwIfMissing: true,
// onError is called when an error occurs.
// If the error isn't thrown then processing will
// continue for the remaining tokens.
// By default, logs the error and carries on.
onError: (err, token, options) => {},
})
P69 files are CSS files containing P69 tokens.
import { filesP69 } from 'p69'
const tokens = {
theme: {
strong: 'burlywood',
},
font: {
family: {
verdana: ['Verdana', 'Arial', 'Helvetica'],
},
},
}
await filesP69(tokens)
await filesP69(tokens, {
// See stringP69 options.
...stringP69.options,
// src directory containing .p69 files that need
// to be converted to CSS. If null then .p69 file
// processing is skipped.
src: './src',
// out is the file path to merge all processed .p69
// files into. This does not include style content from
// framework files. If null, a .css file will be
// created for each .p69 file in the same directory as it.
//
// There are virtues and vices to each approach but
// amalgamation works better for smaller projects while
// big projects usually benefit from more rigorous
// modularisation.
out: './src/app.css',
})
/* styles.p69 */
.text-strong {
color: $theme.strong;
font-weight: bold;
}
.text-fancy {
font-family: $font.family.spectral;
font-style: italic;
}
Unfortunatly, I've had little success in getting a JavaScript token file and its dependencies to reload on change. I can get a single file and I can reload a whole directory, albeit a little leaky. ECMAScript modules were designed to load once and once only. I may apply the directory approach in a future update.
import { watchP69 } from 'p69'
const tokens = {
theme: {
strong: 'burlywood',
},
font: {
family: {
verdana: ['Verdana', 'Arial', 'Helvetica'],
},
},
}
// Does not block.
// Currently uses chokidar.
const terminateWatcher = watchP69(tokens)
await terminateWatcher()
watchP69(tokens, {
// See filesP69 options.
...filesP69.options,
// chokidar is passed to chokidar as options.
// See https://github.com/paulmillr/chokidar.
chokidar: {},
})
// svelte.config.js
import { svelteP69, watchP69, filesP69 } from 'p69'
import tokens from './src/tokens.js'
// Only needed if you're using .p69 files.
// Compiles all into ./src/app.css by default.
if (process.env.NODE_ENV === 'development') {
watchP69(tokens)
} else {
await filesP69(tokens)
}
export default {
...,
preprocess: [svelteP69(tokens)],
...,
}
svelteP69(tokens, {
// See svelteP69 options.
...filesP69.options,
// langs is a list of accepted lang attibute values.
// Undefined means any style tag with no lang set
// will assumed to be P69 parsable.
langs: [undefined, 'p69', 'text/p69'],
})
<!-- StyledSection.svelte -->
<script>
export let title
</script>
<section>
<h2>{title}</h2>
<slot />
</section>
<style>
section {
background: $color.base;
border-radius: 4px;
overflow: hidden;
}
section h2 {
font-size: $font.size.lg.rem;
color: $color.strong;
}
@media $screen.larger_devices {
section h2 {
font-size: $font.size.xl.rem;
}
}
section :global(p) {
font-family: $font.family.helvetica;
font-size: $font.size.md.rem;
color: $color.text;
margin-top: $space.md.em;
}
section :global(strong) {
color: $color.strong;
}
</style>
Optional utility functions to use in your token maps. Don't be limited by what I've done. Write your own if you want.
import { rgbsToColors, themeVariables, colorSchemes, sizer } from 'p69/util'
Converts a map of RGB and RGBA arrays to CSS RGB and RGBA values.
rgbsToColors(rgbMap) cssColorMap
import { rgbsToColors } from 'p69/util'
const colors = rgbsToColors({
burly_wood: [222, 184, 135],
burly_wood_lucid: [222, 184, 135, 0.5],
ice_cream: [250, 250, 250],
jet_blue: [30, 85, 175],
dark_navy_grey: [5, 10, 60],
dark_navy_grey_lucid: [5, 10, 60, 0.5],
})
console.log(colors) // Use console.table for easy reading
/*
{
burly_wood: "rgb(222, 184, 135)",
burly_wood_lucid: "rgba(222, 184, 135, 0.5)",
ice_cream: "rgb(250, 250, 250)",
jet_blue: "rgb(30, 85, 175)",
dark_navy_grey: "rgb(5, 10, 60)",
dark_navy_grey_lucid: "rgba(5, 10, 60, 0.5)",
}
*/
Generates CSS color scheme media queries from a set of themes; goes hand-in-hand with themeVariables.
themeVariables(themes, prefix) mediaQueries
import { colorSchemes } from 'p69/util'
const themes = {
// P69 doesn't care what the theme names are but browsers do!
light: {
base: [250, 250, 250],
text: [5, 10, 60],
},
dark: {
base: [5, 10, 35],
text: [231, 245, 255],
},
}
const scheme = colorSchemes(themes, 'theme-primary')
console.log(scheme)
/*
`@media (prefers-color-scheme: light) {
:root {
--theme-primary-base: rgb(250, 250, 250);
--theme-primary-text: rgb(5, 10, 60);
}
}
@media (prefers-color-scheme: dark) {
:root {
--theme-primary-base: rgb(5, 10, 35);
--theme-primary-text: rgb(231, 245, 255);
}
}`
*/
Generates a set of CSS variables from a set of themes; goes hand-in-hand with colorSchemes.
colorSchemes(themes, prefix) varMap
import { themeVariables } from 'p69/util'
const themes = {
// P69 doesn't care what the theme names are but browsers do!
light: {
base: [250, 250, 250],
text: [5, 10, 60],
},
dark: {
base: [5, 10, 35],
text: [231, 245, 255],
meh: [0, 0, 0],
},
}
const theme = themeVariables(themes, 'theme-primary')
console.log(theme)
/*
{
base: "var(--theme-primary-base)",
text: "var(--theme-primary-text)",
meh: "var(--theme-primary-meh)",
}
*/
Generates a set of size maps mapping a pixel value to other units.
sizer(tokens, base) sizeMap
Everything is in reference to 96 DPI. sizeMap schema:
{
token_name: {
px, // 1dp
em, // 2dp
rem, // 2dp
pt, // 2dp
pc, // 1dp
in, // 3dp
cm, // 2dp
mm, // 1dp
}
}
Example:
import { sizer } from 'p69/util'
const tokens = {
width: sizer(
{
min: 320,
sm: 720,
md: 920,
lg: 1200,
xl: 1600,
}
// base: 16,
),
}
const css = `
main {
/* width: 45rem (720px at 16px per rem) */
width: $width.sm.rem;
}
/* min-width: 920px */
@media (min-width: $width.md.px) {
main {
/* max-width: 1600px */
max-width: $width.xl.px;
}
}
`