diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index bb9a05d0d..758215fbe 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -282,9 +282,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { // CLI-specific routes (before shared API) app.get("/api/runtime.js", (c) => { const serve = async () => { - const runtimeSource = existsSync(runtimePath) - ? readFileSync(runtimePath, "utf-8") - : await loadRuntimeSourceFallback(); + // Prefer the runtime generated from the current core source over a + // potentially stale copied artifact. This keeps local studio/preview + // sessions aligned with source edits without requiring a manual + // rebuild of the CLI runtime bundle first. + const runtimeSource = + (await loadRuntimeSourceFallback()) ?? + (existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null); if (!runtimeSource) return c.text("runtime not available", 404); return c.body(runtimeSource, 200, { "Content-Type": "text/javascript", diff --git a/packages/core/src/lint/rules/media.test.ts b/packages/core/src/lint/rules/media.test.ts index 2d52cc965..684d58676 100644 --- a/packages/core/src/lint/rules/media.test.ts +++ b/packages/core/src/lint/rules/media.test.ts @@ -106,4 +106,55 @@ describe("media rules", () => { const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element"); expect(finding).toBeUndefined(); }); + + it("reports imperative play() control on managed media ids", () => { + const html = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "imperative_media_control"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.elementId).toBe("demo-video"); + }); + + it("reports imperative currentTime writes on query-selected managed media", () => { + const html = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "imperative_media_control"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("does not flag play() on non-media elements", () => { + const html = ` + +
+
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "imperative_media_control"); + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/core/src/lint/rules/media.ts b/packages/core/src/lint/rules/media.ts index 5aeb7bf3d..33066b751 100644 --- a/packages/core/src/lint/rules/media.ts +++ b/packages/core/src/lint/rules/media.ts @@ -1,6 +1,139 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import { readAttr, truncateSnippet, isMediaTag } from "../utils"; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function selectorTargetsManagedMedia(selector: string, mediaIds: Set): boolean { + const normalized = selector.trim(); + if (!normalized) return false; + if (/\b(video|audio)\b/i.test(normalized)) return true; + for (const mediaId of mediaIds) { + if ( + normalized.includes(`#${mediaId}`) || + normalized.includes(`[id="${mediaId}"]`) || + normalized.includes(`[id='${mediaId}']`) + ) { + return true; + } + } + return false; +} + +function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] { + const findings: HyperframeLintFinding[] = []; + const managedMediaIds = new Set( + ctx.tags + .filter((tag) => tag.name === "video" || tag.name === "audio") + .map((tag) => readAttr(tag.raw, "id")) + .filter((id): id is string => Boolean(id)), + ); + + if (managedMediaIds.size === 0 || ctx.scripts.length === 0) return findings; + + for (const script of ctx.scripts) { + const mediaVars = new Map(); + const assignmentPatterns = [ + /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g, + /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)/g, + ]; + + for (const pattern of assignmentPatterns) { + let match: RegExpExecArray | null; + while ((match = pattern.exec(script.content)) !== null) { + const variableName = match[1]; + const target = match[2]; + if (!variableName || !target) continue; + if (managedMediaIds.has(target) || selectorTargetsManagedMedia(target, managedMediaIds)) { + mediaVars.set(variableName, managedMediaIds.has(target) ? target : undefined); + } + } + } + + const directIdPatterns = [ + { + pattern: + /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g, + kind: "play()", + }, + { + pattern: + /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g, + kind: "pause()", + }, + { + pattern: + /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g, + kind: "currentTime", + }, + { + pattern: + /\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g, + kind: "play()", + }, + { + pattern: + /\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g, + kind: "pause()", + }, + { + pattern: + /\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g, + kind: "currentTime", + }, + ]; + + for (const { pattern, kind } of directIdPatterns) { + let match: RegExpExecArray | null; + while ((match = pattern.exec(script.content)) !== null) { + const target = match[1]; + if (!target) continue; + const elementId = managedMediaIds.has(target) + ? target + : selectorTargetsManagedMedia(target, managedMediaIds) + ? undefined + : null; + if (elementId === null) continue; + findings.push({ + code: "imperative_media_control", + severity: "error", + message: `Inline