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
117 changes: 77 additions & 40 deletions packages/base/default-templates/markdown.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import LinkOffIcon from '@cardstack/boxel-icons/link-off';

import {
bfmBlockFormatAndSize,
buildWaiter,
cardTypeName,
extractMermaidBlocks,
processKatexPlaceholders,
Expand Down Expand Up @@ -42,6 +43,11 @@ function wrapTablesHtml(html: string | null | undefined): string {
return doc.body.innerHTML;
}

// Lets `settled()` wait for the async markdown rendering work (Mermaid/KaTeX
// lazy-loading and the deferred card-slot collection) that is kicked off by
// modifiers and ember-concurrency tasks after the initial render settles.
const markdownRenderingWaiter = buildWaiter('markdown-rendering');

type CardSlotFormat = 'atom' | 'embedded' | 'fitted' | 'isolated';
type SlotState = 'resolved' | 'loading' | 'unresolved';

Expand Down Expand Up @@ -179,6 +185,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{
let baseUrl = this.args.cardReferenceBaseUrl;
let virtualNetwork = this.args.cardReferenceVirtualNetwork;
let pendingUpdate = false;
let pendingToken: unknown = undefined;
// On the very first modifier run linkedCards is likely still loading
// (empty []) so we skip unresolved Pills to avoid flashing them for
// refs that will soon resolve. On subsequent runs (linkedCards changed)
Expand Down Expand Up @@ -280,22 +287,28 @@ export default class MarkDownTemplate extends GlimmerComponent<{
// re-render → observer fires again.
let updateSlots = () => {
pendingUpdate = false;
let nextSlots = collectSlots();
let didChange =
nextSlots.length !== this.renderSlots.length ||
nextSlots.some((slot, index) => {
let current = this.renderSlots[index];
if (!current || current.element !== slot.element) return true;
if (current.kind !== slot.kind) return true;
if (current.state !== slot.state) return true;
if (current.format !== slot.format) return true;
if (current.card !== slot.card) return true;
if (current.url !== slot.url) return true;
return String(current.style ?? '') !== String(slot.style ?? '');
});

if (didChange) {
this.renderSlots = nextSlots;
let token = pendingToken;
pendingToken = undefined;
try {
let nextSlots = collectSlots();
let didChange =
nextSlots.length !== this.renderSlots.length ||
nextSlots.some((slot, index) => {
let current = this.renderSlots[index];
if (!current || current.element !== slot.element) return true;
if (current.kind !== slot.kind) return true;
if (current.state !== slot.state) return true;
if (current.format !== slot.format) return true;
if (current.card !== slot.card) return true;
if (current.url !== slot.url) return true;
return String(current.style ?? '') !== String(slot.style ?? '');
});

if (didChange) {
this.renderSlots = nextSlots;
}
} finally {
markdownRenderingWaiter.endAsync(token);
}
};

Expand All @@ -304,15 +317,26 @@ export default class MarkDownTemplate extends GlimmerComponent<{
return;
}
pendingUpdate = true;
pendingToken = markdownRenderingWaiter.beginAsync();
scheduleOnce('afterRender', this, updateSlots);
};

scheduleUpdate();

// End any in-flight waiter token on teardown so a destroyed modifier
// (e.g. the scheduled update never flushed) cannot leave `settled()`
// hanging. `updateSlots` clears `pendingToken` first, so this only fires
// for a still-pending update.
let endPendingToken = () => {
let token = pendingToken;
pendingToken = undefined;
markdownRenderingWaiter.endAsync(token);
};

// MutationObserver re-collects slots when the DOM is reconstructed
// (e.g. after browser back-navigation rebuilds the element's children).
if (typeof MutationObserver === 'undefined') {
return;
return endPendingToken;
}

let observer = new MutationObserver(scheduleUpdate);
Expand All @@ -321,7 +345,10 @@ export default class MarkDownTemplate extends GlimmerComponent<{
subtree: true,
});

return () => observer.disconnect();
return () => {
observer.disconnect();
endPendingToken();
};
},
);

Expand All @@ -339,11 +366,16 @@ export default class MarkDownTemplate extends GlimmerComponent<{
}

