css_library, css_module, asset_library, json_library¶
Four rules put non-TypeScript files into the module graph. Each generates the
ambient declaration TypeScript needs (allowArbitraryExtensions) and propagates
its files through a provider, so a bundler or dev server downstream can find
them.
| Rule | Files | Import form | Provider |
|---|---|---|---|
css_library |
.css |
import "./button.css" (side effect) |
CssInfo |
css_module |
.css (Gazelle routes *.module.css here) |
import styles from "./Button.module.css" |
CssModuleInfo |
asset_library |
.svg .png .jpg .jpeg .gif .webp .woff .woff2 .ttf .eot .md .txt .jsonc |
import logo from "./logo.svg" (URL string by default; see declaration_type) |
AssetInfo |
json_library |
.json |
import config from "./config.json" (fully typed) |
— |
load("@rules_typescript//ts:defs.bzl", "asset_library", "css_library", "css_module", "ts_compile")
css_library(
name = "button_css",
srcs = ["button.css"],
)
css_module(
name = "panel_module_css",
srcs = ["Panel.module.css"],
)
asset_library(
name = "logo_svg",
srcs = ["logo.svg"],
)
ts_compile(
name = "ui",
srcs = ["Button.tsx", "Panel.tsx"],
deps = [":button_css", ":logo_svg", ":panel_module_css"],
)
Gazelle writes all four for you, one target per file, named after the file with
its dots turned into underscores (Panel.module.css → Panel_module_css).
Imports Are Not Rewritten¶
ts_compile leaves the import alone. Button.js in bazel-bin still says
import "./button.css", the carrier of both facts a consumer needs: that this
module requires that stylesheet, and in what order relative to the others. Node
cannot load it:
$ node bazel-bin/tests/css/Button.js
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"
A target with a css_library, css_module or asset_library dep is consumed
by ts_bundle, by ts_dev_server, or by a
downstream bundler reading the published package.
The files are copied into bazel-bin¶
css_library, css_module and asset_library copy their sources into
bazel-bin beside the compiled .js and put the copies, not the source files,
in the provider. The importer is that compiled .js, and
import "./button.css" resolves relative to the importer, so a stylesheet that
existed only in the source tree would not be where the bundler looks.
They are copies, not symlinks, because a bundler resolves a symlink to its real
path before resolving what the file itself imports: @import "tailwindcss"
reached through a symlink would look for a source-tree node_modules that a
Bazel build does not have.
What the Declarations Promise¶
css_library emits an empty declaration; a side-effect import has no export
surface to describe.
css_module emits the keys:
declare const styles: {
readonly panel: string;
readonly panelTitle: string;
readonly "panel-footer": string;
};
export default styles;
The action runs postcss-modules over the stylesheet, writes the export map it
produced to <source>.exports.json, and generates the declaration from that
map's keys, so the declared keys are exactly what the stylesheet exports.
@keyframes names, #id selectors and @value names are exports and are
declared, so styles["panel-fade"] type-checks.
composes: … from "./other.module.css" resolves, and fails on bad input:
postcss-modules errors on a name it cannot find, and the other file has to be in
deps.
The values are scoped names Bazel decides:
The name is a pure function of the local name and the stylesheet's own bytes: no
filename, no cwd, no line number, so a build in a different sandbox or output
base mints the same name. ts_bundle, ts_dev_server and ts_test install a
Bazel-owned Vite plugin that hands Vite that map, so the bundler's own
CSS-modules pass reproduces the names. Under ts_test the import resolves to
the same map, so a unit test asserts the string the bundle really emits.
Four attributes change the answer, and they belong on the rule that wrote the
declaration: locals_convention, scope_behaviour, hash_prefix and
export_globals. Setting one of them under css.modules in the bundler config
is a hard build failure naming the attribute to use, and so are
css.modules.generateScopedName and css.modules = false.
asset_library promises a string URL by default and json_library the parsed
JSON shape. JSON is not an asset_library extension: json_library
parses the file at build time and generates real property types, which an
ambient string declaration would throw away. The parse reads the
JSON-with-comments dialect TypeScript itself accepts, so a .json file carrying
comments or trailing commas types like any other. .jsonc goes the other way
and stays with asset_library: no bundler parses that extension as JSON, so the
import yields a URL and not a value.
When an Asset Is Not a URL¶
A URL string is what a bundler hands back when it does not transform the file.
A project running an svgr-style plugin gets a React component from .svg
instead, and declaration_type is how it says so, keyed by extension:
asset_library(
name = "logo_svg",
srcs = ["logo.svg"],
declaration_type = {
".svg": 'import("react").FC<import("react").SVGProps<SVGElement>>',
},
)
Match the expression to the declare module "*.svg" the project already has,
rather than copying this one: SVGProps is invariant in its element, so
FC<SVGProps<SVGElement>> satisfies a consumer expecting SVGSVGElement while
the reverse does not.
An extension left out keeps string, so one target can retype its .svg and
leave its .png alone. A key that is not an asset_library extension is an
analysis failure listing the ones that are.
Gazelle writes one asset_library per asset file, so on a repo of any size this
attribute is written by
# gazelle:ts_asset_declaration_type
rather than by hand: one directive per extension, applying to a directory and
below.
The expression is inserted verbatim, so any name it uses has to resolve from
inside a generated declaration: write import("pkg").Type rather than a
top-level import, and keep pkg in the consuming ts_compile target's deps.
The expression is unchecked, and a name that does not resolve is silent.
The generated file is a .d.ts and this ruleset compiles with skipLibCheck,
so a typo does not error — the import widens to any and every use of it
type-checks. Build the consuming target with
compiler_options = {"skipLibCheck": False} to surface it:
The generated file's first line names the target and the attribute that wrote the type, so the error leads back to the BUILD file.
A declare module "*.svg" in the project does not do this job. TypeScript
prefers the concrete logo.svg.d.ts this rule writes beside the asset over any
wildcard pattern, so the generated declaration wins and the project's ambient
never applies — declaration_type is the supported way to change the answer.
The exception is an asset reached through a path_aliases alias rather than a
relative import: the alias resolves into the source tree, where no generated
declaration sits beside the asset, so a wildcard ambient decides that import and
declaration_type does not reach it.
In a Bundle¶
App mode hashes every imported stylesheet and asset and rewrites the references
in the HTML. Lib mode extracts all CSS into one declared <bundle_name>.css and
does not reference it from the JS, so the consumer has to include it.
Static files that must keep the name they were given — robots.txt, a favicon
named from an HTML tag — go in ts_bundle's public_dir and not an
asset_library: those are copied verbatim, unhashed. See
Bundling.