Skip to main contentSkip to docs navigation

Build System

The commands of the Chassis Assets build, its configuration, the rules of each platform, the checks, the library and its use in CI.

Introduction

The build copies the files of source/<brand>/<app>/<type>/ to dist/<platform>/<app>/<brand>/. On the way it leaves out the formats a platform does not use, renames each file for the platform, and sorts Android image variants into density folders. By default it does not resize, convert or re-encode a file: the options of Optimization and Image formats do the last two.

This page is the reference of the build: its commands and options, the chassis block that configures it, the rules of each platform, the checks that read its output, and the library behind the commands.

Prerequisites

The build needs Node.js 22.12 or later and nothing installed: it imports Node.js modules only. Run pnpm install for the tests, the type check, the documentation site and the options that use a package: --vector-drawables, --optimize, --webp, --avif and --subset.

Run every command from the repository root. A clone needs its Git LFS files, see Build fails on pointers.

Commands

Each command is a script of the root package.json, and each pnpm assets* command prints its options with --help.

CommandWhat it does
pnpm assetsBuilds every job of the configuration into dist/
pnpm assets:siteBuilds the web output of the configured docs app for the chassis brand, which the Chassis sites read
pnpm assets:lfsPrints the Git LFS paths that a build with the same filters reads, for git lfs pull --include
pnpm assets:validateChecks an existing output against the source and the configuration
pnpm assets:contractChecks that an existing output has the files its consumers read by name
pnpm assets:verifyRuns the validator, then the contract check
pnpm assets:lint:sourceChecks the names and the layout of source/, and the fonts against their stylesheets
pnpm assets:analyzeReports sizes, types, the largest files and files with the same content
pnpm testRuns the tests of the build
pnpm cleanRemoves dist/ and the built site

Build options

The options of pnpm assets select the jobs, the folders and what happens to the output that is already there.

OptionWhat it does
--brand <name...>Only these brands
--app <name...>Only these apps
--platform <name...>Only these platforms
--type <name...>Only these type folders of an app, such as images and icons
--include <pattern...>Only the files that match a pattern, by their path in the folder of the app, such as "images/home/**"
--cleanRemove the output first, of the selected jobs when filtered
--no-cleanKeep the output even for a full build
--out <dir>Output folder, dist by default
--cwd <dir>Repository root, by default the nearest folder upward whose package.json has a chassis block
--watchBuild, then build the jobs of a brand and an app again when a file of theirs under source/ changes
--dry-runPrint the jobs and their file counts, write nothing
--allow-lfs-pointersCopy Git LFS pointer files instead of failing. CHASSIS_ALLOW_LFS_POINTERS=1 does the same
--vector-drawablesWrite the SVG icons of Android as vector drawables, .xml in place of .svg. Needs pnpm install
--asset-catalogWrite the images of iOS as an asset catalog, Assets.xcassets in place of images/
--resWrite the fonts, images and icons of Android as a res/ folder, res/font/ and res/drawable*/
--optimizeWrite the images again under their names where that makes them smaller, with the settings of chassis.optimize. Needs pnpm install
--webpWrite the PNG and JPEG images as WebP too: beside the file on the web, in place of it on Android. Needs pnpm install
--avifWrite the PNG and JPEG images of the web as AVIF too. Needs pnpm install
--subset [range...]Write the WOFF and WOFF2 fonts of the web again under their names with the characters of the ranges only, such as latin or U+0370-03FF. Default: chassis.subset, or latin and latin-ext. Needs pnpm install
--split, --no-splitWith --subset, write each font that a stylesheet declares once per range too, with a rule per range in the stylesheet, or write a font in one file. Default: split of chassis.subset, or one file
--quietPrint errors only
--help, -hPrint the options
--version, -vPrint the version

Filters

A filter takes one or more values, and the filters combine: the build runs the jobs that match all of them.

Shell
pnpm assets --brand chassis
pnpm assets --platform ios android
pnpm assets --brand chassis --app docs --platform web

A value that the configuration does not have fails the build and names the configured values. Filters that match no job together, such as an app with a platform it is not built for, fail the build too.

File filters

The --type and --include options build a part of a job: the build reads only the files of an app that match, and writes them where a full build does, with the same names. Use them when you read a part of the output, to fetch and copy that part only.

Shell
pnpm assets --type images icons
pnpm assets --brand chassis --app docs --include "images/home/**" "icons/cx-sprite.svg"

--type names type folders. --include takes patterns of paths as they are in source/, relative to the folder of the app, so with the source names and not the names of a platform. A file has to pass both options when both are given.

