Skip to content

Commit fb457ea

Browse files
committed
fix(readme-sync): validate marker order and literal replacement, add script tests
1 parent 67257fd commit fb457ea

9 files changed

Lines changed: 151 additions & 36 deletions

File tree

‎.github/scripts/sync-package-readmes.mjs‎

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,44 @@ const countByName = (text, re) => {
5858
* malformed file, not a partially-syncable one: silently skipping it is how a
5959
* stale block reaches npm.
6060
*/
61-
function assertWellFormed(text, label, allowed) {
62-
const starts = countByName(text, START_RE);
63-
const ends = countByName(text, END_RE);
64-
65-
for (const [name, n] of starts) {
66-
if (n > 1) fail(`${label}: block "${name}" has ${n} start markers, expected 1`);
67-
if ((ends.get(name) ?? 0) !== 1)
68-
fail(`${label}: block "${name}" has a start marker but no matching end marker`);
69-
if (allowed && !allowed.has(name))
70-
fail(`${label}: block "${name}" is not defined in the root README`);
61+
export function assertWellFormed(text, label, allowed, onError = fail) {
62+
const MARKER_RE = /<!-- polycss:shared:([a-z-]+):(start|end) -->/g;
63+
const names = new Set();
64+
let open = null;
65+
66+
for (const m of text.matchAll(MARKER_RE)) {
67+
const [, name, kind] = m;
68+
if (kind === "start") {
69+
if (open)
70+
return onError(
71+
`${label}: block "${name}" starts inside block "${open}" — blocks may not nest or cross`,
72+
);
73+
if (names.has(name))
74+
return onError(`${label}: block "${name}" appears more than once`);
75+
if (allowed && !allowed.has(name))
76+
return onError(`${label}: block "${name}" is not defined in the root README`);
77+
open = name;
78+
} else {
79+
if (open === null)
80+
return onError(
81+
`${label}: block "${name}" has an end marker before its start marker`,
82+
);
83+
if (open !== name)
84+
return onError(
85+
`${label}: block "${open}" is closed by "${name}" — blocks may not nest or cross`,
86+
);
87+
names.add(name);
88+
open = null;
89+
}
7190
}
72-
for (const [name, n] of ends) {
73-
if (n > 1) fail(`${label}: block "${name}" has ${n} end markers, expected 1`);
74-
if (!starts.has(name))
75-
fail(`${label}: block "${name}" has an end marker but no matching start marker`);
76-
}
77-
return starts;
91+
92+
if (open) return onError(`${label}: block "${open}" is never closed`);
93+
return names;
7894
}
7995

96+
function main() {
8097
const rootText = readFileSync(source, "utf8");
81-
const rootStarts = assertWellFormed(rootText, "root README", null);
82-
const names = [...rootStarts.keys()];
98+
const names = [...assertWellFormed(rootText, "root README", null)];
8399

84100
if (names.length === 0) {
85101
fail("no shared blocks found in the root README — refusing to run");
@@ -98,15 +114,20 @@ for (const target of targets) {
98114
let text;
99115
try {
100116
text = readFileSync(path, "utf8");
101-
} catch {
102-
continue;
117+
} catch (err) {
118+
// A listed package must have a readable README — packages opt out by
119+
// carrying no markers, not by going missing. Skipping here would let a
120+
// deleted or unreadable README pass both `--check` and `prepack`.
121+
fail(`${target}: cannot be read (${err.code ?? err.message})`);
103122
}
104123

105124
const present = assertWellFormed(text, target, new Set(names));
106125

107126
let next = text;
108-
for (const name of present.keys()) {
109-
next = next.replace(blockRe(name), blocks.get(name));
127+
for (const name of present) {
128+
// Replacement passed as a callback: README content is arbitrary text and
129+
// `$&` / `` $` `` / `$'` in a replacement STRING would be expanded.
130+
next = next.replace(blockRe(name), () => blocks.get(name));
110131
}
111132

112133
if (next === text) continue;
@@ -134,3 +155,6 @@ if (checkOnly) {
134155
console.log(
135156
`[sync-package-readmes] ${updated} README${updated === 1 ? "" : "s"} updated, ${names.length} shared block${names.length === 1 ? "" : "s"}`,
136157
);
158+
}
159+
160+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Unit tests for the README shared-block synchroniser's marker validation.
3+
*
4+
* `assertWellFormed` is the gate that decides whether a package README is
5+
* syncable. Every bug found here so far has been a case it accepted and then
6+
* silently failed to replace, letting `--check` report success while a stale
7+
* block shipped to npm — so the tests below are all about REJECTION.
8+
*/
9+
import { strict as assert } from "node:assert";
10+
import test from "node:test";
11+
import { assertWellFormed } from "./sync-package-readmes.mjs";
12+
13+
const S = (n) => `<!-- polycss:shared:${n}:start -->`;
14+
const E = (n) => `<!-- polycss:shared:${n}:end -->`;
15+
const ALLOWED = new Set(["links", "packages", "license"]);
16+
17+
/** Collects the error instead of exiting, so a rejection is observable. */
18+
const check = (text) => {
19+
let error = null;
20+
const names = assertWellFormed(text, "fixture", ALLOWED, (m) => {
21+
error = m;
22+
return null;
23+
});
24+
return { error, names };
25+
};
26+
27+
test("accepts a well-formed file and reports its blocks", () => {
28+
const { error, names } = check(`# Pkg\n${S("links")}\nx\n${E("links")}\nbody\n${S("license")}\nMIT\n${E("license")}\n`);
29+
assert.equal(error, null);
30+
assert.deepEqual([...names], ["links", "license"]);
31+
});
32+
33+
test("accepts a file with no markers at all (opted out)", () => {
34+
const { error, names } = check("# Pkg\n\nNo shared blocks here.\n");
35+
assert.equal(error, null);
36+
assert.equal(names.size, 0);
37+
});
38+
39+
test("rejects an end marker that precedes its start", () => {
40+
const { error } = check(`${E("links")}\nx\n${S("links")}\n`);
41+
assert.match(error, /end marker before its start/);
42+
});
43+
44+
test("rejects a duplicated block", () => {
45+
const { error } = check(`${S("links")}a${E("links")}\n${S("links")}b${E("links")}`);
46+
assert.match(error, /appears more than once/);
47+
});
48+
49+
test("rejects nested blocks", () => {
50+
const { error } = check(`${S("links")}\n${S("license")}\nx\n${E("license")}\n${E("links")}`);
51+
assert.match(error, /may not nest or cross/);
52+
});
53+
54+
test("rejects crossing blocks", () => {
55+
const { error } = check(`${S("links")}\n${S("license")}\n${E("links")}\n${E("license")}`);
56+
assert.match(error, /may not nest or cross/);
57+
});
58+
59+
test("rejects an unclosed block", () => {
60+
const { error } = check(`${S("links")}\nx\n`);
61+
assert.match(error, /is never closed/);
62+
});
63+
64+
test("rejects a block name the root README does not define", () => {
65+
const { error } = check(`${S("bogus")}\nx\n${E("bogus")}`);
66+
assert.match(error, /not defined in the root README/);
67+
});
68+
69+
test("replacement copies content literally, including $ sequences", () => {
70+
// Regression: passing the block as a replacement STRING expands `$&`,
71+
// "$`" and `$'`, corrupting any README containing them.
72+
const blockRe = /<!-- polycss:shared:links:start -->[\s\S]*?<!-- polycss:shared:links:end -->/;
73+
const replacement = `${S("links")}\ncost: $5 — see $& and $\` and $'\n${E("links")}`;
74+
const target = `head\n${S("links")}\nold\n${E("links")}\ntail`;
75+
76+
const viaString = target.replace(blockRe, replacement);
77+
const viaCallback = target.replace(blockRe, () => replacement);
78+
79+
assert.ok(viaCallback.includes("$& and $` and $'"), "callback must copy verbatim");
80+
assert.notEqual(viaString, viaCallback, "string form is the buggy path this guards against");
81+
});

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
- name: Install dependencies
3535
run: pnpm install --frozen-lockfile
3636

37+
- name: Test repo scripts
38+
run: pnpm test:scripts
39+
3740
- name: Check README shared blocks
3841
run: pnpm check:readmes
3942

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim
5252

5353
Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare `<u>`, because it belongs to the non-triangle clipped-solid family. `<s>` is the universal fallback and cannot be disabled. Solid seam bleed gives detected shared solid edges up to `1.5` CSS px of per-edge overscan, fitted to the polygon plan, rather than inflating every side of each participating polygon. It IS exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Sub-1 numbers additionally scale the per-strategy primitive bleeds in both, by different factors (vanilla via its recomputed ratio, React/Vue by the raw value). Unifying this is a pending cross-renderer fix, not a docs problem.
5454

55-
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces onto scene-level receiver surfaces where `receiveShadow` is enabled. **Renderer divergence on the no-receiver case:** vanilla dropped its legacy virtual ground-shadow fallback for Three.js parity — a caster with no receiver in the scene draws nothing, `emitGroundShadow` is now dead code, and `hideGroundShadow()` suppresses any legacy leftovers every tick. React/Vue still emit a per-mesh ground-plane shadow when a caster has no receiver, and drop it as soon as any receiver exists. Reconciling the two is an open decision. EVERY polygon casts — shadow casters are NOT filtered to the camera-rendered set (atlas plan) or de-duplicated, because a polygon casts a shadow regardless of whether it's painted for the camera; filtering left camera-dependent holes in imported-mesh shadows. Coincident/overlapping projections are merged into one compound path per caster under `fill-rule: nonzero`, so they don't alpha-stack rather than being pre-dropped. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). All of a receiver FACE's lights are merged into **one SVG per face** so overlapping shadows composite correctly: a single-light face paints its remaining color directly (one path); a multi-light solid face paints a base = full-lit color `C` then each light as a `mix-blend-mode: multiply` layer with factor `remaining/C`, so the both-blocked overlap becomes `C·∏factor` (ambient only). `mix-blend-mode` works *within* one SVG but NOT across SVGs (`preserve-3d` isolates each SVG against a transparent backdrop — verified), which is why the merge is per-face rather than per-light. Textured receivers (per-pixel base, no uniform multiply) fall back to per-pass alpha layers that cumulatively darken. The per-face color uses the face CENTROID direction (matching the baked per-polygon shading) so the base leaves no visible color box. The per-face merge is the shared core helper `computeMergedReceiverShadows` (runs every light pass + aggregates each face into one SVG descriptor); all three renderers call it and only emit the `<svg>`/`<path>` nodes, so multi-light overlap is identical everywhere. Moving a light or changing caster/receiver geometry re-emits the shadow SVGs; this is DOM/SVG work only and does not redraw texture atlases.
55+
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces onto scene-level receiver surfaces where `receiveShadow` is enabled. **Renderer divergence on the no-receiver case:** vanilla dropped its legacy virtual ground-shadow fallback for Three.js parity — a caster with no receiver in the scene draws nothing, `emitGroundShadow` is now dead code, and `hideGroundShadow()` suppresses any legacy leftovers every tick. React/Vue still emit a per-mesh ground-plane shadow when a caster has no receiver, and drop it as soon as any receiver exists. Reconciling the two is an open decision. EVERY polygon casts — shadow casters are NOT filtered to the camera-rendered set (atlas plan), because a polygon casts a shadow regardless of whether it's painted for the camera; filtering left camera-dependent holes in imported-mesh shadows. Coincident/back-to-back duplicate faces ARE pre-dropped, via `findOverlappingPolygonDuplicates` in all three renderers (vanilla `dedupByCaster`, React/Vue `dedupDrop`). Coincident/overlapping projections are merged into one compound path per caster under `fill-rule: nonzero`, so they don't alpha-stack rather than being pre-dropped. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). All of a receiver FACE's lights are merged into **one SVG per face** so overlapping shadows composite correctly: a single-light face paints its remaining color directly (one path); a multi-light solid face paints a base = full-lit color `C` then each light as a `mix-blend-mode: multiply` layer with factor `remaining/C`, so the both-blocked overlap becomes `C·∏factor` (ambient only). `mix-blend-mode` works *within* one SVG but NOT across SVGs (`preserve-3d` isolates each SVG against a transparent backdrop — verified), which is why the merge is per-face rather than per-light. Textured receivers (per-pixel base, no uniform multiply) fall back to per-pass alpha layers that cumulatively darken. The per-face color uses the face CENTROID direction (matching the baked per-polygon shading) so the base leaves no visible color box. The per-face merge is the shared core helper `computeMergedReceiverShadows` (runs every light pass + aggregates each face into one SVG descriptor); all three renderers call it and only emit the `<svg>`/`<path>` nodes, so multi-light overlap is identical everywhere. Moving a light or changing caster/receiver geometry re-emits the shadow SVGs; this is DOM/SVG work only and does not redraw texture atlases.
5656

5757
Receiver-shadow geometry has two caster paths. The default per-mesh **silhouette fast path** (caster ≠ receiver, ≥40 polys) projects one outline per caster instead of every front-facing triangle — but only when the caster's silhouette under the current light is a clean union of simple closed loops (every silhouette vertex shared by exactly two silhouette edges). Meshes whose silhouette has non-manifold / T-junction / open-boundary vertices (imported architecture like the castle) fall back to the **per-polygon union**, which is gap-free for any topology. Light-back-facing caster polygons are normally culled (single-sided casting, correct for clean closed meshes); the per-poly path casts **double-sided** (skips that cull) for two cases — cross-mesh casters whose silhouette is unreliable, and ALL self-shadow casters (caster = receiver) — so badly-wound / single-sided interior walls don't leave holes. Closed meshes are unaffected by double-siding: their far back-faces sit below each lit receiver plane and get above-plane-culled, adding no spurious shadow.
5858

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"test:coverage": "pnpm --filter './packages/*' -r --if-present test:coverage",
2525
"sync:readmes": "node .github/scripts/sync-package-readmes.mjs",
2626
"check:readmes": "node .github/scripts/sync-package-readmes.mjs --check",
27+
"test:scripts": "node --test .github/scripts/*.test.mjs",
2728
"publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' -r publish --access public",
2829
"dev:website": "pnpm --filter @layoutit/polycss-website dev",
2930
"build:website": "pnpm --filter @layoutit/polycss-website build",

‎packages/polycss/src/api/scene/types.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,20 +189,24 @@ export interface PolyMeshTransform {
189189
*/
190190
excludeFromAutoCenter?: boolean;
191191
/**
192-
* When `true`, this mesh casts a shadow onto the scene's shadow ground
193-
* plane (and onto any meshes marked `receiveShadow: true`). The shadow
194-
* emits as one per-mesh `<svg>` whose path is the union of every
195-
* casting polygon's projection. Works in both lighting modes.
196-
* Defaults to `false`.
192+
* When `true`, this mesh casts a shadow onto any mesh marked
193+
* `receiveShadow: true`. The shadow emits as one `<svg>` per receiver face
194+
* whose path is the union of every casting polygon's projection. Works in
195+
* both lighting modes. Defaults to `false`.
196+
*
197+
* Vanilla has NO ground-plane fallback: with no receiver in the scene a
198+
* caster draws nothing (Three.js `castShadow` / `receiveShadow` parity).
199+
* React/Vue additionally project onto the scene ground plane when no
200+
* receiver exists.
197201
*/
198202
castShadow?: boolean;
199203
/**
200204
* **(experimental)** When `true`, this mesh acts as a shadow receiver:
201205
* each of its polygon faces becomes a target plane that casting meshes'
202206
* shadows project onto and get clipped to. Useful for "shadow on table"
203-
* scenarios. Currently only convex face outlines clip cleanly. When no
204-
* receivers are present the global ground plane is used as today.
205-
* Defaults to `false`.
207+
* scenarios. Currently only convex face outlines clip cleanly. In vanilla a
208+
* receiver is REQUIRED for any shadow to appear — there is no ground-plane
209+
* fallback. Defaults to `false`.
206210
*/
207211
receiveShadow?: boolean;
208212
/**

‎website/src/content/docs/api/types.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ interface Polygon {
2828
textureAlphaMode?: PolyTextureAlphaMode;
2929
/** Shared material. `material.texture` takes precedence over `texture`. */
3030
material?: PolyMaterial;
31-
/** Source-exact image backing. Renders as a direct image leaf using the
32-
* caller's URL and source rect — no atlas rasterisation, no atlas memory,
33-
* and source lighting is preserved. */
31+
/** Source-exact image metadata (URL + source rect). It does not by itself
32+
* select a direct image leaf: the resolved presentation must end up with
33+
* `backend: "image"` and source lighting, and the default `"auto"` backend
34+
* resolves to the atlas. When it does apply, there is no atlas
35+
* rasterisation and no atlas memory. */
3436
textureImageSource?: PolyTextureImageSource;
3537
/** Backend, projection, filtering, and lighting request for this polygon's
3638
* texture. See Texture Presentation below. */

0 commit comments

Comments
 (0)