fix(lint): recognize Three.js loaded via ESM URL/path imports (#1805)

The missing_three_script rule only treated a bare import from 'three' as
loading Three.js, so ESM imports whose specifier is a URL or path (e.g.
.../+esm CDN builds, esm.sh/three, unpkg three.module.js, or a local
three.module.js) were not recognized. Compositions using THREE. with such
an import got a false blocking error, pushing authors onto the deprecated
UMD global build.

Generalize the module-import detection to count any import/from whose
specifier contains "three" (case-insensitive), matching the existing
loose /three/i treatment of <script src>. Bare 'three', importmap, and
<script src> paths are unchanged; the specifier must still contain
"three", so unrelated imports do not satisfy it.
This commit is contained in:
Miguel Ángel
2026-06-30 11:31:42 -07:00
committed by GitHub
parent 11432ea8ce
commit 27a7f37494
2 changed files with 87 additions and 2 deletions
+82
View File
@@ -124,6 +124,88 @@ describe("adapter rules", () => {
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for an ESM +esm CDN import (jsdelivr)", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/+esm';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for an esm.sh/three import", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import * as THREE from 'https://esm.sh/three';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for a local three.module.js import", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { Scene } from './vendor/three.module.js';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new Scene();
THREE.foo();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for a bare 'three' import (regression)", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import * as THREE from 'three';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("still reports missing_three_script when THREE is used with no three loaded", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { gsap } from 'https://cdn.jsdelivr.net/npm/gsap@3/+esm';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not report any adapter errors for composition with no adapter usage", async () => {
const html = `
<html><body>
+5 -2
View File
@@ -36,8 +36,11 @@ export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
/["']three["']/.test(t) &&
/importmap/.test(scripts.find((s) => s.content === t)?.attrs || ""),
);
const hasThreeModuleImport = texts.some(
(t) => /\bimport\b.*['"]three['"]/.test(t) || /\bfrom\s+['"]three['"]/.test(t),
// Matches any import/from whose specifier contains "three" (bare 'three', or a
// URL/path like .../+esm, esm.sh/three, three.module.js), mirroring the loose
// /three/i treatment of <script src>.
const hasThreeModuleImport = texts.some((t) =>
/\b(?:import|from)\s*[^;\n]*['"][^'"]*three[^'"]*['"]/i.test(t),
);
if (!usesThree || hasThreeScript || hasThreeImportMap || hasThreeModuleImport) return [];