PatternMatches
icons/cx-sprite.svgThat file
images/homeEvery file under the folder, at any depth
images/*The files of images/ itself, not of its subfolders
images/**/*.svgThe SVG files of images/, at any depth
images/home/hero-{light,dark}{,@2x}.pngFour files: {a,b} is one of its alternatives, an empty one too

Quote a pattern, or the shell expands it. A build with a file filter keeps the rest of dist/, as a build with any filter does. Filters that match no file fail the build. Filters that match no file of one job among several warn, and that job gets no folder. The validator checks a full build, so pnpm assets:verify fails on an output of a part.

A file that the filters leave out is not read, so it may be a Git LFS pointer. pnpm assets:lfs takes the same filters and prints the paths of source/ that the build reads, separated by commas, which is the value git lfs pull --include takes. A folder whose files are all read is one path. In a clone made without the Git LFS files, fetch the files of a build and run it:

Shell
FILTERS=(--brand chassis --app docs --include "images/home/**" "icons/cx-sprite.svg")
git lfs pull --include "$(node packages/assets/build/cli.js lfs "${FILTERS[@]}")"
pnpm assets "${FILTERS[@]}"

The first command runs the script with node, since pnpm prints the name of a script before its output; pnpm --silent assets:lfs prints the paths alone too.

Cleaning

A build without filters removes dist/ first, so the output holds nothing of an earlier build. A build with filters keeps dist/ and writes over the files of its jobs, so you can build one job after another.

Shell
pnpm assets                                    # removes dist/, builds every job
pnpm assets --no-clean                         # keeps dist/, builds every job
pnpm assets --brand chassis --platform web     # keeps dist/, builds the selected jobs
pnpm assets --clean --brand chassis --app docs # removes the folders of the selected jobs first

With filters, --clean removes the folders of the selected jobs only. The output of the other jobs stays.

Watch

The --watch option builds once, then keeps running and builds again when a file under source/ changes. Use it while you export from a design tool, with the filters of the jobs you look at:

Shell
pnpm assets --watch --platform ios --app demo

A change builds the jobs it belongs to, and no other. A file of a brand builds the jobs of that brand and its app. A file of the default brand builds the jobs of its app for every brand, since every brand gets it. Changes that come together, as an export of many files does, are one build.

A job is built whole, into a folder that is removed first, so a file you delete from source/ is gone from the output and the output of a job is always what a build of it writes. Every other option of the build applies to each of these builds. A build that fails prints its error, and the watch goes on. Hidden files and the other files the build ignores start no build. Stop the watch with Ctrl + C.

The watch reads package.json when it starts: after a change to the chassis block, start it again.

Vector drawables

The --vector-drawables option writes each SVG file under icons/ of an Android job as a vector drawable, the format the res/drawable/ folder of an app takes. It is off by default, and the output of every other platform and type stays as it is.

Shell
pnpm install
pnpm assets --vector-drawables

The file keeps its folder and its name and gets the .xml extension: icons/svgs/ic_arrow_right_solid.svg becomes icons/svgs/ic_arrow_right_solid.xml. A path without a fill color is filled black, which a tint replaces in the app.

The conversion is the one step of the build that uses a package, svg2vectordrawable, and the build loads it only with this option. Without the package the build stops before it removes anything and names pnpm install as the fix. An SVG without a shape, such as the SVG file of an icon font, stays an SVG and the build warns about it. A file that is not an SVG fails the build.

Asset catalog

The --asset-catalog option moves the images of an iOS job from images/ into Assets.xcassets, the folder Xcode reads image sets from. It is off by default, needs nothing installed, and leaves the output of every other platform and type as it is.

Shell
pnpm assets --asset-catalog

The files of one base name are one image set, with the Contents.json that Xcode writes: images/badge.png, images/badge@2x.png and images/badge@3x.png become Assets.xcassets/badge.imageset/, each file in the slot of its indicator. An SVG or a PDF without a raster of its name is an image set of one file that keeps its vector data. A subfolder of images/ is a folder of the catalog that provides a namespace, so images/logo/mark@2x.png is in Assets.xcassets/logo/mark.imageset/ and an app names it logo/mark.

A file without a place in an image set stays in images/, and the build warns once per job with the number of such files. These are an SVG or a PDF beside a raster of its name, a variant with another indicator than @1x, @2x or @3x, a second file for one slot, and any format but PNG, JPEG, SVG and PDF. The build does not pair a light and a dark image, and the icons of icons/ stay files.

Optimization

The --optimize option writes each image of images/ again under its own name, when that makes the file smaller. It is off by default: the files of source/ are what the designer exported, and the default build copies them as they are.

