frontend: Sonar reliability fixes (promises in conditions, async executors, identical branches, reduce, regex) (#2821)

Clears 51 open TypeScript issues. Typecheck and build are left to CI.

**typescript:S6544 (17)**
- A cached promise checked by truthiness (`if (existing)`, `if
(cached)`, `if (ammoLoadPromise)`…) is checked as `!== undefined` / `!==
null` against its map or field type. Same branches, since a Promise is
always truthy.
- `scene.ts` `load_glb_wrapped_no_cors` / `load_splat_wrapped_no_cors`:
the fetch moves out of the `async` Promise executor. The methods were
already `async`, so a failed fetch now `throw`s (rejects the same way),
and only the callback APIs (`GLTFLoader.parse`, `SplatMesh` `onLoad`)
stay inside `new Promise`.
- `SceneManagerAPI` declares the promises its implementation returns
(`create`/`retrieve` → `Promise<THREE.Object3D | undefined>`,
`update`/`delete` → `Promise<void>`). Nothing calls through the
interface.
- `convertFbxToGlb.worker.ts`: `onmessage` calls `void convert(event)`.

**typescript:S3923 / S1764 / S3516 / S905 (12)**: identical branches
collapsed (`variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]"`,
`ScissorIcon : ScissorIcon`, `index % 2 === 0 ? point + offset : point +
offset`, PromptBox3D's two `setIsEnqueueing(false); return;` arms,
`{DEFAULT_GRID_CONFIG ? null : null}` and its now-unused import), `x &&
x`, the `this.gridHelper;` statement, and `CurrentUserBadge`, which
always rendered `null` (its markup was commented out).

**typescript:S6959 (5)**: each `reduce` starts from the first element.
Every one is behind an emptiness check, and comparing the first element
with itself keeps it.

**typescript:S2871 (4)**: `sort((a, b) => a.localeCompare(b))` for the
order-independent cache keys and the board and font name lists.

**typescript:S8786 (8)**
- Markdown link text is `[^\][]+`. `@image(url, size)` drops the `\s*`
before the size (it's `.trim()`ed already). ATX headings `trimStart()`
the text instead of `\s*`. Slugs trim one `-` (runs were already
collapsed to one).
- `color.ts` `stripCssNoise`: `!important` is replaced by a space, then
trailing `;` are trimmed in a loop.
- Data-URL mime: `/:([^;]*);/`.
- `ControlPanelSceneObject` number check: `/^-?\d*(\.\d*)?$/`. The old
`.` was unescaped and matched any character, so `12a3` passed as a
number.

**Others**: `window.open(…, "noopener,noreferrer")` (S5148).
`AlertTitle` renders `children` explicitly (S6850). The folder name hash
wraps with `Math.imul(…, 1)` instead of `| 0`, the same int32 result
(S7767). `autoFocus` is removed from the two rename inputs, which their
refs already focus and select (S9379).

**Not here**: S7059 in `editor.ts` (`ensureAmmoLoaded()` in the
constructor) is on a line #2807 changes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability when loading 3D scene assets and completing
drag-and-drop operations.
* Corrected numeric input validation, scene import handling, and video
duration tie handling.
* Improved media, audio, and video cache handling to avoid unnecessary
retries or missed waits.
* Refined markdown parsing for links, headings, image directives, and
heading anchors.
  * Discord invites now open with additional browser protections.
  * Improved CSS color cleanup and handling of pasted scene elements.
* **Improvements**
* Made ordering of tags, fonts, and prompt-related items more
consistent.
  * Improved video editor timeline selections.
* Removed the non-functional account badge from the video editor header.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

**typescript:S1848 (2)**: `new SplatMesh({ …, onLoad })` created an
instance only for its callback. `scene.ts` now awaits `mesh.initialized`
(Spark's promise, which resolves after load), so a failed load rejects
instead of leaving the promise pending. `viewer-3d.tsx` runs the same
body in `initialized.then`, with a `.catch` that logs.

Kept as is: `TabState.setActiveTab` still resolves `true` (S3516 stays
open), because `TabState.spec.ts` and the tab-change benchmark read its
result.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-10 20:22:36 -05:00
1 parent 71adfd4701
commit f9f4c99e4f
34 files changed
+97 -129

No files matched your search

@@ -944,7 +944,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"mx-auto grid h-full w-full grid-cols-[1fr_300px] gap-4 overflow-hidden pb-10",
variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]",
"max-w-[1600px]",
)}
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
>
@@ -1111,7 +1111,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex items-center justify-between gap-2.5",
hasResults ? "mt-3" : "mt-3",
"mt-3",
)}
>
<ClassyModelSelector
@@ -135,7 +135,7 @@ export const ButtonDropdown = ({
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
{option.description && option.description}
{option.description}
</div>
{showSelected && (
<>
@@ -64,7 +64,6 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
if (e.key === "Enter") submit();
}}
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
@@ -403,7 +403,7 @@ const SIDEBAR_TAG_LIMIT = 5;
const hashString = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
for (let i = 0; i < s.length; i++) h = Math.imul(Math.imul(h, 31) + s.charCodeAt(i), 1); // int32 wrap
return h;
};
@@ -2710,7 +2710,6 @@ export const GalleryModal = React.memo(
}}
onBlur={handleConfirmRename}
className="text-lg font-semibold bg-transparent px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary"
autoFocus
/>
) : (
<h2
@@ -87,7 +87,7 @@ export function useMediaPromptTokens(
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list, order-independent.
const key = [...new Set(cleaned)].sort().join(",");
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
@@ -60,7 +60,7 @@ export function usePrompts(
}
// Stable cache key for the effect's dep list. Sorting keeps it stable when
// a poll returns the same set in a different order.
const key = [...new Set(cleaned)].sort().join(",");
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
@@ -104,7 +104,7 @@ export function LoginModal({
};
const handleDiscordJoin = () => {
window.open("https://discord.gg/75svZP2Vje", "_blank");
window.open("https://discord.gg/75svZP2Vje", "_blank", "noopener,noreferrer");
setShowDiscord(false);
setShowSuccess(true);
};
@@ -33,7 +33,7 @@ export const collectTags = (board: Board): string[] => {
board.itemOrder.forEach((id) => {
board.items[id]?.tags.forEach((t) => seen.add(t));
});
return Array.from(seen).sort();
return Array.from(seen).sort((a, b) => a.localeCompare(b));
};
const itemMatchesText = (it: BoardItem, q: string): boolean => {
@@ -1011,9 +1011,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
const pastedLineNode: LineNode = {
...lineNode,
id: newId,
points: lineNode.points.map((point, index) =>
index % 2 === 0 ? point + offset : point + offset,
),
points: lineNode.points.map((point) => point + offset),
x: lineNode.x !== undefined ? lineNode.x + offset : undefined,
y: lineNode.y !== undefined ? lineNode.y + offset : undefined,
};
@@ -1131,7 +1129,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
const base64ToFile = (base64: string, filename: string): File => {
const arr = base64.split(",");
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
const mime = arr[0].match(/:([^;]*);/)?.[1] || "image/png";
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
@@ -273,7 +273,7 @@ class DndAsset {
return;
}
const inspection = this.clipNodeNamesCache.get(item.media_id);
if (!inspection) {
if (inspection === undefined) {
// No inspection in flight (shouldn't happen — pointer-down starts
// one): add unvalidated, matching the ungated click-to-add path.
this.completeAnimationDrop(editor, item, target, undefined);
@@ -245,7 +245,7 @@ export const ControlPanelSceneObject = () => {
if (value === "" || value === "-" || value === ".") {
return true;
}
return !/^-?[0-9]*(.[0-9]*)?$/.test(value);
return !/^-?\d*(\.\d*)?$/.test(value);
});
const handlePositionChange = (xyz: Record<string, string>) => {
@@ -8,7 +8,7 @@
let ammoLoadPromise: Promise<void> | null = null;
export const ensureAmmoLoaded = (): Promise<void> => {
if (ammoLoadPromise) return ammoLoadPromise;
if (ammoLoadPromise !== null) return ammoLoadPromise;
ammoLoadPromise = new Promise<void>((resolve, reject) => {
if (typeof document === "undefined") {
@@ -91,7 +91,6 @@ class Scene {
) {
this.version = version;
this.name = name;
this.gridHelper;
this.scene = new THREE.Scene();
this.hot_items = [];
@@ -1026,21 +1025,20 @@ class Scene {
onComplete: () => void,
signal?: AbortSignal,
): Promise<GLTF> {
return new Promise<GLTF>(async (resolve, reject) => {
let buffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load GLB from Tauri error:", error);
}
reject(error);
return;
let buffer: ArrayBuffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load GLB from Tauri error:", error);
}
throw error;
}
return new Promise<GLTF>((resolve, reject) => {
const glbLoader = new GLTFLoader();
glbLoader.parse(
buffer,
@@ -1068,30 +1066,23 @@ class Scene {
onComplete: () => void,
signal?: AbortSignal,
): Promise<SplatMesh> {
return new Promise(async (resolve, reject) => {
let buffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load Splat from Tauri error:", error);
}
reject(error);
return;
let buffer: ArrayBuffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load Splat from Tauri error:", error);
}
new SplatMesh({
fileBytes: buffer,
onLoad: (mesh) => {
resolve(mesh);
onComplete();
},
});
});
throw error;
}
const mesh = new SplatMesh({ fileBytes: buffer });
await mesh.initialized;
onComplete();
return mesh;
}
/**
@@ -32,15 +32,19 @@ export type SceneObject = {
};
export interface SceneManagerAPI {
create(media_token: string, name: string, position: THREE.Vector3): void;
retrieve(object_uuid: string): void;
delete(object_uuid: string): void;
create(
media_token: string,
name: string,
position: THREE.Vector3,
): Promise<THREE.Object3D | undefined>;
retrieve(object_uuid: string): Promise<THREE.Object3D | undefined>;
delete(object_uuid: string): Promise<void>;
update(
object_uuid: string,
position: THREE.Vector3,
rotation: THREE.Euler,
scale: THREE.Vector3,
): void;
): Promise<void>;
selected(): void;
render_outliner(timeline_characters: { [key: string]: ClipGroup }): void;
select_object(id: string): void;
@@ -22,7 +22,6 @@ import { CameraSettingsModal } from "@storyteller/ui-camera-settings-modal";
import { twMerge } from "tailwind-merge";
import { Modal } from "@storyteller/ui-modal";
import {
CommandSuccessStatus,
GenerateImage,
GenerateImageRequest,
} from "@storyteller/tauri-api";
@@ -509,13 +508,8 @@ export const PromptBox3D = ({
console.log("generateResponse", generateResponse);
if (generateResponse.status === CommandSuccessStatus.Success) {
setIsEnqueueing(false);
return;
} else {
setIsEnqueueing(false);
return;
}
setIsEnqueueing(false);
return;
}
try {
@@ -23,7 +23,7 @@ const scope = self as unknown as {
postMessage: (message: ConvertResponse, transfer?: Transferable[]) => void;
};
scope.onmessage = async (event) => {
const convert = async (event: MessageEvent<ConvertRequest>) => {
const { id, buffer } = event.data;
try {
const group = new FBXLoader().parse(buffer, "");
@@ -46,3 +46,7 @@ scope.onmessage = async (event) => {
scope.postMessage({ id, ok: false, error: String(error) });
}
};
scope.onmessage = (event) => {
void convert(event);
};
@@ -100,8 +100,10 @@ export class PasteCommand extends Command {
placementResult.trackIndex - updatedTracks.overlay.length - 1
];
if (targetTrack?.id === updatedTracks.main.id) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
const earliestElement = elementsToAdd.reduce(
(earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
elementsToAdd[0],
);
const adjustedEarliestStartTime = enforceMainTrackStart({
tracks: updatedTracks,
@@ -19,7 +19,6 @@ import { RenameProjectDialog } from "../../project/components/rename-project-dia
import { DeleteProjectDialog } from "../../project/components/delete-project-dialog";
import { cn } from "../../utils/ui";
import { Button } from "../ui/button";
import { useAuthUser } from "./use-auth-user";
export interface EditorHeaderProps {
// Optional host chrome rendered to the right of the export button (e.g.
@@ -39,7 +38,6 @@ export function EditorHeader({ endSlot, exitTo = "/projects" }: EditorHeaderProp
<EditableProjectName />
</div>
<nav className="flex items-center gap-2">
<CurrentUserBadge />
<ExportButton />
{endSlot}
</nav>
@@ -47,21 +45,6 @@ export function EditorHeader({ endSlot, exitTo = "/projects" }: EditorHeaderProp
);
}
function CurrentUserBadge() {
const { authUser } = useEditorAdapters();
const user = useAuthUser(authUser);
if (!user) return null;
return (
// <span
// className="text-muted-foreground hidden truncate text-xs sm:inline-block"
// title={user.displayName}
// >
// {user.displayName}
// </span>
null
);
}
function ProjectDropdown({ exitTo }: { exitTo: string }) {
const [openDialog, setOpenDialog] = useState<
"delete" | "rename" | "shortcuts" | null
@@ -44,7 +44,7 @@ Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
>(({ className, children, ...props }, ref) => (
<h5
ref={ref}
className={cn(
@@ -52,7 +52,9 @@ const AlertTitle = React.forwardRef<
className,
)}
{...props}
/>
>
{children}
</h5>
));
AlertTitle.displayName = "AlertTitle";
@@ -539,7 +539,7 @@ export class AudioManager {
}): Promise<AudioBuffer | null> {
const cacheKey = this.buildPreparedClipCacheKey({ clip });
const existing = this.preparedClipBuffers.get(cacheKey);
if (existing) {
if (existing !== undefined) {
return existing;
}
@@ -574,7 +574,7 @@ export class AudioManager {
clip: AudioClipSource;
}): Promise<AudioBuffer | null> {
const existing = this.decodedBuffers.get(clip.sourceKey);
if (existing) {
if (existing !== undefined) {
return existing;
}
@@ -26,7 +26,7 @@ export function clearFontAtlasCache(): void {
export function loadFontAtlas(): Promise<FontAtlas | null> {
if (cachedAtlas) return Promise.resolve(cachedAtlas);
if (atlasFetchPromise) return atlasFetchPromise;
if (atlasFetchPromise !== null) return atlasFetchPromise;
atlasFetchPromise = fetch(FONT_ATLAS_PATH)
.then(async (response) => {
@@ -46,7 +46,9 @@ export function useFontAtlas({ open }: { open: boolean }) {
const fontNames = useMemo(() => {
if (!atlas) return [];
return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort();
return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort((a, b) =>
a.localeCompare(b),
);
}, [atlas]);
return { atlas, status, fontNames, retry };
@@ -7,7 +7,6 @@ import { HugeiconsIcon } from "@hugeicons/react";
import {
GRID_MIN,
GRID_MAX,
DEFAULT_GRID_CONFIG,
} from "../grid";
import { usePreviewStore } from "../../preview/preview-store";
import { clampRound } from "../../utils/math";
@@ -113,9 +112,6 @@ function GridGuideOptions() {
}}
/>
</label>
{/* Acknowledge DEFAULT_GRID_CONFIG so the reset import stays parked
for when NumberField (with its onReset affordance) is ported. */}
{DEFAULT_GRID_CONFIG ? null : null}
</div>
);
}
@@ -75,7 +75,7 @@ function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge
return shouldPreferCandidate && !shouldPreferBestCandidate
? candidate
: bestCandidate;
});
}, candidates[0]);
}
export function snapPosition({
@@ -124,8 +124,10 @@ export function snapPosition({
if (snapCandidatesWithinThreshold.length === 0) {
return null;
}
return snapCandidatesWithinThreshold.reduce((closest, current) =>
current.distance < closest.distance ? current : closest,
return snapCandidatesWithinThreshold.reduce(
(closest, current) =>
current.distance < closest.distance ? current : closest,
snapCandidatesWithinThreshold[0],
);
}
@@ -35,7 +35,7 @@ export function loadImageSource({
const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
const cached = imageSourceCache.get(cacheKey);
if (cached) return cached;
if (cached !== undefined) return cached;
const promise = (async (): Promise<CachedImageSource> => {
const image = new Image();
@@ -25,7 +25,7 @@ export function loadStickerSource({
stickerId: string;
}): Promise<CachedStickerSource> {
const cached = stickerSourceCache.get(stickerId);
if (cached) return cached;
if (cached !== undefined) return cached;
const promise = (async (): Promise<CachedStickerSource> => {
const url = resolveStickerId({
@@ -170,7 +170,7 @@ export class VideoCache {
try {
while (true) {
if (sinkData.prefetching && sinkData.prefetchPromise) {
if (sinkData.prefetching && sinkData.prefetchPromise !== null) {
await sinkData.prefetchPromise;
}
@@ -215,7 +215,7 @@ export class VideoCache {
time: number;
}): Promise<WrappedCanvas | null> {
try {
if (sinkData.prefetching && sinkData.prefetchPromise) {
if (sinkData.prefetching && sinkData.prefetchPromise !== null) {
await sinkData.prefetchPromise;
}
@@ -27,7 +27,7 @@ export class WaveformCache {
audioUrl,
}: GetSourceWaveformSummaryArgs): Promise<SourceWaveformSummary> {
const existing = this.summaries.get(sourceKey);
if (existing) {
if (existing !== undefined) {
return existing;
}
@@ -47,7 +47,6 @@ import { mediaSupportsAudio } from "../../media/media-utils";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "../audio-separation";
import { buildWaveformGainSamples, isElementMuted } from "../audio-state";
import { getTimelinePixelsPerSecond } from "../pixel-utils";
@@ -346,8 +345,6 @@ export function TimelineElement({
element.type === "video"
? getSourceAudioActionLabel({ element })
: "Extract audio";
const isElementSourceAudioSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
const hasKeyframes = elementKeyframes.length > 0;
const expansionHeight = getExpansionHeight({ rows: expandedRows });
const baseTrackHeight = getTrackHeight({ type: track.type });
@@ -450,11 +447,7 @@ export function TimelineElement({
{canToggleCurrentSourceAudio && (
<ContextMenuItem
icon={
<HugeiconsIcon
icon={
isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon
}
/>
<HugeiconsIcon icon={ScissorIcon} />
}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
@@ -26,7 +26,7 @@ export function getEarliestMainTrackElement({
return element.startTime < earliestElement.startTime
? element
: earliestElement;
});
}, elements[0]);
}
export function enforceMainTrackStart({
@@ -54,10 +54,8 @@ export function appendAlpha({
function stripCssNoise({ text }: { text: string }): string {
let cleaned = text.trim();
cleaned = cleaned
.replace(/\s*!important\s*/gi, "")
.replace(/;+\s*$/, "")
.trim();
cleaned = cleaned.replace(/!important/gi, " ").trim();
while (cleaned.endsWith(";")) cleaned = cleaned.slice(0, -1).trimEnd();
const colonIndex = cleaned.indexOf(":");
const parenIndex = cleaned.indexOf("(");
@@ -408,9 +408,8 @@ export function Viewer3D({
if (SPLAT_EXTENSIONS.includes(urlExtension)) {
console.log(`[Viewer3D] splat format detected (.${urlExtension})`);
new SplatMesh({
url: modelUrl,
onLoad: (mesh) => {
void new SplatMesh({ url: modelUrl }).initialized
.then((mesh) => {
if (cancelled) {
mesh.dispose();
return;
@@ -434,8 +433,10 @@ export function Viewer3D({
}
console.log("Splat loaded");
},
});
})
.catch((error) =>
console.error("[Viewer3D] splat load failed:", error),
);
} else if (!GLTF_EXTENSIONS.includes(urlExtension)) {
// Known non-glTF model format with no loader wired here (obj, fbx,
// pmx, …). Show the fallback shape rather than spamming a parse error.
@@ -143,7 +143,7 @@ export const markdownToHtml = (
return renderImage(safeUrl, alt, size);
});
// links: [text](url)
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
out = out.replace(/\[([^\][]+)\]\(([^)]+)\)/g, (_m, label, url) => {
const safeLabel = String(label || "").replace(/"/g, "&quot;");
const safeUrl = normalizeLink(String(url || ""));
return `<a href="${safeUrl}" class="underline hover:opacity-90">${safeLabel}</a>`;
@@ -220,7 +220,7 @@ export const markdownToHtml = (
// Image/GIF embed: @image(url, size) or @gif(url, size)
const imageMatch = ltrim.match(
/^@(image|gif)\(([^,)]+)(?:,\s*([^)]+))?\)\s*$/,
/^@(image|gif)\(([^,)]+)(?:,([^)]+))?\)\s*$/,
);
if (imageMatch) {
flushParagraph();
@@ -232,12 +232,12 @@ export const markdownToHtml = (
}
// ATX headings: support #..###### with optional space after hashes
const heading = ltrim.match(/^(#{1,6})\s*(.*)$/);
const heading = ltrim.match(/^(#{1,6})(.*)$/);
if (heading) {
flushParagraph();
closeListIfOpen();
const level = Math.min(heading[1].length, 6);
const text = renderInline(heading[2] || "");
const text = renderInline((heading[2] || "").trimStart());
htmlParts.push(`<h${level}>${text}</h${level}>`);
continue;
}
@@ -318,7 +318,7 @@ export const slugify = (s: string): string =>
.replace(/[_\s]+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "");
.replace(/^-|-$/g, "");
export const pathToFilename = (path: string): string => {
const parts = path.split("/");
@@ -70,7 +70,7 @@ export function resolveVideoDuration(
(distance === bestDistance && option > best)
? option
: best;
});
}, model.durationOptions[0]);
}
return model.defaultDuration ?? null;
}