_loadKatexTask = task({ drop: true }, async () => {
let loadKatex = (globalThis as any).__loadKatex;
if (typeof loadKatex !== 'function') {
return;
let token = markdownRenderingWaiter.beginAsync();
try {
let loadKatex = (globalThis as any).__loadKatex;
if (typeof loadKatex !== 'function') {
return;
}
this._katex = await loadKatex();
} finally {
markdownRenderingWaiter.endAsync(token);
}
this._katex = await loadKatex();
});

// ── Mermaid lazy loading + pre-rendering ──
Expand Down Expand Up @@ -372,27 +404,32 @@ export default class MarkDownTemplate extends GlimmerComponent<{
return;
}

let mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'default',
});
let token = markdownRenderingWaiter.beginAsync();
try {
let mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'default',
});

let svgs = new Map<string, string>();
for (let block of blocks) {
try {
let { svg } = await mermaid.render(
`mermaid-${++this._mermaidIdCounter}`,
block,
);
svgs.set(block, svg);
} catch {
// skip failed blocks
let svgs = new Map<string, string>();
for (let block of blocks) {
try {
let { svg } = await mermaid.render(
`mermaid-${++this._mermaidIdCounter}`,
block,
);
svgs.set(block, svg);
} catch {
// skip failed blocks
}
}
}

this._mermaidSvgs = svgs;
this._mermaidSvgs = svgs;
} finally {
markdownRenderingWaiter.endAsync(token);
}
});

getCardComponent = (card: BaseDef) => getComponent(card);
Expand Down
2 changes: 1 addition & 1 deletion packages/base/flac-audio-def.gts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFirstBytes } from '@cardstack/runtime-common';
import FileAudioIcon from '@cardstack/boxel-icons/file-audio';
import AudioDef from './audio-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import type { ByteStream, SerializedFile } from './file-api';
import { extractFlacDuration } from './flac-meta-extractor';

// "fLaC" marker (4) + STREAMINFO block header (4) + STREAMINFO data (34) = 42.
Expand Down
2 changes: 1 addition & 1 deletion packages/base/m4a-audio-def.gts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import FileAudioIcon from '@cardstack/boxel-icons/file-audio';
import AudioDef from './audio-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import type { ByteStream, SerializedFile } from './file-api';
import { extractM4aDurationFromStream } from './m4a-meta-extractor';