Shell
pnpm install
pnpm assets --optimize

Without settings the option changes no pixel that anyone sees. What it does to a format:

FormatWithout settingsWith a setting of chassis.optimize
PNGCompressed again, every pixel keptpng.quality reduces the colors to a palette
JPEGLeft as it is: a JPEG cannot be encoded again without a lossjpeg.quality encodes it again
SVGComments, metadata and white space removed, the shapes keptsvg.precision rounds the coordinates and rewrites the paths
WebP, GIF and any otherLeft as it is

A file keeps its name on every platform, so nothing that reads the output changes. A file that would not get smaller stays as it was copied. The ids of an SVG and the symbols of a sprite are kept, since a page refers to them from outside the file. A file with the extension of an image that is no image fails the build, which names the file.

The option uses the packages sharp and svgo, which the build loads only with it. Without the packages the build stops before it removes anything and names pnpm install as the fix. The chassis.optimize block has the settings.

The archives of a release are built with this option, and with no other: an archive has the files and the names of a default build, with smaller PNG and SVG images.

Image formats

The --webp and --avif options write each PNG and JPEG image of images/ in a second format. Both are off by default, and both use the package sharp.

Shell
pnpm install
pnpm assets --webp          # WebP for the web and Android
pnpm assets --webp --avif   # and AVIF for the web

Where the file is written depends on the platform:

Platform--webp--avif
Webhero.webp beside hero.png, for a <picture> element to choose fromhero.avif beside hero.png
Androidhero.webp in place of hero.png, when it is smaller: a resource has one file per nameNothing
iOSNothingNothing

A WebP of a PNG is lossless without settings, so on Android the image is the same and the file is smaller. A WebP of a JPEG and every AVIF are encoded with the quality of chassis.optimize. An SVG, a GIF and a WebP of the source get no second format.

A file of source/ wins over the one the build would write: when the designer exported hero.webp beside hero.png, that WebP is copied and none is written. With --optimize, the second format is written from the source image, not from the optimized one.

Font subsets

The --subset option writes each WOFF and WOFF2 font of a web job again under its own name, with the characters of a list of Unicode ranges only. It is off by default: a font of source/ holds every script its designer drew, and the default build copies it whole.

Shell
pnpm install
pnpm assets --subset                 # the ranges of chassis.subset, or latin and latin-ext
pnpm assets --subset latin greek     # these ranges
pnpm assets --subset U+0020-007E     # a range by its code points

A range is a name or a range of code points written as U+0370-03FF. A name stands for the code points that Google Fonts serves under it. The values of the option win over the chassis.subset block, and without either the build keeps latin and latin-ext.

NameCharacters
latinBasic Latin and Latin-1, general punctuation and the euro sign: the letters of Western European languages
latin-extThe letters of the other languages written in Latin letters, such as Turkish, Polish and Czech
cyrillic, cyrillic-extCyrillic, and its historic and less common letters
greek, greek-extGreek, and polytonic Greek
vietnameseThe letters and tone marks of Vietnamese
mathMathematical operators, arrows, and the Greek letters and the alphabets of formulas
symbolsArrows, geometric shapes, dingbats and pictographs
symbols2Box drawing and block characters, such as the lines of a file tree in a code font

A font keeps its name, its format and its layout features, such as the ligatures of a code font, so the stylesheets of fonts/ and the pages that load them stay as they are. A character outside the ranges is drawn with the next font of the font-family list. A font that would not get smaller stays as it was copied, and so does a font that has no character of the ranges, with a warning that names it.

A variable font keeps its axes. A browser uses an axis without being asked: it sets the optical size, opsz, from the font size of the text, so a heading is drawn in another cut than a paragraph. To leave an axis out of the fonts of a brand, fix it at a value with axes of the chassis.subset block. The build then writes each font that has the axis without it, as the font it is at that value, and the file is smaller.

A font is one file with the characters of every range. With the --split option, the build also writes each font that a stylesheet of fonts/ declares once per range, beside it, and writes the @font-face rule of the stylesheet once per range, each with the unicode-range of its file. A browser then downloads the file of a range only when the page has a character of that range.

Shell
pnpm assets --subset latin latin-ext --split
TEXT
dist/web/docs/chassis/fonts/
├── text-latin-ext.woff2    -> the characters of latin-ext
├── text-latin.woff2        -> the characters of latin
├── text.css                -> a rule per range, with its unicode-range
├── text.woff2              -> the characters of both ranges, as without split
└── ...

The file of a range has the name of the font, a hyphen and the name of the range: text-latin.woff2, and code-u2500-257f.woff2 for the range U+2500-257F. A font gets no file for a range that it has no character of. The font itself stays, with the characters of every range, for a page that writes its own rules.

The rules of the range you name first are the last ones in the stylesheet. A browser reads the rules of a family from the last to the first, so a character that two ranges hold is drawn with the file of the first range.

Three kinds of rules and fonts stay as they are. A rule with a unicode-range of its own is left to you. A rule whose src names something else than a WOFF or WOFF2 font of the folder is left with a warning. A font that no stylesheet declares, such as the files by weight beside a variable font, stays one file.

The split costs a page that has characters of two ranges: the two files share glyphs and are larger together than the one file. A browser also shapes the text of each file on its own, so a pair of letters from two files has no kerning. So the split is a choice of each build: --split and --no-split win over split of the chassis.subset block, which is the choice of the repository. --split without --subset fails the build.

Only the fonts of fonts/ of a web job are written again. The TTF and OTF fonts of iOS and Android stay whole, and so does the icon font of icons/. A file with the extension of a font that is no font fails the build, which names the file.

The option uses the package subset-font, which the build loads only with it. Without the package the build stops before it removes anything and names pnpm install as the fix. Check the license of a font before you publish a subset of it: a license may ask for another name for a changed font.

Res folder

The --res option moves the files of an Android job that the res/ folder of an app takes into one, dist/android/<app>/<brand>/res/. It is off by default, needs nothing installed, and leaves the output of every other platform as it is.

Shell
pnpm assets --res
pnpm assets --res --vector-drawables   # with the icons, needs pnpm install

A TTF or OTF file of fonts/ moves to res/font/. A PNG, WebP, JPEG or GIF file of images/ moves to the folder of res/ that is named as its density folder, so images/logo/drawable-xhdpi/mark.png becomes res/drawable-xhdpi/mark.png. A vector drawable of icons/ moves to res/drawable/, which makes --vector-drawables the option that brings the icons into res/.

The res/ folder does not nest, so the subfolders of images/ and icons/ are dropped. When two folders hold an image of one name, the folder nearest to images/ gets the resource with every density it has, and the files of the other folder stay.

A file without a place in res/ stays where it is, and the build warns once per job with the number of such files. These are the font licenses, every SVG file, a second file of a name in another format, such as text.otf beside text.ttf or a WebP beside a PNG, and a name that cannot be a resource: one that starts with a digit, or a word of Java such as default.

Configuration

The chassis block of the root package.json names what is built, and holds the optional settings: optimize, subset, ignore and formats. What the checks verify is in a file of its own, see Checks file.

JSON
{
  "chassis": {
    "defaults": {
      "brandFolder": "default"
    },
    "build": {
      "brands": ["chassis", "sinefil"],
      "apps": {
        "docs": ["web"],
        "demo": ["ios", "android"]
      }
    }
  }
}

chassis.defaults

brandFolder is the name of the default brand in source/, the folder whose files every brand gets. It is default unless the block says otherwise.

chassis.build

brands and apps name the folders of source/ that the build reads and the folders of dist/ that it writes.

KeyTypeMeaning
brandsArray of stringsThe brands to build. A brand needs no folder in source/: without one it gets the default files
appsObjectEach app, with the array of platforms it is built for: web, ios, android

chassis.optimize

optimize holds the settings of --optimize, --webp and --avif. The block is optional, each key is optional, and it turns nothing on: only the options do. A block that is not valid fails a build with one of the three options, before the output is removed.

JSON
{
  "chassis": {
    "optimize": {
      "types": ["images"],
      "png": { "quality": null },
      "jpeg": { "quality": null },
      "svg": { "precision": null },
      "webp": { "quality": 80, "lossless": true },
      "avif": { "quality": 60, "lossless": false }
    }
  }
}

These are the values without a block.

KeyTypeMeaning
typesArray of stringsThe type folders whose files are optimized and written in a second format. Add "icons" for the icons of a brand of your own
png.quality1 to 100, or nullnull compresses a PNG again and keeps every pixel. A number reduces its colors to a palette, which is smaller and not the same image
jpeg.quality1 to 100, or nullnull leaves a JPEG as it is. A number encodes it again at that quality
svgObject, or falsefalse leaves an SVG as it is
svg.precision0 to 8, or nullnull keeps the shapes. A number rounds the coordinates to that many decimals and rewrites the paths, which is much smaller and changes pixels at the edge of a shape
webp.lossless, avif.losslessBooleanWhether the format of a PNG is written without a loss. A JPEG is always written with quality
webp.quality, avif.quality1 to 100The quality of the format of a JPEG, and of a PNG without lossless

