Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ SPDX-FileCopyrightText = "asciinema contributors"
SPDX-License-Identifier = "Apache-2.0"
# NOTE: This file must be placed before deploying tutorials that use ::cast directives.
# Download from https://github.com/asciinema/asciinema-player/releases

[[annotations]]
path = "hugo/static/js/vendor/asciinema-player.css"
SPDX-FileCopyrightText = "asciinema contributors"
SPDX-License-Identifier = "Apache-2.0"
# NOTE: This file must be placed before deploying tutorials that use ::cast directives.
# Download from https://github.com/asciinema/asciinema-player/releases

66 changes: 66 additions & 0 deletions hugo/assets/js/__tests__/cast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest';

// Provide a minimal AsciinemaPlayer stub on window.
const mockCreate = vi.fn();
beforeEach(() => {
vi.resetModules();
document.body.innerHTML = '';
(window as unknown as Record<string, unknown>)['AsciinemaPlayer'] = { create: mockCreate };
mockCreate.mockClear();
});

it('mounts player on .tut-cast elements in viewport', async () => {
document.body.innerHTML = `
<div class="tut-cast" data-tut-cast="https://example.com/demo.cast"
data-rows="24" data-speed="1.5"></div>`;
const { initCast } = await import('../cast.js');
await initCast({ skipObserver: true }); // bypass IO for unit test
expect(mockCreate).toHaveBeenCalledOnce();
const [src, el, opts] = mockCreate.mock.calls[0];
expect(src).toBe('https://example.com/demo.cast');
expect(el).toBeInstanceOf(HTMLElement);
expect(opts.rows).toBe(24);
expect(opts.speed).toBe(1.5);
});

it('does not double-mount an already mounted element', async () => {
document.body.innerHTML = `
<div class="tut-cast" data-tut-cast="https://example.com/demo.cast"
data-cast-mounted="true"></div>`;
const { initCast } = await import('../cast.js');
await initCast({ skipObserver: true });
expect(mockCreate).not.toHaveBeenCalled();
});

it('falls back to immediate mount when IntersectionObserver absent', async () => {
document.body.innerHTML = `
<div class="tut-cast" data-tut-cast="https://example.com/demo.cast"></div>`;
// Remove IO from window to simulate absent support.
const origIO = (window as unknown as Record<string, unknown>)['IntersectionObserver'];
delete (window as unknown as Record<string, unknown>)['IntersectionObserver'];
const { initCast } = await import('../cast.js');
await initCast({});
expect(mockCreate).toHaveBeenCalledOnce();
(window as unknown as Record<string, unknown>)['IntersectionObserver'] = origIO;
});

it('passes idle-limit as idleTimeLimit to player', async () => {
document.body.innerHTML = `
<div class="tut-cast" data-tut-cast="https://example.com/demo.cast"
data-idle-limit="2"></div>`;
const { initCast } = await import('../cast.js');
await initCast({ skipObserver: true });
const [, , opts] = mockCreate.mock.calls[0];
expect(opts.idleTimeLimit).toBe(2);
});

it('removes the placeholder pre after mounting', async () => {
document.body.innerHTML = `
<div class="tut-cast" data-tut-cast="https://example.com/demo.cast">
<pre class="tut-cast__placeholder">Loading…</pre>
</div>`;
const { initCast } = await import('../cast.js');
await initCast({ skipObserver: true });
expect(document.querySelector('.tut-cast__placeholder')).toBeNull();
});
118 changes: 117 additions & 1 deletion hugo/assets/js/cast.ts
Original file line number Diff line number Diff line change
@@ -1 +1,117 @@
export {};
// cast.ts — lazy-mount asciinema player on .tut-cast[data-tut-cast] elements.
// The asciinema-player is vendored as an IIFE (sets window.AsciinemaPlayer),
// so we inject a <script> tag at runtime rather than using import().

declare const AsciinemaPlayer:
| { create(src: string, el: Element, opts: Record<string, unknown>): void }
| undefined;

const PLAYER_JS = '/js/vendor/asciinema-player.min.js';
const PLAYER_CSS = '/js/vendor/asciinema-player.css';

