lovely. got a couple of partial implementations....
This commit is contained in:
parent
89ca4013fd
commit
89f526e9d9
14 changed files with 180 additions and 154 deletions
|
@ -6,22 +6,22 @@ import devtools from 'solid-devtools/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
css: {
|
// css: {
|
||||||
transformer: 'lightningcss',
|
// transformer: 'lightningcss',
|
||||||
lightningcss: {
|
// lightningcss: {
|
||||||
targets: browserslistToTargets(browserslist('>= .25%')),
|
// targets: browserslistToTargets(browserslist('>= .25%')),
|
||||||
include: Features.Nesting | Features.LightDark | Features.Colors,
|
// include: Features.Nesting | Features.LightDark | Features.Colors,
|
||||||
customAtRules: {
|
// customAtRules: {
|
||||||
property: {
|
// property: {
|
||||||
prelude: '<custom-ident>',
|
// prelude: '<custom-ident>',
|
||||||
body: 'style-block',
|
// body: 'style-block',
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
build: {
|
// build: {
|
||||||
cssMinify: 'lightningcss',
|
// cssMinify: 'lightningcss',
|
||||||
},
|
// },
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools({
|
devtools({
|
||||||
autoname: true,
|
autoname: true,
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { json } from "@solidjs/router";
|
|
||||||
import vid from "../../../public/videos/bbb_sunflower_2160p_60fps_normal.mp4";
|
|
||||||
import { APIEvent } from "@solidjs/start/server";
|
|
||||||
|
|
||||||
export const GET = async (event: APIEvent) => {
|
|
||||||
"use server";
|
|
||||||
|
|
||||||
console.log(event);
|
|
||||||
|
|
||||||
// async function* packetGenerator() {
|
|
||||||
// for (let i = 0; i < 10; i++) {
|
|
||||||
// yield `packet ${i}`;
|
|
||||||
// await new Promise((res) => setTimeout(res, 1000));
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// console.log(vid);
|
|
||||||
|
|
||||||
return "OK";
|
|
||||||
};
|
|
|
@ -1,20 +1,21 @@
|
||||||
|
import { toSlug } from "~/utilities";
|
||||||
import type { Entry } from "./types";
|
import type { Entry } from "./types";
|
||||||
|
|
||||||
export const entries = new Map<number, Entry>([
|
export const entries = new Map<string, Entry>([
|
||||||
{ id: 1, title: 'Realtime with Bill Maher', thumbnail: 'https://www.themoviedb.org/t/p/w342/pbpoLLp4kvnYVfnEGiEhagpJuVZ.jpg' },
|
{ id: '1', title: 'Realtime with Bill Maher', thumbnail: 'https://www.themoviedb.org/t/p/w342/pbpoLLp4kvnYVfnEGiEhagpJuVZ.jpg' },
|
||||||
{ id: 2, title: 'Binnelanders', thumbnail: 'https://www.themoviedb.org/t/p/w342/v9nGSRx5lFz6KEgfmgHJMSgaARC.jpg' },
|
{ id: '2', title: 'Binnelanders', thumbnail: 'https://www.themoviedb.org/t/p/w342/v9nGSRx5lFz6KEgfmgHJMSgaARC.jpg' },
|
||||||
{ id: 3, title: 'Family guy', thumbnail: 'https://www.themoviedb.org/t/p/w342/y0HUz4eUNUe3TeEd8fQWYazPaC7.jpg' },
|
{ id: '3', title: 'Family guy', thumbnail: 'https://www.themoviedb.org/t/p/w342/y0HUz4eUNUe3TeEd8fQWYazPaC7.jpg' },
|
||||||
{ id: 4, title: 'The Simpsons', thumbnail: 'https://www.themoviedb.org/t/p/w342/vHqeLzYl3dEAutojCO26g0LIkom.jpg' },
|
{ id: '4', title: 'The Simpsons', thumbnail: 'https://www.themoviedb.org/t/p/w342/vHqeLzYl3dEAutojCO26g0LIkom.jpg' },
|
||||||
{ id: 5, title: 'Breaking Bad', thumbnail: 'https://www.themoviedb.org/t/p/w342/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg' },
|
{ id: '5', title: 'Breaking Bad', thumbnail: 'https://www.themoviedb.org/t/p/w342/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg' },
|
||||||
{ id: 6, title: 'Loki', thumbnail: 'https://www.themoviedb.org/t/p/w342/oJdVHUYrjdS2IqiNztVIP4GPB1p.jpg' },
|
{ id: '6', title: 'Loki', thumbnail: 'https://www.themoviedb.org/t/p/w342/oJdVHUYrjdS2IqiNztVIP4GPB1p.jpg' },
|
||||||
{ id: 7, title: 'Dark desire', thumbnail: 'https://www.themoviedb.org/t/p/w342/uxFNAo2A6ZRcgNASLk02hJUbybn.jpg' },
|
{ id: '7', title: 'Dark desire', thumbnail: 'https://www.themoviedb.org/t/p/w342/uxFNAo2A6ZRcgNASLk02hJUbybn.jpg' },
|
||||||
{ id: 8, title: 'Bridgerton', thumbnail: 'https://www.themoviedb.org/t/p/w342/luoKpgVwi1E5nQsi7W0UuKHu2Rq.jpg' },
|
{ id: '8', title: 'Bridgerton', thumbnail: 'https://www.themoviedb.org/t/p/w342/luoKpgVwi1E5nQsi7W0UuKHu2Rq.jpg' },
|
||||||
{ id: 9, title: 'Naruto', thumbnail: 'https://www.themoviedb.org/t/p/w342/xppeysfvDKVx775MFuH8Z9BlpMk.jpg' },
|
{ id: '9', title: 'Naruto', thumbnail: 'https://www.themoviedb.org/t/p/w342/xppeysfvDKVx775MFuH8Z9BlpMk.jpg' },
|
||||||
{ id: 11, title: 'Teenwolf', thumbnail: 'https://www.themoviedb.org/t/p/w342/fmlMmxSBgPEunHS5gjokIej048g.jpg' },
|
{ id: '11', title: 'Teenwolf', thumbnail: 'https://www.themoviedb.org/t/p/w342/fmlMmxSBgPEunHS5gjokIej048g.jpg' },
|
||||||
{ id: 12, title: 'Record of Ragnarok', thumbnail: 'https://www.themoviedb.org/t/p/w342/kTs2WNZOukpWdNhoRlH94pSJ3xf.jpg' },
|
{ id: '12', title: 'Record of Ragnarok', thumbnail: 'https://www.themoviedb.org/t/p/w342/kTs2WNZOukpWdNhoRlH94pSJ3xf.jpg' },
|
||||||
{ id: 13, title: 'The Mandalorian', thumbnail: 'https://www.themoviedb.org/t/p/w342/eU1i6eHXlzMOlEq0ku1Rzq7Y4wA.jpg' },
|
{ id: '13', title: 'The Mandalorian', thumbnail: 'https://www.themoviedb.org/t/p/w342/eU1i6eHXlzMOlEq0ku1Rzq7Y4wA.jpg' },
|
||||||
{
|
{
|
||||||
id: 14,
|
id: '14',
|
||||||
title: 'Wednesday',
|
title: 'Wednesday',
|
||||||
thumbnail: 'https://www.themoviedb.org/t/p/w342/9PFonBhy4cQy7Jz20NpMygczOkv.jpg',
|
thumbnail: 'https://www.themoviedb.org/t/p/w342/9PFonBhy4cQy7Jz20NpMygczOkv.jpg',
|
||||||
image: 'https://www.themoviedb.org/t/p/original/iHSwvRVsRyxpX7FE7GbviaDvgGZ.jpg',
|
image: 'https://www.themoviedb.org/t/p/original/iHSwvRVsRyxpX7FE7GbviaDvgGZ.jpg',
|
||||||
|
@ -27,4 +28,6 @@ export const entries = new Map<number, Entry>([
|
||||||
].map((entry) => [entry.id, entry]));
|
].map((entry) => [entry.id, entry]));
|
||||||
|
|
||||||
|
|
||||||
export const emptyEntry = Object.freeze<Entry>({ id: 0, title: '' });
|
export const emptyEntry = Object.freeze<Entry>({ id: '0', title: '' });
|
||||||
|
|
||||||
|
export const createSlug = (entry: Entry) => toSlug(`${entry.title}-${entry.id}`);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export type * from './types';
|
export type * from './types';
|
||||||
|
|
||||||
export { emptyEntry } from './data';
|
export { emptyEntry, createSlug } from './data';
|
||||||
export * from './service';
|
export * from './service';
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Category, Entry } from './types';
|
import type { Category, Entry } from './types';
|
||||||
import { cache } from "@solidjs/router";
|
import { query } from "@solidjs/router";
|
||||||
import { entries } from './data';
|
import { entries } from './data';
|
||||||
|
|
||||||
export const listCategories = cache(async (): Promise<Category[]> => {
|
export const listCategories = query(async (): Promise<Category[]> => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
@ -25,7 +25,7 @@ export const listCategories = cache(async (): Promise<Category[]> => {
|
||||||
];
|
];
|
||||||
}, 'series.categories.list');
|
}, 'series.categories.list');
|
||||||
|
|
||||||
export const getEntry = cache(async (id: Entry['id']): Promise<Entry | undefined> => {
|
export const getEntry = query(async (id: Entry['id']): Promise<Entry | undefined> => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
return entries.get(id);
|
return entries.get(id);
|
||||||
|
|
|
@ -5,7 +5,7 @@ export interface Category {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
id: number;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import type { Entry } from "../content";
|
import type { Entry } from "../content";
|
||||||
import { Component } from "solid-js";
|
import { createSlug } from "../content";
|
||||||
|
import { Component, createMemo } from "solid-js";
|
||||||
import css from "./list-item.module.css";
|
import css from "./list-item.module.css";
|
||||||
|
|
||||||
export const ListItem: Component<{ entry: Entry }> = (props) => {
|
export const ListItem: Component<{ entry: Entry }> = (props) => {
|
||||||
|
const slug = createMemo(() => createSlug(props.entry));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={css.listItem}>
|
<div class={css.listItem}>
|
||||||
<img src={props.entry.thumbnail} />
|
<img src={props.entry.thumbnail} />
|
||||||
|
@ -10,7 +13,7 @@ export const ListItem: Component<{ entry: Entry }> = (props) => {
|
||||||
<main>
|
<main>
|
||||||
<strong>{props.entry.title}</strong>
|
<strong>{props.entry.title}</strong>
|
||||||
|
|
||||||
<a href={`/watch/${props.entry.id}`}>Watch now</a>
|
<a href={`/watch/${slug()}`}>Watch now</a>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -19,16 +19,6 @@ type OverviewProps = {
|
||||||
export const Overview: Component<OverviewProps> = (props) => {
|
export const Overview: Component<OverviewProps> = (props) => {
|
||||||
const [container, setContainer] = createSignal<HTMLElement>();
|
const [container, setContainer] = createSignal<HTMLElement>();
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
new MutationObserver(() => {
|
|
||||||
container()
|
|
||||||
?.querySelector<HTMLElement>(
|
|
||||||
`.${css.list} > ul > div:nth-child(4) > main > a`,
|
|
||||||
)
|
|
||||||
?.focus({ preventScroll: true });
|
|
||||||
}).observe(document.body, { subtree: true, childList: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setContainer} class={css.container}>
|
<div ref={setContainer} class={css.container}>
|
||||||
<Hero class={css.hero} entry={props.highlight}></Hero>
|
<Hero class={css.hero} entry={props.highlight}></Hero>
|
||||||
|
|
|
@ -1,86 +1,44 @@
|
||||||
import {
|
import {
|
||||||
createEventListenerMap,
|
createEventListenerMap,
|
||||||
makeEventListener,
|
createEventSignal,
|
||||||
makeEventListenerStack,
|
|
||||||
} from "@solid-primitives/event-listener";
|
} from "@solid-primitives/event-listener";
|
||||||
import { createAsync, json, query } from "@solidjs/router";
|
import { query } from "@solidjs/router";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
|
||||||
createSignal,
|
createSignal,
|
||||||
|
For,
|
||||||
onMount,
|
onMount,
|
||||||
|
untrack,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { isServer } from "solid-js/web";
|
|
||||||
|
|
||||||
const streamKaas = query(async () => {
|
|
||||||
"use server";
|
|
||||||
|
|
||||||
const stream = new WritableStream();
|
|
||||||
|
|
||||||
async function* packetGenerator() {
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
yield `packet ${i}`;
|
|
||||||
await new Promise((res) => setTimeout(res, 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const writer = stream.getWriter();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await writer.ready;
|
|
||||||
|
|
||||||
for await (const packet of packetGenerator()) {
|
|
||||||
writer.write(packet);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
writer.releaseLock();
|
|
||||||
}
|
|
||||||
// response.body.wr
|
|
||||||
})();
|
|
||||||
|
|
||||||
return new Response(packetGenerator());
|
|
||||||
}, "kaas");
|
|
||||||
|
|
||||||
interface PlayerProps {
|
interface PlayerProps {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Player: Component<PlayerProps> = (props) => {
|
export const Player: Component<PlayerProps> = (props) => {
|
||||||
const [video, setVideo] = createSignal<HTMLVideoElement>();
|
const [video, setVideo] = createSignal<HTMLVideoElement>(undefined as unknown as HTMLVideoElement);
|
||||||
const stream = createAsync(async () => {
|
|
||||||
const res = await streamKaas();
|
|
||||||
|
|
||||||
console.log(res);
|
const onDurationChange = createEventSignal(video, 'durationchange');
|
||||||
|
const onTimeUpdate = createEventSignal(video, 'timeupdate');
|
||||||
|
|
||||||
return "";
|
const duration = createMemo(() => {
|
||||||
|
onDurationChange();
|
||||||
|
onTimeUpdate();
|
||||||
|
|
||||||
|
return video()?.duration ?? 100;
|
||||||
});
|
});
|
||||||
// const [kaas, { refetch }] = createResource(async () => {
|
|
||||||
// if (isServer) {
|
|
||||||
// return "";
|
|
||||||
// }
|
|
||||||
// const response = await fetch("http://localhost:3000/api/stream/video", {
|
|
||||||
// method: "GET",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// console.log(response.body);
|
const currentTime = createMemo(() => {
|
||||||
|
onTimeUpdate();
|
||||||
|
|
||||||
// for await (const packet of response.body) {
|
return video()?.currentTime ?? 0;
|
||||||
// console.log(new TextDecoder().decode(packet));
|
});
|
||||||
// }
|
|
||||||
|
|
||||||
// return "";
|
createEffect(() => {
|
||||||
// });
|
console.log(duration(), currentTime());
|
||||||
|
});
|
||||||
// onMount(() => refetch());
|
|
||||||
|
|
||||||
// createEffect(() => console.log(stream()));
|
|
||||||
|
|
||||||
// const progress = createMemo(() => {
|
|
||||||
// const
|
|
||||||
// });
|
|
||||||
|
|
||||||
createEventListenerMap(() => video()!, {
|
createEventListenerMap(() => video()!, {
|
||||||
durationchange(e) {
|
durationchange(e) {
|
||||||
|
@ -102,12 +60,15 @@ export const Player: Component<PlayerProps> = (props) => {
|
||||||
console.log("seeking", e);
|
console.log("seeking", e);
|
||||||
},
|
},
|
||||||
stalled(e) {
|
stalled(e) {
|
||||||
console.log("stalled", e);
|
console.log("stalled (meaning downloading data failed)", e, video()!.error);
|
||||||
},
|
},
|
||||||
|
|
||||||
play(e) {
|
play(e) {
|
||||||
console.log("play", e);
|
console.log("play", e);
|
||||||
},
|
},
|
||||||
|
canplay(e) {
|
||||||
|
console.log("canplay", e);
|
||||||
|
},
|
||||||
playing(e) {
|
playing(e) {
|
||||||
console.log("playing", e);
|
console.log("playing", e);
|
||||||
},
|
},
|
||||||
|
@ -130,9 +91,9 @@ export const Player: Component<PlayerProps> = (props) => {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
},
|
},
|
||||||
|
|
||||||
timeupdate(e) {
|
// timeupdate(e) {
|
||||||
console.log("timeupdate", e);
|
// console.log("timeupdate", e);
|
||||||
},
|
// },
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
@ -149,13 +110,14 @@ export const Player: Component<PlayerProps> = (props) => {
|
||||||
<>
|
<>
|
||||||
<h1>{props.id}</h1>
|
<h1>{props.id}</h1>
|
||||||
|
|
||||||
<video ref={setVideo} muted preload="metadata">
|
<video ref={setVideo} width="1280px" height="720px" muted src="/api/stream/video" />
|
||||||
<source src="/videos/bbb_sunflower_2160p_60fps_normal.mp4" />
|
|
||||||
</video>
|
|
||||||
|
|
||||||
<button onclick={toggle}>play/pause</button>
|
<button onclick={toggle}>play/pause</button>
|
||||||
|
|
||||||
<progress />
|
<span style={{ '--duration': duration(), '--current-time': currentTime() }} />
|
||||||
|
<span data-duration={duration()} data-current-time={currentTime()} />
|
||||||
|
|
||||||
|
<progress max={duration()} value={currentTime()} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { Params, useParams } from "@solidjs/router";
|
|
||||||
import { createEffect } from "solid-js";
|
|
||||||
import { Player } from "~/features/player";
|
|
||||||
|
|
||||||
interface ItemParams extends Params {
|
|
||||||
item: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Item() {
|
|
||||||
const params = useParams<ItemParams>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Player id={params.item} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
41
src/routes/(shell)/watch/[slug].tsx
Normal file
41
src/routes/(shell)/watch/[slug].tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { json, Params, query, redirect, RouteDefinition, useParams } from "@solidjs/router";
|
||||||
|
import { createSlug, getEntry } from "~/features/content";
|
||||||
|
import { Player } from "~/features/player";
|
||||||
|
import { toSlug } from "~/utilities";
|
||||||
|
|
||||||
|
const healUrl = query(async (slug: string) => {
|
||||||
|
const entry = await getEntry(slug.slice(slug.lastIndexOf('-') + 1));
|
||||||
|
|
||||||
|
if (entry === undefined) {
|
||||||
|
return json(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualSlug = createSlug(entry);
|
||||||
|
|
||||||
|
if (slug === actualSlug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect(`/watch/${actualSlug}`);
|
||||||
|
}, 'watch.heal');
|
||||||
|
|
||||||
|
interface ItemParams extends Params {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const route = {
|
||||||
|
async preload({ params }) {
|
||||||
|
await healUrl(params.slug);
|
||||||
|
},
|
||||||
|
} satisfies RouteDefinition;
|
||||||
|
|
||||||
|
export default function Item() {
|
||||||
|
const params = useParams<ItemParams>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Player id={params.id} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
BIN
src/routes/api/stream/SampleVideo_1280x720_10mb.mp4
Normal file
BIN
src/routes/api/stream/SampleVideo_1280x720_10mb.mp4
Normal file
Binary file not shown.
49
src/routes/api/stream/video.ts
Normal file
49
src/routes/api/stream/video.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { APIEvent } from "@solidjs/start/server";
|
||||||
|
// import * as Bun from 'bun';
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 1 * 1e6; // 1MB
|
||||||
|
|
||||||
|
export const GET = async ({ request, ...event }: APIEvent) => {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
|
||||||
|
if (range === null) {
|
||||||
|
return new Response('Requires Range header', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const video = Bun.file(import.meta.dirname + '/SampleVideo_1280x720_10mb.mp4');
|
||||||
|
|
||||||
|
if ((await video.exists()) !== true) {
|
||||||
|
return new Response('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoSize = video.size;
|
||||||
|
|
||||||
|
const start = Number.parseInt(range.replace(/\D/g, ''));
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, videoSize - 1);
|
||||||
|
const contentLength = end - start + 1;
|
||||||
|
|
||||||
|
const view = video.slice(start, end);
|
||||||
|
|
||||||
|
console.log(start, end, videoSize, video, view);
|
||||||
|
|
||||||
|
return new Response(video.stream());
|
||||||
|
|
||||||
|
// return new Response(stream, {
|
||||||
|
// status: 206,
|
||||||
|
// headers: {
|
||||||
|
// 'Accept-Ranges': 'bytes',
|
||||||
|
// 'Content-Range': `bytes ${start}-${end}/${videoSize}`,
|
||||||
|
// 'Content-Length': `${contentLength}`,
|
||||||
|
// 'Content-type': 'video/mp4',
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
15
src/utilities.ts
Normal file
15
src/utilities.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export const splitAt = (subject: string, index: number): readonly [string, string] => {
|
||||||
|
if (index < 0) {
|
||||||
|
return [subject, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > subject.length) {
|
||||||
|
return [subject, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [subject.slice(0, index), subject.slice(index + 1)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toSlug = (subject: string) => {
|
||||||
|
return subject.toLowerCase().replaceAll(' ', '-');
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue