Skip to content

Commit

Permalink
Implements custom CSS ruleset.
Browse files Browse the repository at this point in the history
Refs #41.

See #41 (comment) for more info on the design of this ruleset.

This includes rules for `css_library()` and `css_binaries()` along with supporting providers and rules.

The core design here is that `css_library()` targets return a `CssInfo` provider which tracks direct and transitive sources. `css_binaries()` uses them to generate a bundled CSS file for every direct CSS source file of its direct `css_library()` dependencies. These bundled files have resolved all of their imports and inlined them.

`css_binaries()` also provides a `CssImportMapInfo` which maps importable CSS file names to their actual file path on disk. This solves two problems:
1. Users shouldn't be writing artifact roots in their code, but the files actually being imported do have artifact roots and may exist in many configurations.
2. `css_binaries()` bundle source files from dependency `css_library()` targets which may exist in different packages. As a result, the generated bundle may not have the same root-relative path as the library file that the user wants to import.

`css_group()` provides `filegroup()`-like semantics for merging multiple `css_binary()` targets into a single target with a cohesive `CssImportMapInfo` provider.

I did my best to include tests for the major interactions between these rules, hopefully this should provide some reasonable confidence going forward.
  • Loading branch information
dgp1130 committed May 1, 2022
1 parent 34cb490 commit aee3fca
Show file tree
Hide file tree
Showing 25 changed files with 694 additions and 3 deletions.
2 changes: 1 addition & 1 deletion examples/extract/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
load("//:index.bzl", "extract_single_resource", "prerender_pages_unbundled")
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("//:index.bzl", "extract_single_resource", "prerender_pages_unbundled")