The settings are in package.json and not in options of the command, so that every build of a commit writes the same files. There is no setting for a single file: to choose the bytes of one image, commit that image as the designer exported it, in the format it is wanted in.

chassis.subset

subset holds the settings of --subset: the ranges, the axes that are fixed, and whether a font is split by range. The block is optional, and it turns nothing on: only the option does. A block that is not valid fails a build with the option, before the output is removed.

JSON
{
  "chassis": {
    "subset": {
      "ranges": ["latin", "latin-ext"],
      "axes": {
        "chassis": { "opsz": 14 }
      },
      "split": true
    }
  }
}

ranges is an array of the names and the code point ranges that Font subsets describes, and every font of a web job gets the same ranges. Without it the ranges are latin and latin-ext.

axes names, for a brand, the axes of a variable font to fix and the value of each. A key is a brand of chassis.build.brands, and a key under it is the tag of an axis as the font names it, such as opsz or wdth. The build fixes an axis in every font of the web output of the brand that has it, and writes a font without it as it would without the setting. Without axes no axis is fixed.

This repository has axes of the example and nothing else: the optical size of the chassis brand is fixed at 14, the cut of Inter for text, and the sinefil brand keeps the axes of its fonts.

A value outside the axis of a font fails the build, which names the file and the values the axis spans. An axis that no font of the brand has is a warning.

split is true or false. With true, a font that a stylesheet declares is written once per range too, and the stylesheet gets a rule per range, as Font subsets describes. Without it a font is one file. The --split and --no-split options of a build win over it.

chassis.ignore

ignore lists names of files and folders that the build leaves out, beside the ones of Ignored files. The list is optional, and without it the build ignores what it always did.

JSON
{
  "chassis": {
    "ignore": ["*.bak", "notes.md", "drafts"]
  }
}

A pattern is a name, not a path: it matches a file or a folder of that name at any depth, in every brand and every app. * stands for any run of characters, and the match is case-sensitive. A folder that matches is left out with everything in it. A pattern with a slash fails the build.

The list adds to the names the build ignores and takes none away. The source lint, the validator, the analyzer, pnpm assets:lfs and --watch read the same list, so a file you ignore is no file for them either.

chassis.formats

formats names, for a platform and a type folder, the extensions the platform keeps of that type. The block is optional. Use it when your application needs a format that the platform rules leave out, such as TTF fonts on the web or WebP images on iOS.

JSON
{
  "chassis": {
    "formats": {
      "web": {
        "fonts": [".woff2", ".ttf", ".css", ".txt"]
      },
      "ios": {
        "images": [".png", ".jpg", ".webp"]
      }
    }
  }
}

A key is a platform, and a key under it is fonts, images or icons, the type folders that have a rule. Any other folder is copied in every format, and has no setting. An extension is written with its dot, and its case does not matter.

A list replaces the rule of the platform for the type, it does not add to it. Name every format you want to keep: .txt for the licenses of the fonts, .css and .scss for their stylesheets. A platform or a type that the block does not name keeps its rule, and an empty list keeps no file of the type.

The options of a build work on the files that are kept, each with the formats it reads. A TTF file that the web keeps is copied whole, since --subset writes WOFF and WOFF2 fonts only. The validator reads the block, and expects in the output the files that it keeps.

A block that is not valid fails the build before the output is removed, and names each problem: a platform without a processor, another folder than the three, or an extension without its dot.

Jobs

A job is one brand, one app and one platform. The build runs every brand for every platform of every app, and writes each job to dist/<platform>/<app>/<brand>/, the layout that Chassis Tokens uses too.

In the chassis block of package.json, two brands and two apps:

JSON
{
  "chassis": {
    "build": {
      "brands": ["acme", "wonka"],
      "apps": {
        "website": ["web"],
        "mobile": ["ios", "android"]
      }
    }
  }
}

The build of this configuration writes these folders:

TEXT
dist/web/website/acme/
dist/web/website/wonka/
dist/ios/mobile/acme/
dist/ios/mobile/wonka/
dist/android/mobile/acme/
dist/android/mobile/wonka/

Platform rules

Each platform has a processor that says which formats it keeps and how a file is named. The rules of a type apply to the files of its folder, fonts/, images/ or icons/, and any other folder is copied in every format. The formats of the table are the rules without a chassis.formats block, which replaces the formats of a platform for a type.

