Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ assets.html("src/main.tsx");
| --- | --- |
| `assetUrl(fn)` | マニフェストのパスを URL にする。既定は `/` を前置 |
| `modulePreload(true)` | import したチャンクごとに `<link rel="modulepreload">` を出す。既定は off |
| `scriptAttribute(name, value)` | `<script>` タグに属性を足す |
| `styleAttribute(name, value)` | すべての `<link rel="stylesheet">` タグに属性を足す |

`crossorigin` や `nonce`、CDN や監視ツールが要求する属性のためのものです。

```java
ViteAssets.builder(manifest)
.scriptAttribute("crossorigin", "anonymous")
.scriptAttribute("async")
.styleAttribute("data-turbo-track", "reload")
.build();
```

```html
<link rel="stylesheet" href="/assets/main.css" data-turbo-track="reload">
<script type="module" src="/assets/main.js" crossorigin="anonymous" async></script>
```

名前だけを渡すと値なしで出力されます。`async` や `defer` のように、存在することで意味を持つ属性の
ためです。属性は追加した順に並び、値は URL と同じエスケープを通ります。

vite4j 自身が書く属性は拒否されます。script の `src` と `type`、stylesheet の `href` と `rel` です。
これらを上書きするとタグが黙って壊れるので、動かないものを出力せず、指定した場所で例外になります。

## 開発時

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ the tags arrive on the page as text:
| --- | --- |
| `assetUrl(fn)` | Turns a manifest path into a URL. Defaults to `/` + path |
| `modulePreload(true)` | Adds `<link rel="modulepreload">` per imported chunk. Off by default |
| `scriptAttribute(name, value)` | Adds an attribute to the `<script>` tag |
| `styleAttribute(name, value)` | Adds an attribute to every `<link rel="stylesheet">` tag |

A `crossorigin`, a `nonce`, or whatever a CDN or a monitoring tool asks for:

```java
ViteAssets.builder(manifest)
.scriptAttribute("crossorigin", "anonymous")
.scriptAttribute("async")
.styleAttribute("data-turbo-track", "reload")
.build();
```

```html
<link rel="stylesheet" href="/assets/main.css" data-turbo-track="reload">
<script type="module" src="/assets/main.js" crossorigin="anonymous" async></script>
```

Called with a name alone, the attribute stands on its own, which is how `async` and `defer` carry
their meaning. Attributes appear in the order they were added, and values are escaped the way the
URLs already are.

The attributes vite4j writes itself are refused: `src` and `type` on the script, `href` and `rel` on
the stylesheets. Overriding one of those makes the tag silently wrong, so it throws where it is
asked for rather than rendering something that does not work.

## Development

Expand Down
96 changes: 94 additions & 2 deletions src/main/java/com/nulabinc/vite4j/ViteAssets.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package com.nulabinc.vite4j;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;

/**
* Renders the tags for a built bundle, following the four steps in Vite's
Expand All @@ -28,11 +35,27 @@ public final class ViteAssets implements ViteTags {
private final ViteManifest manifest;
private final UnaryOperator<String> assetUrl;
private final boolean modulePreload;
private final String scriptAttributes;
private final String styleAttributes;

private ViteAssets(Builder builder) {
this.manifest = builder.manifest;
this.assetUrl = builder.assetUrl;
this.modulePreload = builder.modulePreload;
this.scriptAttributes = render(builder.scriptAttributes);
this.styleAttributes = render(builder.styleAttributes);
}

/** An attribute with no value renders bare, the way {@code async} and {@code defer} are written. */
private static String render(Map<String, String> attributes) {
StringBuilder rendered = new StringBuilder();
for (Map.Entry<String, String> attribute : attributes.entrySet()) {
rendered.append(' ').append(attribute.getKey());
if (attribute.getValue() != null) {
rendered.append("=\"").append(Html.attribute(attribute.getValue())).append('"');
}
}
return rendered.toString();
}

