before ", + "after |
| x", + "y |
| ", + " |
in a table cell", + "
", + " |
", "
"], + ["a formatting element", "", ""], + ]; + it.each(wrappers)("matches the build in %s", (_label, open, close) => { + for (const sequence of sequences) { + const spans: Array<{ name: string; start: number; end: number }> = []; + let page = open; + for (const _ of sequence) { + spans.push({ + name: "x-c", + start: page.length, + end: page.length + 11, + }); + page += "x
"), + fc.constantFrom("", "y", "y", "y
"), + (pieces, [, open, close], lead, tail) => { + const spans: Array<{ name: string; start: number; end: number }> = + []; + let page = open + lead; + for (const _ of pieces) { + spans.push({ + name: "x-c", + start: page.length, + end: page.length + 11, + }); + page += "`, and where the page could not be followed, the instances are as
+ * written and stay so. One pass over the pieces' edges.
+ */
+const mergeSeamWhitespace = (
+ finished: string[],
+ spans: readonly UsageSpan[],
+ contexts: readonly FragmentContext[],
+ neighbors: SeamNeighbors,
+): string[] => {
+ if (finished.length < 2) return finished;
+ let changed = false;
+ /** The stretch being read: its tokens, and what it started after. */
+ let stretch: SeamToken[] = [];
+ let afterText = false;
+ let formatting = false;
+ const close = (beforeText: boolean): void => {
+ const runs: SeamToken[] = [];
+ let previous: SeamToken | undefined;
+ for (const token of stretch) {
+ if (!token.whitespace) {
+ previous = undefined;
+ continue;
+ }
+ if (previous) {
+ token.keep = false;
+ changed = true;
+ } else {
+ runs.push(token);
+ previous = token;
+ }
+ }
+ if (!formatting && runs.length > 0) {
+ // Runs that are part of the text on either side stay with it.
+ const first = stretch[0];
+ const last = stretch[stretch.length - 1];
+ let from = afterText && first.whitespace ? 1 : 0;
+ let to = runs.length;
+ if (beforeText && last.whitespace && runs.length > from) to--;
+ // A single run between two texts is both; it is counted once.
+ if (from > to) from = to;
+ // The build keeps that space only between two inline elements. Each
+ // instance checked its own neighbors, but one that follows text was
+ // minified as if it touched that text, which a comment separator
+ // between them undoes.
+ const keepLast = !beforeText && !afterText;
+ for (let index = from; index < to; index++) {
+ if (keepLast && index === to - 1) break;
+ runs[index].keep = false;
+ changed = true;
+ }
+ }
+ stretch = [];
+ };
+ let open = false;
+ const parts = finished.map((piece, index) => {
+ const context = contexts[index];
+ const skipped = context.inPre || context.verbatim;
+ const adjacent = index > 0 && spans[index].start === spans[index - 1].end;
+ if (open && (skipped || !adjacent)) {
+ // The stretch ran to the end of its run of instances.
+ close(neighbors.after[index - 1] === "text");
+ open = false;
+ }
+ if (skipped) return undefined;
+ if (!open) {
+ open = true;
+ afterText = neighbors.before[index] === "text";
+ formatting = context.formatting;
+ }
+ const seams = splitSeams(piece);
+ stretch.push(...seams.lead);
+ if (seams.middle !== "") {
+ const { first, last } = neighbors.edges(index);
+ close(first === "text");
+ afterText = last === "text";
+ formatting = context.formatting;
+ stretch.push(...seams.trail);
+ }
+ return seams;
+ });
+ if (open) close(neighbors.after[finished.length - 1] === "text");
+ if (!changed) return finished;
+ const text = (tokens: SeamToken[]): string =>
+ tokens.map((token) => (token.keep ? token.text : "")).join("");
+ return finished.map((piece, index) => {
+ const seams = parts[index];
+ return seams ? text(seams.lead) + seams.middle + text(seams.trail) : piece;
+ });
+};
+
/**
* The request path's finish for each expanded top-level usage. The build
* rewrites base paths in, then minifies, the whole page once
@@ -520,42 +688,41 @@ export const usageFinalizer = (
if (!manifest.minifyHtml) return rebased;
const contexts = fragmentContexts(html, spans, outer);
// Two usages written next to each other are each other's neighbors
- // in the page as their instances, not as their usage tags.
- const edges = new Map>();
- const edgesOf = (index: number) => {
- let found = edges.get(index);
- if (!found) edges.set(index, (found = fragmentEdges(rebased[index])));
- return found;
- };
- const finished = rebased.map((piece, index) => {
- const context = { ...contexts[index] };
- if (context.beforeIsSpan) {
- const last = edgesOf(index - 1).last;
- if (last !== "none") context.before = last;
- }
- if (context.afterIsSpan) {
- const first = edgesOf(index + 1).first;
- if (first !== "none") context.after = first;
- }
- return minifyHtmlFragment(piece, context);
- });
- // Whitespace at the end of one instance and the start of the next,
- // written with nothing between them, is one text run in the built
- // page and collapses to one space; outside a it is kept once.
- for (let index = 1; index < finished.length; index++) {
- const context = contexts[index];
- if (
- context.inPre ||
- context.verbatim ||
- spans[index].start !== spans[index - 1].end ||
- trailingCollapsibleWhitespace(finished[index - 1]) === 0
- )
- continue;
- finished[index] = finished[index].slice(
- leadingCollapsibleWhitespace(finished[index]),
- );
+ // in the page as their instances, not as their usage tags. An
+ // instance that leaves nothing at its edge (it is empty, or only
+ // comments and whitespace) is no neighbor: the one past it is, and
+ // past the last of them whatever the output has there.
+ const edges: Array | undefined> = [];
+ const edgesOf = (index: number) =>
+ (edges[index] ??= fragmentEdges(rebased[index]));
+ // Resolved from the far end of each run of usages, so a run of
+ // instances that leave nothing is walked once, not once per member.
+ const before: FragmentNeighbor[] = [];
+ for (let index = 0; index < rebased.length; index++) {
+ before[index] = contexts[index].before;
+ if (!contexts[index].beforeIsSpan) continue;
+ const last = edgesOf(index - 1).last;
+ before[index] = last !== "none" ? last : before[index - 1];
+ }
+ const after: FragmentNeighbor[] = [];
+ for (let index = rebased.length - 1; index >= 0; index--) {
+ after[index] = contexts[index].after;
+ if (!contexts[index].afterIsSpan) continue;
+ const first = edgesOf(index + 1).first;
+ after[index] = first !== "none" ? first : after[index + 1];
}
- return finished;
+ const finished = rebased.map((piece, index) =>
+ minifyHtmlFragment(piece, {
+ ...contexts[index],
+ before: before[index],
+ after: after[index],
+ }),
+ );
+ return mergeSeamWhitespace(finished, spans, contexts, {
+ edges: edgesOf,
+ before,
+ after,
+ });
}
: undefined;