RuleWebiOSAndroid
Nameskebab-casesnake_casesnake_case
Indicator @2x, @3xKept in the nameKept in the nameRemoved from the name
fonts/WOFF, WOFF2, CSS, SCSS, TXTTTF, OTF, TXTTTF, OTF, TXT
images/Every formatEvery format but WebP. An asset catalog with --asset-catalogEvery format, in density folders. In res/ with --res
icons/Every formatSVG, PDF, PNGSVG, with the ic_ prefix. Vector drawables with --vector-drawables
SubfoldersKeptKeptKept, with the density folders inside each

The pages of the fonts, the images and the icons show the output of each type.

Renaming

A name is lowercased, and every space, underscore, dot inside the name or other character becomes the separator of the platform: a hyphen for the web, an underscore for iOS and Android. Hero Banner@2x.png is hero-banner@2x.png for the web and hero_banner@2x.png for iOS.

When two files of a folder get the same name on a platform, the file copied last wins, and the build warns with Filename collision: "<name>" → "<new name>" (conflicts with "<other name>"). A brand file over the default file of the same name is the override, not a collision. Names that keep the rules of the design guidelines do not collide.

Ignored files

The build does not copy the files that an operating system or an editor leaves behind: .DS_Store, Thumbs.db, desktop.ini, .gitkeep, and names that match ._*, *~, *.swp, *.tmp or *.temp. It removes a folder that is empty after the format rules. A name that starts with a dot is left out too. To leave out more names, list them in chassis.ignore.

Checks

The checks read source/ or an existing output and change nothing. Each exits with 1 when it fails, so a script or a CI job stops there.

Validator

pnpm assets:validate checks an existing output against the source and the configuration. Run it after a full build.

Shell
pnpm assets
pnpm assets:validate

It checks that the output and the source exist, that every job has its folder and the type folders of its source, that every source file is there under the name of the platform, that the output has no fewer files than the source and no empty folder, and that the names keep the rules of the platform. --out <dir> reads another folder than dist/.

Contract check

A contract is a list of files that a consumer reads by name from the output of one job. pnpm assets:contract checks the contracts of chassis.checks.json, and names each missing file and its reader.

Shell
pnpm assets:contract
pnpm assets:verify

pnpm assets:verify runs the validator and then the contract check. A repository without contracts has nothing to check, and the command is the validator alone.

Source lint

pnpm assets:lint:source checks source/ against the naming rules of the design guidelines.

It fails on a name or a folder that is not of lowercase letters, digits and hyphens, on an indicator that is not at the end of a name, on an extension with capitals, on a file outside a type folder, and on a Git LFS pointer in place of a file. It warns about a brand or an app folder that the configuration does not name, which the build does not read.

The message suggests a name that keeps the rules. A file that breaks a rule on purpose is listed in lint.allow of the checks file with the reason, and is printed as a warning.

The lint also reads the fonts. For each @font-face rule of a stylesheet in fonts/, it opens the file the rule declares and compares the two, as a brand gets them, with its own files over those of the default brand. It fails when the file is of another family than the rule names, when its weight is not the weight of the rule or its weight axis does not hold the range of the rule, when one is italic and the other is not, and when the rule declares a file that is not in the folder. A rule that loads a font from a service is not read.

A rule declares the file of the web, and the apps get the file of the same name in another format, which no rule declares. The lint checks a rule against those files too: a rule for text.woff2 is also the rule of text.ttf in the same folder, and of an .otf or a .woff file of that name. It also fails when two files of one name are not one font in two formats, and names both files with what differs: the family, the weight, the style, the axes, the version or the number of glyphs.

It warns about a font of the default brand that a brand gets beside a family of its own, where no stylesheet of the brand declares the family of that font. The file is in the output of the brand, and nothing loads it. It also warns about a format() of a rule that is not the format of its file.

Analyzer

pnpm assets:analyze reports on source/ and on an existing output: the file types, the files per platform, brand and app, the largest files, and the files with the same content. It takes the filters of the build.

Shell
pnpm assets:analyze
pnpm assets:analyze --brand chassis --platform web

Files with the same content are listed per group, for the source and for the output apart. An app that holds a copy of the files of another app, as docs and demo of this repository do, shows up there.

Checks file

What the checks verify is in chassis.checks.json, beside package.json. The build does not read it. The file is optional: without it there is no contract to check and no name that the source lint keeps.

contracts

contracts holds, for a job written as <platform>/<app>/<brand>, the files that a consumer reads by name from its output.