public static Builder builder(ViteManifest manifest) {
Expand All @@ -51,15 +74,15 @@ public List<String> tags(String entry) {
public List<String> stylesheetTags(String entry) {
List<String> tags = new ArrayList<>();
for (String stylesheet : manifest.stylesheets(entry)) {
tags.add("<link rel=\"stylesheet\" href=\"" + Html.attribute(url(stylesheet)) + "\">");
tags.add("<link rel=\"stylesheet\" href=\"" + Html.attribute(url(stylesheet)) + "\"" + styleAttributes + ">");
}
return Collections.unmodifiableList(tags);
}

/** The {@code <script type="module">} for the entry, empty when the manifest does not describe it. */
public java.util.Optional<String> scriptTag(String entry) {
return manifest.file(entry)
.map(file -> "<script type=\"module\" src=\"" + Html.attribute(url(file)) + "\"></script>");
.map(file -> "<script type=\"module\" src=\"" + Html.attribute(url(file)) + "\"" + scriptAttributes + "></script>");
}

/**
Expand Down Expand Up @@ -87,9 +110,24 @@ private String url(String path) {

public static final class Builder {

/** What vite4j writes on the script itself. Letting these be overridden makes the tag silently wrong. */
private static final Set<String> SCRIPT_RESERVED = reserved("src", "type");

/** The same for the stylesheet link. */
private static final Set<String> STYLE_RESERVED = reserved("href", "rel");

/** An HTML attribute name. A value can be escaped; a name cannot, so one that is not a name is refused. */
private static final Pattern NAME = Pattern.compile("[A-Za-z_:][A-Za-z0-9_:.-]*");

private final ViteManifest manifest;
private UnaryOperator<String> assetUrl = path -> "/" + path;
private boolean modulePreload;
private final Map<String, String> scriptAttributes = new LinkedHashMap<>();
private final Map<String, String> styleAttributes = new LinkedHashMap<>();

private static Set<String> reserved(String... names) {
return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(names)));
}

private Builder(ViteManifest manifest) {
if (manifest == null) {
Expand All @@ -113,6 +151,60 @@ public Builder modulePreload(boolean modulePreload) {
return this;
}

/**
* Adds an attribute to the {@code <script>} tag, for a {@code crossorigin}, a {@code nonce}, or
* whatever a CDN or a monitoring tool asks for. Attributes render in the order they were added,
* and adding the same name twice keeps the last value. Names are matched without regard to case,
* as HTML reads them.
*
* @throws IllegalArgumentException if the name is not an attribute name, or is one vite4j writes
* itself ({@code src}, {@code type})
*/
public Builder scriptAttribute(String name, String value) {
scriptAttributes.put(checked(name, SCRIPT_RESERVED), value);
return this;
}

/** The same, for an attribute that stands alone with no value, such as {@code async} or {@code defer}. */
public Builder scriptAttribute(String name) {
return scriptAttribute(name, null);
}

/**
* Adds an attribute to each {@code <link rel="stylesheet">} tag.
*
* @throws IllegalArgumentException if the name is not an attribute name, or is one vite4j writes
* itself ({@code href}, {@code rel})
*/
public Builder styleAttribute(String name, String value) {
styleAttributes.put(checked(name, STYLE_RESERVED), value);
return this;
}

/** The same, for an attribute that stands alone with no value. */
public Builder styleAttribute(String name) {
return styleAttribute(name, null);
}

/**
* HTML does not tell {@code crossorigin} and {@code CROSSORIGIN} apart, so neither does the map
* they are collected in: keeping both would render one attribute twice, and the browser would
* take the first while the caller was promised the last.
*/
private static String checked(String name, Set<String> reserved) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("attribute name must not be empty");
}
if (!NAME.matcher(name).matches()) {
throw new IllegalArgumentException("\"" + name + "\" is not an attribute name");
}
String normalized = name.toLowerCase(Locale.ROOT);
if (reserved.contains(normalized)) {
throw new IllegalArgumentException(name + " is written by vite4j and cannot be overridden");
}
return normalized;
}