prerender_pages_unbundled(
name = "single_resource",
Expand Down
5 changes: 3 additions & 2 deletions packages/rules_prerender/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ bzl_library(
deps = [
":multi_inject_resources",
":prerender_pages_unbundled",
":postcss_import_plugin_bzl",
":postcss_import_plugin",
":web_resources",
],
)
Expand All @@ -128,8 +128,9 @@ bzl_library(
)

bzl_library(
name = "postcss_import_plugin_bzl",
name = "postcss_import_plugin",
srcs = ["postcss_import_plugin.bzl"],
visibility = ["//packages/rules_prerender/css:__pkg__"],
)

bzl_library(
Expand Down
35 changes: 35 additions & 0 deletions packages/rules_prerender/css/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
load("@bazel_skylib//:bzl_library.bzl", "bzl_library")

bzl_library(
name = "css_binaries",
srcs = ["css_binaries.bzl"],
deps = [
":css_group",
":css_map",
":css_providers",
"//packages/rules_prerender:postcss_import_plugin",
],
)

bzl_library(
name = "css_group",
srcs = ["css_group.bzl"],
deps = [":css_providers"],
)

bzl_library(
name = "css_library",
srcs = ["css_library.bzl"],
deps = [":css_providers"],
)

bzl_library(
name = "css_map",
srcs = ["css_map.bzl"],
deps = [":css_providers"],
)

bzl_library(
name = "css_providers",
srcs = ["css_providers.bzl"],
)
147 changes: 147 additions & 0 deletions packages/rules_prerender/css/css_binaries.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Defines `css_binaries()` and related rules."""

load("@npm//@bazel/postcss:index.bzl", "postcss_multi_binary")
load(
"//packages/rules_prerender:postcss_import_plugin.bzl",
IMPORT_PLUGIN_CONFIG = "PLUGIN_CONFIG",
)
load(":css_group.bzl", "css_group")
load(":css_map.bzl", "css_map")
load(":css_providers.bzl", "CssInfo")

def css_binaries(
name,
deps,
sourcemap = True,
testonly = None,
visibility = None,
tags = None,
):
"""Generates CSS "binaries" of direct `css_library()` dependencies.
Each `css_library()` in `deps` gets each of its direct sources compiled into a
"binary", a new CSS file which bundles all the `@import` dependencies of that source.
Returns:
`DefaultInfo`: Contains bundled CSS and sourcemaps.
`CssImportMapInfo`: Maps importable paths to generated CSS binary files.
Args:
name: Name of this target.
deps: Dependencies of this target which provide `CssInfo` (generally
`css_library()`).
sourcemap: Whether to generate sourcemaps (defaults to `True`).
testonly: https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes
visibility: https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes
tags: https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes
"""
binaries = []
for (index, dep) in enumerate(deps):
binary_name = "%s_binary_%s" % (name, index)
_css_binary(
name = binary_name,
sourcemap = sourcemap,
dep = dep,
testonly = testonly,
tags = tags,
)
binaries.append(binary_name)

css_group(
name = name,
deps = [":%s" % binary for binary in binaries],
testonly = testonly,
visibility = visibility,
tags = tags,
)

# It might look like this implementation should accept multiple `css_library()` targets,
# but doing so would be a bad idea. This is because the library is separated into "direct
# sources" and "transitive dependencies" which are fed into `postcss_multi_binary()`. If
# this allowed multiple libraries and same split was done, it would be "all libraries'
# direct sources" and "all libraries' transitive dependencies". This sounds reasonable,
# but would break strict deps. If one library had `@import url("rules_prerender/foo");`
# but forgot to add a dep on `//:foo` yet another library in this binary had a dep on
# `//:foo`, the first would incorrectly compile successfully. As a result, we need a
# separate `postcss_multi_binary()` for each compiled `css_library()`.
def _css_binary(
name,
dep,
sourcemap,
testonly = None,
visibility = None,
tags = None,
):
"""Generates a CSS "binary" for a single `css_library()` dependency.
The `css_library()` dependency gets each of its direct sources compiled into a
"binary", a new CSS file which bundles all the `@import` dependencies of that source.
Note that while this macro is called `_css_binary()` in the singular form, if the
`css_library()` dependency has multiple direct sources, each one will be compiled into
an independent "binary", meaning multiple binaries can be generated by this macro.
Returns:
`DefaultInfo`: Contains bundled CSS and sourcemaps.
`CssImportMapInfo`: Maps importable paths to generated CSS binary files.
Args:
name: Name of this target.
dep: Dependency of this target which provides `CssInfo` (generally
`css_library()`).
sourcemap: Whether to generate sourcemaps.
testonly: https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes
visibility: https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes
tags: https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes
"""
# Collect all transitive CSS dependencies into a single `DefaultInfo` target.
css_transitive_deps = "%s_transitive_deps" % name
_css_deps(
name = css_transitive_deps,
dep = dep,
testonly = testonly,
tags = tags,
)

# Compile the CSS. This only compiles one library, but that library may have many
# source files, each one should be compiled into its own output, which requires
# `postcss_multi_binary()` even though `postcss_binary()` sounds like it would be
# sufficient here.
binary = "%s_postcss" % name
postcss_multi_binary(
name = binary,
srcs = [dep],
output_pattern = "{name}",
sourcemap = sourcemap,
plugins = {
"//tools/internal:postcss_import_plugin": IMPORT_PLUGIN_CONFIG,
},
testonly = testonly,
tags = tags,
deps = [":%s" % css_transitive_deps],
)

# Return the binary outputs with a map of import name -> file path.
css_map(
name = name,
bin = ":%s" % binary,
lib = dep,
testonly = testonly,
visibility = visibility,
tags = tags,
)

def _css_deps_impl(ctx):
return DefaultInfo(
files = ctx.attr.dep[CssInfo].transitive_sources,
)

_css_deps = rule(
implementation = _css_deps_impl,
attrs = {
"dep": attr.label(
mandatory = True,
providers = [CssInfo],
),
},
doc = "Returns transitive CSS sources in `DefaultInfo`.",
)
39 changes: 39 additions & 0 deletions packages/rules_prerender/css/css_group.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Defines `css_group()` to group multiple `css_library()` targets like `filegroup()`."""

load(":css_providers.bzl", "CssImportMapInfo")

def _css_group_impl(ctx):
return [
DefaultInfo(
files = depset([], transitive = [dep[DefaultInfo].files
for dep in ctx.attr.deps]),
),
CssImportMapInfo(
import_map = _merge_import_maps([dep[CssImportMapInfo]
for dep in ctx.attr.deps
if CssImportMapInfo in dep]),
),
]

css_group = rule(
implementation = _css_group_impl,
attrs = {
"deps": attr.label_list(mandatory = True),
},
doc = "Like a `filegroup()`, but for `css_library()` targets.",
)

def _merge_import_maps(css_import_maps):
"""Merges a list of `CssImportMapInfo` into a single `CssImportMapInfo`.
Fails the build if the same import path appears as a key in two maps.
"""
import_map = dict()
for css_import_map in css_import_maps:
for (key, value) in css_import_map.import_map.items():
if key in import_map:
fail(("Found duplicate CSS import path in `_css_group()`. %s maps to" +
" both %s and %s.") % (key, import_map[key].path, value.path))
import_map[key] = value

return import_map
37 changes: 37 additions & 0 deletions packages/rules_prerender/css/css_library.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Defines the `css_library()` rule."""

load(":css_providers.bzl", "CssInfo")

def _css_library_impl(ctx):
return [
DefaultInfo(files = depset(ctx.files.srcs)),
CssInfo(
direct_sources = ctx.files.srcs,
transitive_sources = depset(ctx.files.srcs,
transitive = [dep[CssInfo].transitive_sources
for dep in ctx.attr.deps],
),
),
]

css_library = rule(
implementation = _css_library_impl,
attrs = {
"srcs": attr.label_list(
allow_files = [".css"],
doc = "List of CSS source files in this library.",
),
"deps": attr.label_list(
providers = [CssInfo],
doc = """
List of other `css_library()` dependencies with sources imported by this target's sources.
""".strip(),
),
},
doc = """
A library of CSS source files and their `@import` dependencies.
This rule does not currently implement any form of strict dependencies, take care to
manage your imports and dependencies.
""".strip(),
)
80 changes: 80 additions & 0 deletions packages/rules_prerender/css/css_map.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Defines the `css_map()` rule."""

load(":css_providers.bzl", "CssImportMapInfo", "CssInfo")

def _css_map_impl(ctx):
return [
ctx.attr.bin[DefaultInfo],
CssImportMapInfo(import_map = _make_import_map(ctx)),
]

css_map = rule(
implementation = _css_map_impl,
attrs = {
"bin": attr.label(
mandatory = True,
doc = """
The `postcss_multi_binary()` target which compiles the library. Files generated by this
target are used as the actual files paths in the provided `CssImportMapInfo`.
""".strip(),
),
"lib": attr.label(
mandatory = True,
providers = [CssInfo],
doc = """
The `css_library()` target containing direct sources used in the `postcss_multi_binary()`.
Files included here are used to define the user-authorable import path in the provided
`CssImportMapInfo`.
""".strip(),
),
},
doc = """
Provides a `CssImportMapInfo` which maps the user-authorable import path to the actual
file path to resolve to.
""".strip(),
)

def _make_import_map(ctx):
lib_files = sorted(ctx.attr.lib[CssInfo].direct_sources)
bin_files = sorted([file for file in ctx.files.bin if not file.basename.endswith(".map")])

# Validate that the `postcss_binary()` and the `css_library()` contain the same number
# of files.
if len(lib_files) != len(bin_files):
fail(("Number of files from the CSS library (%s from %s) does not equal the" +
" number of files from the CSS binary (%s from %s)") % (
len(lib_files),
ctx.attr.lib.label,
len(bin_files),
ctx.attr.bin.label,
))

import_map = dict()
lib_wksp = (ctx.attr.lib.label.workspace_name
if ctx.attr.lib.label.workspace_name
else ctx.workspace_name)
for index in range(len(lib_files)):
lib_file = lib_files[index]
bin_file = bin_files[index]

# The library and binary file should have the same name, but might be in
# different packages.
if lib_file.basename != bin_file.basename:
fail(("CSS library files did not match up with CSS binary files.\n" +
"Lib files:\n%s\n\nBin files:\n%s") % (
"\n".join([file.path for file in lib_files]),
"\n".join([file.path for file in bin_files]),
))

# Verify that the importable path isn't already registered.
key = "%s/%s" % (lib_wksp, lib_file.short_path)
if key in import_map:
fail("CSS library file (%s) mapped twice, once to %s and a second time to %s." % (
key,
import_map[key].path,
bin_file.path,
))

import_map[key] = bin_file

return import_map
20 changes: 20 additions & 0 deletions packages/rules_prerender/css/css_providers.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Provider of information related to CSS compilation.
CssInfo = provider(fields = {
"direct_sources": "Direct sources of the target.",
"transitive_sources": "All direct *and* transitive sources of the target.",
})

# Provider of a map which relates an importable path to the actual file path which it
# references.
CssImportMapInfo = provider(fields = {
"import_map": """
A `dict[str, str]` where a key is a paths which can be used in a user-authored import
statement (wksp/foo/bar/baz.css) mapped to a value which is the file it actually refers
to (bazel-out/bin/foo/bar/baz.css).
This abstracts away the artifact root from user-authored code and also decouples the
import statement authored from the file actually being imported. The root-relative paths
of both the import statement and the real file path *usually* align, but not always and
should not be assumed to match.
""".strip(),
})
Loading

0 comments on commit aee3fca

Please sign in to comment.