KeyTypeMeaning
readerStringWho reads the files, printed when one is missing
filesArray of stringsPaths that have to be there, relative to the output of the job. {a,b} stands for each alternative
setsArrayPatterns of files that are read together, with * and {a,b}. When the output has a file that matches, it has to have that file with every other alternative too. An entry is a pattern, or an object with pattern and except, a list of patterns to leave out

In chassis.checks.json, a contract with a list of files and a set:

JSON
{
  "contracts": {
    "web/docs/chassis": [
      {
        "reader": "the layout of the site",
        "files": ["images/site-logo.svg", "images/favicon{,-16x16,-32x32}.png"]
      },
      {
        "reader": "the gallery of the site",
        "sets": ["images/gallery/*/{light,dark}/*{,@2x}.png"]
      }
    ]
  }
}

The set says that every image of the gallery is there in the light and in the dark mode, each at the base size and at @2x.

lint.allow

lint.allow lists the files of source/ that break a naming rule and are kept. The source lint prints a warning for them instead of an error.

KeyTypeMeaning
patternStringThe path of the file, relative to source/, with * for one folder
reasonStringWhy the name is kept, printed with the warning

In chassis.checks.json, the entry of a file that another project names:

JSON
{
  "lint": {
    "allow": [
      {
        "pattern": "*/*/icons/icons/chassis-icons.min.css",
        "reason": "part of the build output of @chassis-ui/icons, copied as it is"
      }
    ]
  }
}

Custom platforms

A platform is a processor: a module of packages/assets/build/processors/ with the formats the platform keeps and a function that names a file. To add a platform, write the module, add it to the registry, and name the platform in an app.

Add the processor as packages/assets/build/processors/desktop.js. A format list that is missing means every format.

JavaScript
const desktopProcessor = {
  name: 'desktop',
  icon: '🖥️',

  allowedFontFormats: ['.ttf', '.otf', '.txt'],
  allowedIconFormats: ['.svg'],
  excludedImageFormats: [],

  renameFile(fileName) {
    return fileName.toLowerCase()
  }
}

export default desktopProcessor

Add it to the registry in packages/assets/build/processors/index.js:

JavaScript
import desktopProcessor from './desktop.js'

export const platformProcessors = {
  web: webProcessor,
  ios: iosProcessor,
  android: androidProcessor,
  desktop: desktopProcessor
}

Name the platform in an app of the chassis block, and build it with pnpm assets --platform desktop.

A processor may also place an image itself with processImage(), as the Android processor does for the density folders. It returns true when it wrote the file, and false to let the build copy it. Such a processor also has imageFolder(fileName), which returns the folder the image goes to, so that the build can warn about two images that meet there.

A processor with vectorDrawables takes part in the --vector-drawables option. The property names the type folder and the two extensions, as the Android processor does with { type: 'icons', from: '.svg', to: '.xml' }. A processor with assetCatalog takes part in the --asset-catalog option. The property names the type folder and the folder of the catalog, as the iOS processor does with { type: 'images', name: 'Assets.xcassets' }. A processor with res takes part in the --res option. The property names the folder, and for the fonts and the drawables the type folders, the folder of res/ and the formats it takes. A processor with imageFormats takes part in --webp and --avif: the property says for each format whether the file is written beside the image or to replace it, as the web processor does with { webp: 'beside', avif: 'beside' } and the Android processor with { webp: 'replace' }. A processor with subset takes part in --subset: the property names the type folder and the extensions of the fonts that are written again, as the web processor does with { type: 'fonts', formats: ['.woff', '.woff2'] }.

Library

The commands are thin entries over modules that a script can import. An imported function takes its options as an object, never reads the command line, and throws where the command would exit.

Use the paths of your checkout. This script builds one job into a folder of its own and reads the result:

JavaScript
import ChassisAssets from '../chassis-assets/packages/assets/build/api/index.js'
import AssetAnalyzer from '../chassis-assets/packages/assets/build/analyze-assets.js'
import { generateAssets } from '../chassis-assets/packages/assets/build/build-assets.js'

// The repository root: where package.json and source/ are
const cwd = new URL('../chassis-assets/', import.meta.url).pathname

// The build, with the options of the command line. Throws on an unknown brand
const result = await generateAssets({
  brands: ['chassis'],
  apps: ['docs'],
  platforms: ['web'],
  cwd,
  out: 'dist',
  quiet: true
})
console.log(result.filesProcessed, result.jobs)

// The configuration as an object
const assets = new ChassisAssets('package.json', { cwd })
console.log(assets.getBrands(), assets.getCombinations())

// The analyzer. analyze() is synchronous
const analyzer = new AssetAnalyzer({ brands: ['chassis'], platforms: ['web'], cwd, quiet: true })
analyzer.analyze()
console.log(analyzer.stats.totalFiles, analyzer.stats.duplicatesInSource.length)