public ViteAssets build() {
return new ViteAssets(this);
}
Expand Down
112 changes: 112 additions & 0 deletions src/test/java/com/nulabinc/vite4j/ViteAssetsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ViteAssetsTest {
Expand Down Expand Up @@ -80,4 +81,115 @@ void joinsTagsForTemplatesThatWantOneString() {
private static List<String> pathsIn(List<String> tags) {
return tags.stream().map(tag -> tag.replaceAll(".*src=\"([^\"]+)\".*", "$1")).collect(java.util.stream.Collectors.toList());
}

@Test
void putsAnAttributeOnTheScript() {
ViteAssets assets = ViteAssets.builder(manifest).scriptAttribute("crossorigin", "anonymous").build();

assertEquals("<script type=\"module\" src=\"/assets/index.js\" crossorigin=\"anonymous\"></script>",
assets.scriptTag("main.js").orElse(null));
}

@Test
void putsAnAttributeOnEveryStylesheet() {
ViteAssets assets = ViteAssets.builder(manifest).styleAttribute("data-turbo-track", "reload").build();

assertEquals(Arrays.asList(
"<link rel=\"stylesheet\" href=\"/assets/shared.css\" data-turbo-track=\"reload\">",
"<link rel=\"stylesheet\" href=\"/assets/index.css\" data-turbo-track=\"reload\">"),
assets.stylesheetTags("main.js"));
}

// async and defer carry their meaning by being present, so a value would be wrong rather than redundant.
@Test
void writesAnAttributeWithNoValueBare() {
ViteAssets assets = ViteAssets.builder(manifest).scriptAttribute("async").build();

assertEquals("<script type=\"module\" src=\"/assets/index.js\" async></script>",
assets.scriptTag("main.js").orElse(null));
}

@Test
void keepsAttributesInTheOrderTheyWereAdded() {
ViteAssets assets = ViteAssets.builder(manifest)
.scriptAttribute("crossorigin", "anonymous")
.scriptAttribute("async")
.scriptAttribute("data-id", "main")
.build();

assertEquals("<script type=\"module\" src=\"/assets/index.js\" crossorigin=\"anonymous\" async data-id=\"main\"></script>",
assets.scriptTag("main.js").orElse(null));
}

@Test
void keepsTheLastValueForAnAttributeAddedTwice() {
ViteAssets assets = ViteAssets.builder(manifest)
.scriptAttribute("crossorigin", "anonymous")
.scriptAttribute("crossorigin", "use-credentials")
.build();

assertEquals("<script type=\"module\" src=\"/assets/index.js\" crossorigin=\"use-credentials\"></script>",
assets.scriptTag("main.js").orElse(null));
}

// HTML reads the two as one attribute, so rendering both would put it on the tag twice and the
// browser would take the first - the opposite of the last-one-wins the caller was promised.
@Test
void treatsAttributeNamesDifferingOnlyInCaseAsOne() {
ViteAssets assets = ViteAssets.builder(manifest)
.scriptAttribute("crossorigin", "anonymous")
.scriptAttribute("CROSSORIGIN", "use-credentials")
.build();

assertEquals("<script type=\"module\" src=\"/assets/index.js\" crossorigin=\"use-credentials\"></script>",
assets.scriptTag("main.js").orElse(null));
}

// The same escaping the URLs already get. A value carrying a quote would otherwise end the attribute.
@Test
void escapesAnAttributeValue() {
ViteAssets assets = ViteAssets.builder(manifest).scriptAttribute("data-note", "a \"quote\" & <tag>").build();

assertEquals("<script type=\"module\" src=\"/assets/index.js\" data-note=\"a &quot;quote&quot; &amp; &lt;tag&gt;\"></script>",
assets.scriptTag("main.js").orElse(null));
}

// Overriding these makes the tag silently wrong rather than loudly broken, so it is refused up front.
@Test
void refusesTheAttributesItWritesItself() {
ViteAssets.Builder builder = ViteAssets.builder(manifest);

assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("src", "/elsewhere.js"));
assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("type", "text/javascript"));
assertThrows(IllegalArgumentException.class, () -> builder.styleAttribute("href", "/elsewhere.css"));
assertThrows(IllegalArgumentException.class, () -> builder.styleAttribute("rel", "preload"));
}