export class M4aDef extends AudioDef {
Expand Down
32 changes: 13 additions & 19 deletions packages/base/m4a-meta-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,7 @@ function parseMvhd(
if (version === 0) {
// creation(4) + modification(4) + timescale(4) + duration(4)
if (cursor + 16 > mvhd.payloadEnd) {
throw new FileContentMismatchError(
'MP4 mvhd (v0) box is truncated',
);
throw new FileContentMismatchError('MP4 mvhd (v0) box is truncated');
}
let timescale = view.getUint32(cursor + 8);
let duration = view.getUint32(cursor + 12);
Expand All @@ -133,9 +131,7 @@ function parseMvhd(
if (version === 1) {
// creation(8) + modification(8) + timescale(4) + duration(8)
if (cursor + 28 > mvhd.payloadEnd) {
throw new FileContentMismatchError(
'MP4 mvhd (v1) box is truncated',
);
throw new FileContentMismatchError('MP4 mvhd (v1) box is truncated');
}
let timescale = view.getUint32(cursor + 16);
let durHi = view.getUint32(cursor + 20);
Expand Down Expand Up @@ -168,9 +164,7 @@ export function extractM4aDuration(bytes: Uint8Array): { duration: number } {

let moov = findChildBox(bytes, view, 0, bytes.length, MOOV);
if (!moov) {
throw new FileContentMismatchError(
'MP4 file does not contain a moov box',
);
throw new FileContentMismatchError('MP4 file does not contain a moov box');
}
return durationFromMoov(bytes, view, moov);
}
Expand All @@ -184,18 +178,20 @@ function durationFromMoov(
view: DataView,
moov: BoxLocation,
): { duration: number } {
let mvhd = findChildBox(bytes, view, moov.payloadOffset, moov.payloadEnd, MVHD);
let mvhd = findChildBox(
bytes,
view,
moov.payloadOffset,
moov.payloadEnd,
MVHD,
);
if (!mvhd) {
throw new FileContentMismatchError(
'MP4 file does not contain a mvhd box',
);
throw new FileContentMismatchError('MP4 file does not contain a mvhd box');
}

let { timescale, duration } = parseMvhd(bytes, view, mvhd);
if (timescale === 0) {
throw new FileContentMismatchError(
'MP4 mvhd reports a zero timescale',
);
throw new FileContentMismatchError('MP4 mvhd reports a zero timescale');
}
return { duration: duration / timescale };
}
Expand Down Expand Up @@ -415,9 +411,7 @@ export async function extractM4aDurationFromStream(
break;
}
}
throw new FileContentMismatchError(
'MP4 file does not contain a moov box',
);
throw new FileContentMismatchError('MP4 file does not contain a moov box');
} finally {
await reader.cancel();
}
Expand Down
8 changes: 2 additions & 6 deletions packages/base/markdown-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,14 +272,10 @@ export function markdownAudio(
} catch {
encodedHref = url;
}
let attrSafeHref = encodedHref
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
let attrSafeHref = encodedHref.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
let attrSafeName = (name ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
let ariaLabel = attrSafeName
? ` aria-label="${attrSafeName}"`
: '';
let ariaLabel = attrSafeName ? ` aria-label="${attrSafeName}"` : '';
return `<audio src="${attrSafeHref}" controls preload="metadata"${ariaLabel}></audio>`;
}
2 changes: 1 addition & 1 deletion packages/base/mp3-audio-def.gts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFirstBytes } from '@cardstack/runtime-common';
import FileAudioIcon from '@cardstack/boxel-icons/file-audio';
import AudioDef from './audio-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import type { ByteStream, SerializedFile } from './file-api';
import { extractMp3Duration } from './mp3-meta-extractor';

// ID3v2 tags can be large (embedded artwork). 1 MB covers virtually all
Expand Down
2 changes: 1 addition & 1 deletion packages/base/ogg-audio-def.gts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import FileAudioIcon from '@cardstack/boxel-icons/file-audio';
import AudioDef from './audio-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import type { ByteStream, SerializedFile } from './file-api';
import { extractOggDurationFromStream } from './ogg-meta-extractor';

export class OggDef extends AudioDef {
Expand Down
14 changes: 9 additions & 5 deletions packages/base/ogg-meta-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ const GRANULE_POSITION_OFFSET = 6;
// Codec identification magics on the first page's data packet
const VORBIS_ID_MAGIC = [0x01, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73]; // "\x01vorbis"
const OPUS_ID_MAGIC = [
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
0x4f,
0x70,
0x75,
0x73,
0x48,
0x65,
0x61,
0x64, // "OpusHead"
];

// Opus always outputs at 48 kHz, regardless of the encoder input sample rate
Expand All @@ -34,10 +41,7 @@ function matchBytes(
return true;
}

function readUint64LEAsNumber(
view: DataView,
offset: number,
): number {
function readUint64LEAsNumber(view: DataView, offset: number): number {
let low = view.getUint32(offset, true);
let high = view.getUint32(offset + 4, true);
// Granule positions for any plausible audio length fit in a JS number.
Expand Down
2 changes: 1 addition & 1 deletion packages/base/wav-audio-def.gts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFirstBytes } from '@cardstack/runtime-common';
import FileAudioIcon from '@cardstack/boxel-icons/file-audio';
import AudioDef from './audio-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import type { ByteStream, SerializedFile } from './file-api';
import { extractWavDuration } from './wav-meta-extractor';

// A WAVE file's `fmt ` and `data` chunk headers normally sit within the first
Expand Down
12 changes: 3 additions & 9 deletions packages/base/wav-meta-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ function validateWavSignature(bytes: Uint8Array): void {
);
}
if (!matchTag(bytes, 8, WAVE)) {
throw new FileContentMismatchError(
'File is not a WAVE RIFF container',
);
throw new FileContentMismatchError('File is not a WAVE RIFF container');
}
}

Expand Down Expand Up @@ -81,9 +79,7 @@ export function extractWavDuration(bytes: Uint8Array): { duration: number } {
let advance = CHUNK_HEADER_BYTES + chunkSize + (chunkSize & 1);
if (advance <= CHUNK_HEADER_BYTES) {
// Malformed (size makes us not advance) — bail rather than loop.
throw new FileContentMismatchError(
'WAV file contains a malformed chunk',
);
throw new FileContentMismatchError('WAV file contains a malformed chunk');
}
offset += advance;
}
Expand All @@ -94,9 +90,7 @@ export function extractWavDuration(bytes: Uint8Array): { duration: number } {
);
}
if (dataSize === undefined) {
throw new FileContentMismatchError(
'WAV file is missing a data chunk',
);
throw new FileContentMismatchError('WAV file is missing a data chunk');
}

return { duration: dataSize / byteRate };
Expand Down
Loading
Loading