function loadPlayerResources(): Promise<void> {
return new Promise<void>((resolve) => {
// If the player global already exists, nothing to load.
if (typeof (window as unknown as Record<string, unknown>)['AsciinemaPlayer'] !== 'undefined') {
resolve();
return;
}

// Inject CSS link if not already present.
if (!document.querySelector(`link[href="${PLAYER_CSS}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = PLAYER_CSS;
document.head.appendChild(link);
}

// Inject script tag if not already present.
if (!document.querySelector(`script[src="${PLAYER_JS}"]`)) {
const script = document.createElement('script');
script.src = PLAYER_JS;
script.onload = () => resolve();
script.onerror = () => resolve(); // fail-open: resolve anyway
document.head.appendChild(script);
} else {
// Script tag exists but global not yet set — wait for it.
const existing = document.querySelector(`script[src="${PLAYER_JS}"]`) as HTMLScriptElement;
existing.addEventListener('load', () => resolve(), { once: true });
}
});
}

function mount(el: HTMLElement): void {
const src = el.dataset['tutCast'];
if (!src) return;

// Remove placeholder if present.
const placeholder = el.querySelector('.tut-cast__placeholder');
if (placeholder) placeholder.remove();

// Build options from data attributes.
const opts: Record<string, unknown> = {};
const rows = el.dataset['rows'];
const cols = el.dataset['cols'];
const speed = el.dataset['speed'];
const poster = el.dataset['poster'];
const idleLimit = el.dataset['idleLimit'];

if (rows !== undefined) opts['rows'] = Number(rows);
if (cols !== undefined) opts['cols'] = Number(cols);
if (speed !== undefined) opts['speed'] = Number(speed);
if (poster !== undefined) opts['poster'] = poster;
if (idleLimit !== undefined) opts['idleTimeLimit'] = Number(idleLimit);

// Mark as mounted to guard against double-mount.
el.dataset['castMounted'] = 'true';

// AsciinemaPlayer is available as a global from the IIFE.
(AsciinemaPlayer as NonNullable<typeof AsciinemaPlayer>).create(src, el, opts);
}

export async function initCast(opts?: { skipObserver?: boolean }): Promise<void> {
const elements = Array.from(
document.querySelectorAll<HTMLElement>('.tut-cast[data-tut-cast]:not([data-cast-mounted])')
);

if (elements.length === 0) return;

const skipIO = opts?.skipObserver === true;

if (skipIO || !('IntersectionObserver' in window)) {
// Bypass IntersectionObserver — mount immediately (test seam + IO-absent fallback).
await loadPlayerResources();
for (const el of elements) {
mount(el);
}
return;
}

// Lazy-mount via IntersectionObserver with a 200px root margin so the
// player loads slightly before scrolling into view.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
observer.unobserve(entry.target);
const target = entry.target as HTMLElement;
void loadPlayerResources().then(() => mount(target));
}
},
{ rootMargin: '200px' }
);

for (const el of elements) {
observer.observe(el);
}
}

// Auto-init when the script is loaded by the page.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void initCast({}));
} else {
void initCast({});
}

// Test seam — exported for unit tests only.
export const __test__ = { loadPlayerResources, mount };
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"start:approuter": "cd approuter && NODE_ENV=development npm start",
"bind:setup": "node scripts/setup-hybrid-env.js",
"seed:thirdparty": "echo 'RETIRED: HomepageShelves (incl. third-party links) is now seeded idempotently at boot by srv/lib/homepage/seed-homepage-shelves.js from srv/lib/homepage/homepage-shelves-defaults.js. Edit that module or use /admin-ui/#homepage; no manual seed step needed.' && exit 1",
"setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/channel-atlas install --no-audit --no-fund && sh scripts/install-git-hooks.sh",
"setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/channel-atlas install --no-audit --no-fund && sh scripts/install-git-hooks.sh && npm run vendor:asciinema",
"build:cds": "cds build --production",
"fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo",
"fetch-homepage-shelves": "tsx scripts/fetch-homepage-shelves.ts",
Expand Down Expand Up @@ -83,8 +83,9 @@
"vendor:mediapipe": "node scripts/vendor-mediapipe.cjs",
"vendor:imgly": "node scripts/vendor-imgly.cjs",
"vendor:animegan": "node scripts/vendor-animegan.mjs",
"vendor:asciinema": "node scripts/vendor-asciinema.mjs",
"clean:island-bundles": "node scripts/clean-island-bundles.cjs",
"build:apps": "npm run vendor:mediapipe && npm run vendor:imgly && npm run vendor:animegan && npm run clean:island-bundles && npm --prefix hugo-apps run build",
"build:apps": "npm run vendor:asciinema && npm run vendor:mediapipe && npm run vendor:imgly && npm run vendor:animegan && npm run clean:island-bundles && npm --prefix hugo-apps run build",
"build:island-manifest": "node scripts/build-island-manifest.cjs",
"check:ui5-single-copy": "node scripts/check-ui5-single-copy.cjs",
"retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json",
Expand Down
32 changes: 32 additions & 0 deletions scripts/__tests__/vendor-asciinema.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import fs from 'node:fs';

// Uses vi.mock to stub fetch and fs so the test runs offline.
// NOTE: vi.stubGlobal returns the `vi` object for chaining (not the mock),
// so we capture the mock fn separately before passing it to stubGlobal.
describe('vendor-asciinema', () => {
afterEach(() => { vi.restoreAllMocks(); });

it('exits 1 and prints error when fetch returns non-ok', async () => {
// Ensure sentinel check passes (returns false = not present) so we reach the fetch path.
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404, statusText: 'Not Found' }));
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); });
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { main } = await import('../vendor-asciinema.mjs?v=' + Date.now());
await expect(main()).rejects.toThrow('exit');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(consoleSpy.mock.calls.some(args => args[0].toString().includes('404'))).toBe(true);
});

it('skips download when sentinel file already present', async () => {
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
const { main } = await import('../vendor-asciinema.mjs?v=' + Date.now());
await main();
expect(mockFetch).not.toHaveBeenCalled();
existsSpy.mockRestore();
});
});

9 changes: 9 additions & 0 deletions scripts/parsers/directives/__tests__/cast-diagram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ describe('cast directive', () => {
const node = { name: 'cast', kind: 'leaf' as const, label: 'demo.cast', attrs: {}, classes: [], line: 1, column: 0 };
expect(def.toText(node, {})).toContain('demo.cast');
});

it('cast shortcode HTML has data-tut-cast and placeholder pre', () => {
// This test documents the attribute contract cast.ts relies on.
// The shortcode is Hugo-rendered — test the *directive output* which feeds Hugo.
const out = expandDirectives('::cast[demo.cast]{rows=24 idle-limit=2}', { slug: 's', repo: 'r', branch: 'b' });
// The output is a Hugo shortcode call (not HTML), so we verify the shortcode call params.
expect(out).toContain('rows="24"');
expect(out).toContain('idle-limit="2"');
});
});

describe('diagram directive', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/vendor-asciinema.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// scripts/vendor-asciinema.mjs
// Downloads asciinema-player v3.18.0 static assets from GitHub releases.
// Both files are placed in hugo/static/js/vendor/ (gitignored).
// Idempotent: skips if both files and sentinel are already present at the
// expected version.
//
// License: asciinema-player is Apache-2.0. REUSE.toml records both files.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const DEST_DIR = path.resolve(__dirname, '../hugo/static/js/vendor');
const VERSION = '3.18.0';
const SENTINEL = path.join(DEST_DIR, `.asciinema-vendored-${VERSION}`);

export async function main() {
if (fs.existsSync(SENTINEL)) {
console.log(`skipped (already v${VERSION})`);
return;
}

const files = ['asciinema-player.min.js', 'asciinema-player.css'];

for (const name of files) {
const url = `https://github.com/asciinema/asciinema-player/releases/download/v${VERSION}/${name}`;
const res = await fetch(url);
if (!res.ok) {
console.error(`Failed to download ${name}: ${res.status} ${res.statusText}`);
process.exit(1);
}
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(path.join(DEST_DIR, name), buf);
}

fs.writeFileSync(SENTINEL, VERSION);
console.log(`vendored asciinema-player v${VERSION}`);
}

// Only run when invoked directly (not when imported by tests)
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main().catch(e => { console.error(e); process.exit(1); });
}
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default defineConfig({
name: 'unit',
globals: true,
environment: 'node',
include: ['test/**/*.test.{js,ts}', 'scripts/__tests__/**/*.test.{js,ts}', 'scripts/**/__tests__/**/*.test.{js,ts}', 'srv/**/__tests__/**/*.test.{js,ts}', 'app/analytics-explorer/src/**/__tests__/**/*.test.ts', 'app/explore/src/**/__tests__/**/*.test.ts', 'app/channel-atlas/src/**/__tests__/**/*.test.ts', 'hugo-apps/src/**/*.{test,spec}.{js,ts}', '.claude/skills/**/__tests__/**/*.test.{js,ts}', 'hugo/assets/js/__tests__/**/*.test.{js,ts}'],
include: ['test/**/*.test.{js,mjs,ts}', 'scripts/__tests__/**/*.test.{js,mjs,ts}', 'scripts/**/__tests__/**/*.test.{js,mjs,ts}', 'srv/**/__tests__/**/*.test.{js,mjs,ts}', 'app/analytics-explorer/src/**/__tests__/**/*.test.ts', 'app/explore/src/**/__tests__/**/*.test.ts', 'app/channel-atlas/src/**/__tests__/**/*.test.ts', 'hugo-apps/src/**/*.{test,spec}.{js,ts}', '.claude/skills/**/__tests__/**/*.test.{js,mjs,ts}', 'hugo/assets/js/__tests__/**/*.test.{js,mjs,ts}'],
// test/e2e/** is the Playwright-driven `e2e` project (below). Its
// specs launch a real browser against a DEPLOYED approuter and would
// hang the unit tier (which has no BASE_URL); the broad
Expand Down
Loading