@Test
void refusesAReservedAttributeWhateverItsCase() {
ViteAssets.Builder builder = ViteAssets.builder(manifest);

assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("SRC", "/elsewhere.js"));
}

// A value can be escaped on the way out; a name cannot, so one that would break the markup is refused.
@Test
void refusesAttributeNamesThatAreNotNames() {
ViteAssets.Builder builder = ViteAssets.builder(manifest);

assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("", "x"));
assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute(null, "x"));
assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("one two", "x"));
assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("onload=alert(1) x", "y"));
assertThrows(IllegalArgumentException.class, () -> builder.scriptAttribute("a\"b", "x"));
}

// The two sets are separate: an attribute meant for the script has no business on a stylesheet.
@Test
void keepsScriptAndStyleAttributesApart() {
ViteAssets assets = ViteAssets.builder(manifest).scriptAttribute("async").build();

assertEquals(singletonList("<link rel=\"stylesheet\" href=\"/assets/shared.css\">"),
assets.stylesheetTags("_shared.js"));
}
}
41 changes: 41 additions & 0 deletions src/test/java/com/nulabinc/vite4j/jackson/RealManifestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,47 @@ void rendersThePageTagsInOrder() {
assertTrue(tags.get(3).contains("/static/assets/main-"), tags.get(3));
}


// The attributes have to land on every tag a real build produces, not just the one the
// hand-written fixtures happen to make. This entry needs two stylesheets, so a style attribute
// that only reached the first would pass elsewhere and fail here.
@Test
void putsTheAttributesOnEveryTagOfARealBuild() {
List<String> tags = ViteAssets.builder(manifest)
.assetUrl(path -> "/static/" + path)
.modulePreload(true)
.scriptAttribute("crossorigin", "anonymous")
.scriptAttribute("async")
.styleAttribute("data-turbo-track", "reload")
.build()
.tags("src/main.js");

List<String> stylesheets = tags.stream().filter(tag -> tag.contains("rel=\"stylesheet\"")).collect(java.util.stream.Collectors.toList());
assertEquals(2, stylesheets.size(), tags.toString());
assertTrue(stylesheets.stream().allMatch(tag -> tag.contains("data-turbo-track=\"reload\"")), stylesheets.toString());

String script = tags.get(tags.size() - 1);
assertTrue(script.contains("crossorigin=\"anonymous\" async>"), script);
assertTrue(script.contains("type=\"module\""), script);
}

// The preload links are the one tag kind that takes no attributes yet, so nothing should have
// leaked onto them from either set.
@Test
void leavesThePreloadLinksAlone() {
List<String> tags = ViteAssets.builder(manifest)
.modulePreload(true)
.scriptAttribute("async")
.styleAttribute("data-turbo-track", "reload")
.build()
.tags("src/main.js");

List<String> preloads = tags.stream().filter(tag -> tag.contains("rel=\"modulepreload\"")).collect(java.util.stream.Collectors.toList());
assertEquals(1, preloads.size(), tags.toString());
assertFalse(preloads.get(0).contains("data-turbo-track"), preloads.get(0));
assertFalse(preloads.get(0).contains("async"), preloads.get(0));
}

private static List<String> kindsOf(List<String> tags) {
return tags.stream()
.map(tag -> tag.startsWith("<link") ? "link" : "script")
Expand Down
Loading