diff --git a/e2e/specs/console-line-logs.spec.ts b/e2e/specs/console-line-logs.spec.ts new file mode 100644 index 0000000000..d9ac23ec71 --- /dev/null +++ b/e2e/specs/console-line-logs.spec.ts @@ -0,0 +1,500 @@ +import { expect, type Frame, type Page } from '@playwright/test'; +import type { UrlQueryParams } from '../../src/livecodes/models'; +import { getLoadedApp } from '../helpers'; +import { test } from '../test-fixtures'; + +interface ConsoleEntry { + text: string; + sourceLine: string | null; +} + +const getConsoleEntries = async (app: Frame): Promise => + app.evaluate(() => + [...document.querySelectorAll('#console-container .luna-console-log-item')] + .map((item) => ({ + text: item.querySelector('.luna-console-log-content')?.textContent?.trim() ?? '', + sourceLine: item.querySelector('.console-line-number')?.textContent?.trim() ?? null, + })) + .filter((entry) => entry.text !== 'Console was cleared'), + ); + +const waitForConsoleEntries = async (app: Frame, expectedCount: number) => { + await app.waitForFunction( + (count) => + [...document.querySelectorAll('#console-container .luna-console-log-item')] + .map((item) => item.querySelector('.luna-console-log-content')?.textContent?.trim() ?? '') + .filter((text) => text !== '' && text !== 'Console was cleared').length >= count, + expectedCount, + ); +}; + +const openWithCode = async ( + page: Page, + getTestUrl: (config?: UrlQueryParams) => string, + { + markup, + script, + language = 'js', + }: { markup: string; script: string; language?: string; filename?: string }, +) => { + const scriptParam = language === 'ts' ? 'ts' : language === 'js' ? 'js' : language; + await page.goto( + getTestUrl({ + tools: 'console|open', + active: 'script', + html: encodeURIComponent(markup), + [scriptParam]: encodeURIComponent(script), + }), + ); + const { app, waitForResultUpdate } = await getLoadedApp(page); + await waitForResultUpdate(); + await app.click('#tools-pane-titles > .console'); + return { app, waitForResultUpdate }; +}; + +test.describe('Console line logs', () => { + test('shows script line for JavaScript template log', async ({ page, getTestUrl }) => { + // Arrange + const markup = [ + '
', + '

Hello, World!

', + ' ', + '

You clicked 0 times.

', + ' ', + '
', + ].join('\n'); + const script = [ + 'const title = document.querySelector("#title");', + 'const counter = document.querySelector("#counter");', + 'const button = document.querySelector("#counter-button");', + '', + 'title.innerText = "JavaScript";', + 'let count = 0;', + 'console.log("line 7 in js template")', + 'button.addEventListener("click", () => {', + ' count++;', + ' counter.innerText = count;', + '});', + ].join('\n'); + + // Act + const { app } = await openWithCode(page, getTestUrl, { markup, script }); + await waitForConsoleEntries(app, 1); + const entries = await getConsoleEntries(app); + + // Assert + expect(entries.find((e) => e.text === 'line 7 in js template')?.sourceLine).toBe('script:7'); + }); + + test('groups repeated logs from same line into one entry', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '
console test
', + script: 'for (let i = 0; i < 5; i++) console.log("zew");', + }); + + // Act + await waitForConsoleEntries(app, 1); + await app.waitForTimeout(300); + const zewEntries = (await getConsoleEntries(app)).filter((e) => e.text === 'zew'); + + // Assert + expect(zewEntries).toHaveLength(1); + expect(zewEntries[0].sourceLine).toBe('script:1'); + }); + + test('shows markup line for inline script logs', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '
inline
\n', + script: 'document.body.dataset.ready = "1";', + }); + + // Act + await waitForConsoleEntries(app, 1); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + const sourceLine = entries.find((e) => e.text === 'inline markup log')?.sourceLine; + expect(sourceLine).toMatch(/^markup:\d+$/); + const lineNum = Number(sourceLine!.split(':')[1]); + expect(lineNum).toBeGreaterThanOrEqual(2); + expect(lineNum).toBeLessThanOrEqual(4); + }); + + test('groups same-line loop logs and regroups after interruption', async ({ + page, + getTestUrl, + }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '
test
', + script: [ + 'for (let i = 0; i < 3; i++) console.log("looping");', + 'console.log("break");', + 'for (let i = 0; i < 2; i++) console.log("looping");', + ].join('\n'), + }); + + // Act + await waitForConsoleEntries(app, 3); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + expect(entries).toHaveLength(3); + expect(entries[0]).toEqual({ text: 'looping', sourceLine: 'script:1' }); + expect(entries[1]).toEqual({ text: 'break', sourceLine: 'script:2' }); + expect(entries[2]).toEqual({ text: 'looping', sourceLine: 'script:3' }); + }); + + test('does not merge same text from markup and script', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '
mixed
\n', + script: 'console.log("same text")', + }); + + // Act + await waitForConsoleEntries(app, 1); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + const sameTextEntries = entries.filter((e) => e.text === 'same text'); + expect(sameTextEntries).toHaveLength(2); + const sourceLines = sameTextEntries.map((e) => e.sourceLine).sort(); + expect(sourceLines[0]).toMatch(/^markup:\d+$/); + expect(sourceLines[1]).toMatch(/^script:\d+$/); + }); + + test('console.time does not shift badge queue', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '', + script: ["console.log('before');", "console.time('t');", "console.log('after');"].join('\n'), + }); + + // Act + await waitForConsoleEntries(app, 2); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + expect(entries.find((e) => e.text === 'before')?.sourceLine).toBe('script:1'); + expect(entries.find((e) => e.text === 'after')?.sourceLine).toBe('script:3'); + }); + + test('console.assert(true) does not shift badge queue', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '', + script: [ + "console.log('before');", + 'console.assert(1 === 1, "should not appear");', + "console.log('after');", + ].join('\n'), + }); + + // Act + await waitForConsoleEntries(app, 2); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + expect(entries.find((e) => e.text === 'before')?.sourceLine).toBe('script:1'); + expect(entries.find((e) => e.text === 'after')?.sourceLine).toBe('script:3'); + }); + + test('console.countReset does not shift badge queue', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '', + script: [ + "console.log('before');", + "console.count('x');", + "console.countReset('x');", + "console.log('after');", + ].join('\n'), + }); + + // Act + await waitForConsoleEntries(app, 3); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + expect(entries.find((e) => e.text === 'before')?.sourceLine).toBe('script:1'); + expect(entries.find((e) => e.text === 'after')?.sourceLine).toBe('script:4'); + }); + + test('does not group identical logs from different lines (same source)', async ({ + page, + getTestUrl, + }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '', + script: ['console.log("same");', 'console.log("same");'].join('\n'), + }); + + // Act + await waitForConsoleEntries(app, 2); + await app.waitForTimeout(300); + const entries = (await getConsoleEntries(app)).filter((e) => e.text === 'same'); + + // Assert + expect(entries).toHaveLength(2); + expect(entries[0].sourceLine).toBe('script:1'); + expect(entries[1].sourceLine).toBe('script:2'); + }); + + test('groups logs from the same source line called from multiple call sites', async ({ + page, + getTestUrl, + }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '', + script: ['const greet = () => console.log("hi");', 'greet();', 'greet();', 'greet();'].join( + '\n', + ), + }); + + // Act + await waitForConsoleEntries(app, 1); + await app.waitForTimeout(300); + const entries = (await getConsoleEntries(app)).filter((e) => e.text === 'hi'); + + // Assert + expect(entries).toHaveLength(1); + expect(entries[0].sourceLine).toBe('script:1'); + }); + + test('markup inline script: different lines do not group, loop lines do', async ({ + page, + getTestUrl, + }) => { + // Arrange + const markup = [ + '', + ].join('\n'); + const { app } = await openWithCode(page, getTestUrl, { + markup, + script: '', + }); + + // Act + await waitForConsoleEntries(app, 3); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + const byeEntries = entries.filter((e) => e.text === 'bye'); + + // Assert + expect(byeEntries).toHaveLength(3); + for (const entry of byeEntries) { + expect(entry.sourceLine).toMatch(/^markup:\d+$/); + } + const lines = byeEntries.map((e) => Number(e.sourceLine!.split(':')[1])); + expect(lines[1]).toBe(lines[0] + 1); + expect(lines[2]).toBeGreaterThan(lines[1]); + }); + + test('markup inline script: line numbers correct when TypeScript is active', async ({ + page, + getTestUrl, + }) => { + // Arrange + const markup = [ + '
', + '

Hello

', + '

paragraph

', + ' ', + '
', + '', + '', + ].join('\n'); + const { app } = await openWithCode(page, getTestUrl, { + markup, + script: 'const greeting: string = "hello";', + language: 'ts', + }); + + // Act + await waitForConsoleEntries(app, 2); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + const lineA = entries.find((e) => e.text === 'ts-markup-a')?.sourceLine; + const lineB = entries.find((e) => e.text === 'ts-markup-b')?.sourceLine; + + // Assert + expect(lineA).toMatch(/^markup:\d+$/); + expect(lineB).toMatch(/^markup:\d+$/); + const numA = Number(lineA!.split(':')[1]); + const numB = Number(lineB!.split(':')[1]); + expect(numA).toBeGreaterThanOrEqual(7); + expect(numB).toBe(numA + 1); + }); + + test('markup runtime errors should map to markup line numbers', async ({ page, getTestUrl }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: [ + 'some text', + '', + '', + ].join('\n'), + script: '', + }); + + // Act + await waitForConsoleEntries(app, 2); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + const logLine = entries.find((e) => e.text.includes('line-number-bug-log'))?.sourceLine; + const errLine = entries.find((e) => e.text.includes('line-number-bug-error'))?.sourceLine; + + // Assert + expect(logLine).toMatch(/^markup:\d+$/); + expect(errLine).toMatch(/^markup:\d+$/); + const logNum = Number(logLine?.split(':')[1]); + const errNum = Number(errLine?.split(':')[1]); + expect(logNum).toBeGreaterThanOrEqual(3); + expect(logNum).toBeLessThanOrEqual(5); + expect(errNum).toBeGreaterThanOrEqual(6); + expect(errNum).toBeLessThanOrEqual(8); + }); + + test('markup inline script: same-value logs keep exact lines in JS/TS/React', async ({ + page, + getTestUrl, + }) => { + // Arrange + const markup = [ + '
', + '

Hello, World!

', + ' ', + '

You clicked 0 times.

', + ' ', + '
', + '', + '', + ].join('\n'); + + const cases = [ + { + label: 'JavaScript', + language: 'js', + script: 'document.body.dataset.ready = "js";', + }, + { + label: 'TypeScript', + language: 'ts', + script: 'const ready: string = "ts"; document.body.dataset.ready = ready;', + }, + { + label: 'React', + language: 'react', + script: 'export default function App() { return null; }', + }, + ] as const; + + for (const testCase of cases) { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup, + script: testCase.script, + language: testCase.language, + }); + + // Act + await waitForConsoleEntries(app, 2); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + const sameValueEntries = entries.filter((e) => e.text === 'same value'); + + // Assert + expect(sameValueEntries).toHaveLength(2); + const firstLine = sameValueEntries[0].sourceLine; + const secondLine = sameValueEntries[1].sourceLine; + expect(firstLine).toMatch(/^markup:\d+$/); + expect(secondLine).toMatch(/^markup:\d+$/); + const firstNum = Number(firstLine?.split(':')[1]); + const secondNum = Number(secondLine?.split(':')[1]); + expect(firstNum).toBeGreaterThanOrEqual(8); + expect(firstNum).toBeLessThanOrEqual(10); + expect(secondNum).toBeGreaterThanOrEqual(9); + expect(secondNum).toBeLessThanOrEqual(11); + expect(secondNum).toBeGreaterThan(firstNum); + } + }); + + test('TypeScript source map maps compiled lines to original TS lines', async ({ + page, + getTestUrl, + }) => { + // Arrange + const { app } = await openWithCode(page, getTestUrl, { + markup: '', + script: [ + 'interface Greeter { name: string; }', + 'interface Config { debug: boolean; }', + 'interface Options { timeout: number; }', + '', + 'const msg = "ts source map works";', + 'console.log(msg);', + ].join('\n'), + language: 'ts', + }); + + // Act + await waitForConsoleEntries(app, 1); + await app.waitForTimeout(300); + const entries = await getConsoleEntries(app); + + // Assert + expect(entries.find((e) => e.text === 'ts source map works')?.sourceLine).toBe('script:6'); + }); + + test('badge queue resets correctly after page re-run', async ({ page, getTestUrl }) => { + // Arrange + const { app, waitForResultUpdate } = await openWithCode(page, getTestUrl, { + markup: '', + script: ["console.time('t');", "console.log('hello');"].join('\n'), + }); + await waitForConsoleEntries(app, 1); + + // Act & Assert — first run + let entries = await getConsoleEntries(app); + expect(entries.find((e) => e.text === 'hello')?.sourceLine).toBe('script:2'); + + // Act & Assert — after re-run, queue should reset + await waitForResultUpdate(); + await waitForConsoleEntries(app, 1); + await app.waitForTimeout(300); + entries = await getConsoleEntries(app); + expect(entries.find((e) => e.text === 'hello')?.sourceLine).toBe('script:2'); + }); +}); diff --git a/src/livecodes/compiler/__tests__/source-maps.spec.ts b/src/livecodes/compiler/__tests__/source-maps.spec.ts new file mode 100644 index 0000000000..a4b1107ecd --- /dev/null +++ b/src/livecodes/compiler/__tests__/source-maps.spec.ts @@ -0,0 +1,247 @@ +import { buildSourceLineMap, decodeVlq, toPositiveLineNumber } from '../source-maps'; + +describe('toPositiveLineNumber', () => { + test('returns the integer for a positive number', () => { + // Arrange + const input = 7; + + // Act + const result = toPositiveLineNumber(input); + + // Assert + expect(result).toBe(7); + }); + + test('truncates a positive float to its integer part', () => { + // Arrange + const input = 3.9; + + // Act + const result = toPositiveLineNumber(input); + + // Assert + expect(result).toBe(3); + }); + + test('returns undefined for zero', () => { + expect(toPositiveLineNumber(0)).toBeUndefined(); + }); + + test('returns undefined for a negative number', () => { + expect(toPositiveLineNumber(-1)).toBeUndefined(); + }); + + test('returns undefined for NaN', () => { + expect(toPositiveLineNumber(NaN)).toBeUndefined(); + }); + + test('returns undefined for Infinity', () => { + expect(toPositiveLineNumber(Infinity)).toBeUndefined(); + }); + + test('returns undefined for a string', () => { + expect(toPositiveLineNumber('7')).toBeUndefined(); + }); + + test('returns undefined for undefined', () => { + expect(toPositiveLineNumber(undefined)).toBeUndefined(); + }); + + test('returns undefined for null', () => { + expect(toPositiveLineNumber(null)).toBeUndefined(); + }); +}); + +describe('decodeVlq', () => { + test('decodes a single positive value', () => { + // Arrange + // 'A' = 0 in base64, VLQ value 0 + const input = 'A'; + const startPos = 0; + + // Act + const result = decodeVlq(input, startPos); + + // Assert + expect(result).toEqual([0, 1]); + }); + + test('decodes a positive single-digit VLQ', () => { + // Arrange + // 'C' = 2 in base64. VLQ: low bit = sign (0 = positive), remaining = 1 → value 1 + const input = 'C'; + const startPos = 0; + + // Act + const result = decodeVlq(input, startPos); + + // Assert + expect(result).toEqual([1, 1]); + }); + + test('decodes a negative single-digit VLQ', () => { + // Arrange + // 'D' = 3 in base64. VLQ: low bit = 1 (negative), remaining = 1 → value -1 + const input = 'D'; + const startPos = 0; + + // Act + const result = decodeVlq(input, startPos); + + // Assert + expect(result).toEqual([-1, 1]); + }); + + test('decodes a multi-character VLQ (continuation bit set)', () => { + // Arrange + // 'yB' encodes value 25: + // 'y' = 50 = 0b110010 → continuation bit set, low 5 bits = 0b10010 = 18 + // 'B' = 1 = 0b000001 → continuation bit clear, value = 1 + // combined: (1 << 5) | 18 = 50, sign bit (low) = 0 → positive → 50 >> 1 = 25 + const input = 'yB'; + const startPos = 0; + + // Act + const result = decodeVlq(input, startPos); + + // Assert + expect(result).toEqual([25, 2]); + }); + + test('advances position correctly when starting mid-string', () => { + // Arrange + const input = 'AAA'; + const startPos = 1; + + // Act + const [value, nextPos] = decodeVlq(input, startPos); + + // Assert + expect(value).toBe(0); + expect(nextPos).toBe(2); + }); + + test('stops at invalid character', () => { + // Arrange + // '!' is not a base64 VLQ character → indexOf returns -1 → loop breaks immediately + const input = '!'; + const startPos = 0; + + // Act + const [value, nextPos] = decodeVlq(input, startPos); + + // Assert + expect(value).toBe(0); + expect(nextPos).toBe(1); // pos was incremented before the break + }); +}); + +describe('buildSourceLineMap', () => { + test('returns empty map for invalid JSON', () => { + // Arrange + const input = 'not json'; + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.size).toBe(0); + }); + + test('returns empty map for missing mappings field', () => { + // Arrange + const input = JSON.stringify({ version: 3 }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.size).toBe(0); + }); + + test('returns empty map for empty mappings string', () => { + // Arrange + const input = JSON.stringify({ mappings: '' }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.size).toBe(0); + }); + + test('maps compiled line 1 to original line 1 for identity mapping', () => { + // Arrange + // Minimal source map: one segment on compiled line 1 + // Segment "AAAA": col=0, sources=0, line=0, col=0 → originalLine delta=0 → line 1 + const input = JSON.stringify({ mappings: 'AAAA' }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.get(1)).toBe(1); + }); + + test('maps compiled lines to correct original lines across multiple compiled lines', () => { + // Arrange + // TypeScript-like: 2 preamble compiled lines (no mapping), then user code starts + // mappings: ";;AACA" → compiled line 1 empty, line 2 empty, line 3 → original line 2 + // "AACA": col=0, sources=0, line delta=+1 (from 0) → originalLine=1 → line 2 (1-indexed) + const input = JSON.stringify({ mappings: ';;AACA' }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.get(1)).toBeUndefined(); + expect(map.get(2)).toBeUndefined(); + expect(map.get(3)).toBe(2); + }); + + test('only records the first segment per compiled line', () => { + // Arrange + // Two segments on compiled line 1: first maps to original line 1, second to line 3 + // "AAAA,AAEA": first=line 1, second=line delta +2 → original line 3 + const input = JSON.stringify({ mappings: 'AAAA,AAEA' }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + // First segment wins + expect(map.get(1)).toBe(1); + }); + + test('handles a realistic TypeScript source map structure', () => { + // Arrange + // Simulate: TS source has 3 lines, compiled output has 3 lines starting at the same positions + // Each compiled line maps back to the corresponding original line + // mappings: "AAAA;AACA;AACA" + // Line 1: AAAA → delta=0 → original line 1 + // Line 2: AACA → delta=+1 → original line 2 + // Line 3: AACA → delta=+1 → original line 3 + const input = JSON.stringify({ mappings: 'AAAA;AACA;AACA' }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.get(1)).toBe(1); + expect(map.get(2)).toBe(2); + expect(map.get(3)).toBe(3); + }); + + test('skips segments with only one field (no source info)', () => { + // Arrange + // A segment with only a generated column (no sources index, no line) should be skipped + // "A" = only generated column field, no further fields + const input = JSON.stringify({ mappings: 'A' }); + + // Act + const map = buildSourceLineMap(input); + + // Assert + expect(map.get(1)).toBeUndefined(); + }); +}); diff --git a/src/livecodes/compiler/import-map.ts b/src/livecodes/compiler/import-map.ts index a915cd0040..3084cd5a19 100644 --- a/src/livecodes/compiler/import-map.ts +++ b/src/livecodes/compiler/import-map.ts @@ -286,9 +286,9 @@ export const cjs2esm = (code: string) => { [ `import * as __requires_${i} from '${id}';`, `const __requires_${i}_default = __requires_${i}.default;`, - ].join('\n'), + ].join(' '), ) - .join('\n'); + .join(' '); const lookup = `const __requires_lookup = { ${requires .map((id, i) => `'${id}': __requires_${i}_default || __requires_${i}`) .join(', ')} };`; @@ -296,7 +296,7 @@ export const cjs2esm = (code: string) => { const require = `window.require = window.require || ((id) => { if (id in __requires_lookup) return __requires_lookup[id]; throw new Error(\`Cannot require modules dynamically (\${id})\`); -});`; +});`.replaceAll('\n', ''); return [ imports, @@ -304,8 +304,8 @@ export const cjs2esm = (code: string) => { require, `const exports = {}; const module = { exports };`, code, - `export default module.exports;`, - ].join('\n\n'); + `\nexport default module.exports;`, + ].join(' '); }; export const createCSSModulesImportMap = ( diff --git a/src/livecodes/compiler/source-maps.ts b/src/livecodes/compiler/source-maps.ts new file mode 100644 index 0000000000..e1b2898c73 --- /dev/null +++ b/src/livecodes/compiler/source-maps.ts @@ -0,0 +1,237 @@ +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type ConsoleSource = 'markup' | 'script'; + +export type ConsoleDisplaySource = ConsoleSource | 'style'; + +export interface ConsoleCallSite { + lineNumber?: number; + source: ConsoleSource; + callerFrame?: string; + markupOffset?: number; + scriptOffset?: number; + externalScriptFrame?: boolean; +} + +export const isConsoleDisplaySource = (source: unknown): source is ConsoleDisplaySource => + source === 'markup' || source === 'style' || source === 'script'; + +// ─── Line number utilities ───────────────────────────────────────────────────── + +export const toPositiveLineNumber = (line: unknown): number | undefined => { + if (typeof line !== 'number' || !Number.isFinite(line) || line <= 0) return undefined; + return Math.trunc(line); +}; + +// ─── Source map decoder ──────────────────────────────────────────────────────── + +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export const decodeVlq = (str: string, pos: number): [value: number, nextPos: number] => { + let result = 0; + let shift = 0; + let digit: number; + do { + digit = BASE64_CHARS.indexOf(str[pos++]); + if (digit < 0) break; + result += (digit % 32) * 2 ** shift; // low 5 bits, shifted into position + shift += 5; + } while (digit >= 32); // bit 5 is the continuation flag + const value = result % 2 === 1 ? -Math.floor(result / 2) : Math.floor(result / 2); + return [value, pos]; +}; + +// Returns the original line delta for a segment, or null if the segment lacks source info. +// Source map segments have up to 5 VLQ fields: generatedCol, sourcesIndex, originalLine, +// originalCol, namesIndex. We need at least 3 fields to get the original line. +const decodeOriginalLineDelta = (segment: string): number | null => { + if (!segment) return null; + let pos = 0; + [, pos] = decodeVlq(segment, pos); // skip: generated column + if (pos >= segment.length) return null; + [, pos] = decodeVlq(segment, pos); // skip: sources index + if (pos >= segment.length) return null; + return decodeVlq(segment, pos)[0]; // original line delta +}; + +export const buildSourceLineMap = /* @__PURE__ */ (sourceMapStr: string): Map => { + const lineMap = new Map(); + + let mappings: string; + try { + mappings = JSON.parse(sourceMapStr).mappings; + } catch { + return lineMap; // invalid JSON — nothing to map + } + if (!mappings || typeof mappings !== 'string') return lineMap; + + let originalLine = 0; + mappings.split(';').forEach((group, compiledLine) => { + for (const segment of group.split(',')) { + const delta = decodeOriginalLineDelta(segment); + if (delta === null) continue; + originalLine += delta; // delta is relative to previous segment across all lines + if (!lineMap.has(compiledLine + 1)) { + lineMap.set(compiledLine + 1, originalLine + 1); // convert 0-indexed to 1-indexed + } + } + }); + + return lineMap; +}; + +// ─── Markup inline-script line resolver ─────────────────────────────────────── + +const toUserLine = (docLine: number, offset: number): number | undefined => { + if (!offset) return undefined; + return toPositiveLineNumber(docLine - offset); +}; + +const getMarkupInlineScriptStartLines = () => { + const scriptTags = Array.from( + document.body?.querySelectorAll( + 'script[data-livecodes-markup-script-line]', + ) ?? [], + ); + return scriptTags + .map((script) => Number(script.dataset.livecodesMarkupScriptLine)) + .filter((line) => Number.isFinite(line) && line > 0) + .sort((a, b) => a - b); +}; + +const getCurrentMarkupScriptStartLine = () => { + const script = document.currentScript; + if (!script) return undefined; + const startLine = Number(script.dataset.livecodesMarkupScriptLine); + return toPositiveLineNumber(startLine); +}; + +const getCurrentMarkupScriptStackBase = (): number => { + const script = document.currentScript; + if (!script) return 1; + return Number(script.dataset.livecodesMarkupScriptStackBase) || 1; +}; + +const getMarkupInlineScriptLine = (docLine: number, markupOffset: number): number | undefined => { + const currentScriptStartLine = getCurrentMarkupScriptStartLine(); + if (currentScriptStartLine) { + const stackBase = getCurrentMarkupScriptStackBase(); + return toPositiveLineNumber(currentScriptStartLine + docLine - stackBase); + } + + if (docLine < markupOffset) { + const firstScriptStartLine = getMarkupInlineScriptStartLines()[0]; + if (firstScriptStartLine) { + return toPositiveLineNumber(firstScriptStartLine + docLine - 1); + } + } + + return toUserLine(docLine, markupOffset); +}; + +// ─── Console call-site detector ─────────────────────────────────────────────── + +const getOffsets = () => ({ + markup: Number(document.body?.dataset?.livecodesMarkupLineOffset ?? 0), + script: Number(document.body?.dataset?.livecodesScriptLineOffset ?? 0), +}); + +const isExternalScriptFrame = (frame: string) => + /data:text\/javascript/i.test(frame) || + /blob:/i.test(frame) || + /\.[cm]?js(?:[?#][^)\s]*)?:\d+:\d+\)?[\s]*$/i.test(frame); + +const getCandidateFrame = (stack: string) => { + const frames = stack.split('\n').filter(Boolean); + const codeFrames = frames.filter( + (frame, index) => + index > 0 && + !/result-utils(?:\.[\w-]+)?\.js/i.test(frame) && + frame.match(/:(\d+):\d+\)?[\s]*$/), + ); + return codeFrames.find(isExternalScriptFrame) ?? codeFrames[0] ?? ''; +}; + +const getLineNumberFromFrame = (callerFrame: string): number | undefined => { + const match = callerFrame.match(/:(\d+):\d+\)?[\s]*$/); + return toPositiveLineNumber(Number(match?.[1])); +}; + +const resolveConsoleCallSiteFromDocLine = ( + docLine: number, + callerFrame?: string, +): ConsoleCallSite => { + const { markup, script } = getOffsets(); + const externalScriptFrame = isExternalScriptFrame(callerFrame ?? ''); + + if (externalScriptFrame || (!markup && !script)) { + return { + lineNumber: docLine, + source: 'script', + callerFrame, + markupOffset: markup, + scriptOffset: script, + externalScriptFrame, + }; + } + + if (script > 0) { + const source: ConsoleSource = docLine >= script ? 'script' : 'markup'; + const lineNumber = + source === 'script' + ? toUserLine(docLine, script) ?? docLine + : getMarkupInlineScriptLine(docLine, markup) ?? docLine; + return { + lineNumber, + source, + callerFrame, + markupOffset: markup, + scriptOffset: script, + externalScriptFrame, + }; + } + + if (markup > 0) { + return { + lineNumber: getMarkupInlineScriptLine(docLine, markup) ?? docLine, + source: 'markup', + callerFrame, + markupOffset: markup, + scriptOffset: script, + externalScriptFrame, + }; + } + + return { + lineNumber: docLine, + source: 'script', + callerFrame, + markupOffset: markup, + scriptOffset: script, + externalScriptFrame, + }; +}; + +export const getConsoleCallSiteFromError = ( + lineNumber: unknown, + stack?: string, +): ConsoleCallSite => { + const callerFrame = stack ? getCandidateFrame(stack) : undefined; + const docLine = getLineNumberFromFrame(callerFrame ?? '') ?? toPositiveLineNumber(lineNumber); + if (!docLine) return { source: 'script', callerFrame }; + + return resolveConsoleCallSiteFromDocLine(docLine, callerFrame); +}; + +export const getConsoleCallSite = (): ConsoleCallSite => { + try { + const stack = new Error().stack ?? ''; + const callerFrame = getCandidateFrame(stack); + const docLine = getLineNumberFromFrame(callerFrame); + if (!docLine) return { source: 'script', callerFrame }; + + return resolveConsoleCallSiteFromDocLine(docLine, callerFrame); + } catch { + return { source: 'script' }; + } +}; diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index a5300e7f7d..6348a26fdc 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -999,6 +999,10 @@ const getResultPage = async ({ }); const compiledScript = scriptCompileResult.code; + const { sourceMaps } = scriptCompileResult.info ?? {}; + const consoleSourceMaps = sourceMaps ?? (scriptLanguage === 'javascript' ? null : undefined); + toolsPane?.console?.setSourceMap?.(consoleSourceMaps); + let compileInfo: CompileInfo = { ...markupCompileResult.info, ...scriptCompileResult.info, @@ -1009,6 +1013,10 @@ const getResultPage = async ({ ...scriptCompileResult.info.imports, ...markupCompileResult.info.imports, }, + sourceMaps: { + ...markupCompileResult.info.sourceMaps, + ...scriptCompileResult.info.sourceMaps, + }, }; const [styleCompileResult, testsCompileResult] = await Promise.all([ @@ -5475,6 +5483,10 @@ const createApi = (): API => { } }; + eventsManager.addEventListener(window, customEvents.consoleNavigate, (e: any) => { + apiShow(e.detail.editorId, { line: e.detail.line }); + }); + const apiRunTests: API['runTests'] = () => new Promise((resolve) => { const watcher = sdkWatchers.tests.subscribe((testResults) => { diff --git a/src/livecodes/editor/codemirror/codemirror.ts b/src/livecodes/editor/codemirror/codemirror.ts index a947789215..07762e687b 100644 --- a/src/livecodes/editor/codemirror/codemirror.ts +++ b/src/livecodes/editor/codemirror/codemirror.ts @@ -496,11 +496,11 @@ export const createEditor = async (options: EditorOptions): Promise return { lineNumber, column }; }; - const setPosition = ({ lineNumber, column = 1 }: EditorPosition) => { - const col = column - 1; // columns in codemirror start at 0 - const line = view.state.doc.lines > lineNumber ? lineNumber : view.state.doc.lines; + const setPosition = ({ lineNumber, column }: EditorPosition) => { + const col = column && column > 0 ? column : 1; + const line = Math.max(1, Math.min(lineNumber, view.state.doc.lines)); const lineInfo = view.state.doc.line(line); - const columnNumber = lineInfo.length > col ? col : lineInfo.length; + const columnNumber = Math.max(0, Math.min(col - 1, lineInfo.length)); const position = lineInfo.from + columnNumber; view.dispatch({ selection: { anchor: position }, diff --git a/src/livecodes/editor/monaco/monaco.ts b/src/livecodes/editor/monaco/monaco.ts index 473e0dc253..90a5df0c98 100644 --- a/src/livecodes/editor/monaco/monaco.ts +++ b/src/livecodes/editor/monaco/monaco.ts @@ -605,7 +605,7 @@ export const createEditor = async (options: EditorOptions): Promise const setPosition = (position: EditorPosition) => { const newPosition = { lineNumber: position.lineNumber, - column: position.column ?? 1, + column: position.column && position.column > 0 ? position.column : 1, }; editor.setPosition(newPosition); setTimeout(() => editor.revealPositionInCenter(newPosition, 0), 50); diff --git a/src/livecodes/events/custom-events.ts b/src/livecodes/events/custom-events.ts index 80b2713b07..b18e1305a7 100644 --- a/src/livecodes/events/custom-events.ts +++ b/src/livecodes/events/custom-events.ts @@ -11,6 +11,7 @@ export const customEvents: CustomEvents = { run: 'livecodes-run', testResults: 'livecodes-test-results', console: 'livecodes-console', + consoleNavigate: 'livecodes-console-navigate', destroy: 'livecodes-destroy', resizeEditor: 'livecodes-resize-editor', apiResponse: 'livecodes-api-response', diff --git a/src/livecodes/languages/react/lang-react.ts b/src/livecodes/languages/react/lang-react.ts index 01e843923b..42b214deb3 100644 --- a/src/livecodes/languages/react/lang-react.ts +++ b/src/livecodes/languages/react/lang-react.ts @@ -26,7 +26,7 @@ export const react: LanguageSpecs = { 'babel-plugin-react-compiler' as any, config, ); - return (window as any).Babel.transform(code, { + const result = (window as any).Babel.transform(code, { filename: 'script.tsx', presets: [ ['env', { modules: false, ...presetEnvConfig }], @@ -35,7 +35,14 @@ export const react: LanguageSpecs = { ], plugins: [[(window as any).reactCompiler.reactCompiler, reactCompilerConfig]], ...babelConfig, - }).code; + sourceMaps: true, + }); + return { + code: result.code, + info: { + sourceMaps: result.map ? { script: JSON.stringify(result.map) } : undefined, + }, + }; }, }, extensions: ['react.jsx', 'react-jsx'], diff --git a/src/livecodes/languages/typescript/lang-typescript.ts b/src/livecodes/languages/typescript/lang-typescript.ts index bb0053a6da..2800b994a8 100644 --- a/src/livecodes/languages/typescript/lang-typescript.ts +++ b/src/livecodes/languages/typescript/lang-typescript.ts @@ -37,15 +37,33 @@ export const typescript: LanguageSpecs = { url: typescriptUrl, factory: () => - async (code, { config }) => - (window as any).ts.transpile(code, { + async (code, { config }) => { + const ts = (window as any).ts; + const rawOptions = { ...typescriptOptions, ...(['jsx', 'tsx'].includes(config.script.language) && !hasCustomJsxRuntime(code, config) ? { jsx: 'react-jsx' } : {}), ...getLanguageCustomSettings('typescript', config), ...getLanguageCustomSettings(config.script.language, config), - }), + sourceMap: true, + }; + const { options: compilerOptions, errors } = ts.convertCompilerOptionsFromJson( + rawOptions, + '', + ); + if (errors?.length) { + // eslint-disable-next-line no-console + console.warn('TypeScript compiler option errors:', errors); + } + const result = ts.transpileModule(code, { compilerOptions }); + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=\S+/m, ''), + info: { + sourceMaps: result.sourceMapText ? { script: result.sourceMapText } : undefined, + }, + }; + }, }, extensions: ['ts', 'mts', 'typescript'], editor: 'script', diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index 4663bc8ad3..26b24e20fd 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -142,6 +142,7 @@ export interface CompileInfo { importedContent?: string; imports?: Record; errors?: string[]; + sourceMaps?: Record; } export interface CompileResult { @@ -292,6 +293,7 @@ export interface Console extends Tool { evaluate: (code: string) => void; reloadEditor: (config: Config) => Promise; setTheme?: (theme: Theme) => void; + setSourceMap?: (sourceMaps: Record | null | undefined) => void; } export interface CompiledCodeViewer extends Tool { diff --git a/src/livecodes/result/result-page.ts b/src/livecodes/result/result-page.ts index c269305b90..2640584be4 100644 --- a/src/livecodes/result/result-page.ts +++ b/src/livecodes/result/result-page.ts @@ -115,6 +115,19 @@ export const createResultPage = async ({ // editor markup const markup = code.markup.compiled; dom.body.innerHTML += markup; + const consoleEnabled = + !forExport && + (config.tools?.enabled === 'all' || + config.tools?.enabled == null || + config.tools?.enabled.includes('console')); + const markupInlineScripts = dom.body.querySelectorAll('script:not([src])'); + const shouldMarkInlineScripts = consoleEnabled && markupInlineScripts.length > 0; + if (shouldMarkInlineScripts) { + // Mark inline scripts from markup so runtime can recover absolute markup line numbers. + markupInlineScripts.forEach((script, index) => { + script.dataset.livecodesMarkupScriptId = String(index + 1); + }); + } // user-defined import map in