generateAssets

generateAssets(options) of build-assets.js is the build. It resolves to the statistics of the run, with filesProcessed, filesRenamed, filesConverted, imageSets, resourceFiles, filesOptimized, bytesSaved, filesGenerated, fontsSubsetted, fontBytesSaved, fontAxesFixed, fontRangeFiles, warnings and jobs, and throws on a failure. Its options are brands, apps, platforms, types, include, clean, quiet, cwd, out, dryRun, allowLfsPointers, vectorDrawables, assetCatalog, res, optimize, webp, avif, subset and split.

ChassisAssets

new ChassisAssets(configPath, { cwd, out }) of api/index.js reads the configuration of package.json in cwd.

MethodReturns
getBrands()The configured brands
getApps()The names of the configured apps
getPlatforms(app)The platforms of one app
getAllPlatforms()Every platform that an app names
getCombinations()Every job, as brand, app and platform
assetsExist(brand, app)Whether the source has files for the brand and the app
getAssetInventory(brand, app)The source files of the brand and the app, by type
build(options)The result of generateAssets() with brands, apps, platforms, types, include, clean, quiet, vectorDrawables, assetCatalog, res, optimize, webp, avif and subset
getStats()The numbers of brands, apps, jobs, source files and output files
validate()The errors and the warnings of the configuration and the source

AssetAnalyzer

new AssetAnalyzer({ brands, apps, platforms, quiet, cwd, out }) of analyze-assets.js takes the filters and the folders. analyze() fills stats: totalFiles, totalSize, fileTypes, platforms, brands, apps, largestFiles, duplicatesInSource and duplicatesInDist. formatBytes(bytes) formats a size for reading.

Continuous integration

A CI job builds the output it needs and checks it. The checkout has to fetch the Git LFS files, and the build needs no install step.

YAML
name: Assets

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          lfs: true

      # pnpm at the version of packageManager, Node.js from .nvmrc
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc

      - name: Lint the source
        run: pnpm assets:lint:source

      - name: Build
        run: pnpm assets

      - name: Verify
        run: pnpm assets:verify

To build one platform per job, give each job a filter, such as pnpm assets --platform ios, and keep its folder, dist/ios/. A job that needs the names and not the binaries, such as the source lint alone, can skip the Git LFS files and pass --allow-lfs-pointers.

Tests

The tests run with Vitest on a small fixture in packages/assets/test/fixtures/, with brands, apps and files of its own. They need no Git LFS files and write to temporary folders only, never to dist/.

Shell
pnpm test           # run every test
pnpm test:golden    # write the golden output again

One test builds the fixture and compares the result, file by file, with the committed output in packages/assets/test/golden/. After a change to the build that is meant to change the output, run pnpm test:golden and review the difference: every file that moves or changes its name there does so in dist/ too.

Best practices

These practices keep a build short while you work, and keep the paths of the output stable for the consumers.

  • Build a part while you work: pnpm assets --brand chassis --platform web writes one job and keeps the rest of dist/.
  • Run a full build before the checks: pnpm assets:validate and pnpm assets:verify expect the output of every job.
  • Start with the default brand: put a complete set of files in source/default/<app>/, and add to a brand folder only what differs.
  • Keep brand and app names in lowercase with hyphens: they are folder names in source/ and in dist/, and a consumer reads its files by that path.

Avoid the habits that undo this:

  • Do not commit dist/: every consumer builds it from the source.
  • Do not rename a brand or an app without telling the consumers: the name is part of every path of its output.

Troubleshooting

Each entry gives the message or the symptom, then the cause and the fix.

Build fails on pointers

The build stops with <count> source file(s) are Git LFS pointers, not files: <files>. The clone has no Git LFS files, so a font or an image is a pointer of a few lines. Install Git LFS, run git lfs pull, and build again. Pass --allow-lfs-pointers only where the binaries are not needed. With --type or --include, fetch the files of the filters only, see File filters.

Unknown brand

The build stops with Unknown brand "<name>". Configured: <names>, or the same for an app or a platform. A filter has a value that the chassis block does not name. Use a name of the list.

No job selected

The build stops with The filters select no job. Check --brand, --app and --platform together. Each value is configured, and no app has that platform. Run pnpm assets --dry-run to see the jobs.

Validator misses files

pnpm assets:validate fails after a filtered build and names the folders or the files of other jobs. The validator checks every job of the configuration. Run pnpm assets without filters, then validate.

Next steps

Continue with one of these pages: