diff --git a/app/browser.tsx b/app/browser.tsx
index 05775c9e..9501648f 100644
--- a/app/browser.tsx
+++ b/app/browser.tsx
@@ -15,7 +15,7 @@ import Env from 'quilt:module/env';
import type {AppContext} from '~/shared/context.ts';
import {SearchParam} from '~/global/auth.ts';
-import {createClipsManager} from '~/shared/clips.ts';
+import {ClipsManager} from '~/shared/clips.ts';
import App from './App.tsx';
import {EXTENSION_POINTS} from './clips.ts';
@@ -79,10 +79,7 @@ const context = {
graphql: {cache: new GraphQLCache(), fetch: fetchGraphQL},
performance,
clipsManager: user
- ? createClipsManager(
- {user, graphql: fetchGraphQL, router},
- EXTENSION_POINTS,
- )
+ ? new ClipsManager({user, graphql: fetchGraphQL, router}, EXTENSION_POINTS)
: undefined,
} satisfies AppContext;
diff --git a/app/features/Developer/Console/Console.tsx b/app/features/Developer/Console/Console.tsx
index 1c08ca7e..325caa41 100644
--- a/app/features/Developer/Console/Console.tsx
+++ b/app/features/Developer/Console/Console.tsx
@@ -16,7 +16,7 @@ import {Page} from '~/shared/page.ts';
import {
useClipsManager,
useLocalDevelopmentServerQuery,
- type ClipsLocalDevelopmentServer,
+ type ClipsLocalDevelopmentServerType,
} from '~/shared/clips.ts';
import developerConsoleQuery, {
@@ -37,7 +37,7 @@ export default function Console() {
);
}
-function ConnectedConsole({server}: {server: ClipsLocalDevelopmentServer}) {
+function ConnectedConsole({server}: {server: ClipsLocalDevelopmentServerType}) {
const {data, loading} = useLocalDevelopmentServerQuery(developerConsoleQuery);
usePerformanceNavigation({state: loading ? 'loading' : 'complete'});
@@ -119,7 +119,7 @@ function ExtensionBuildResult({
}
}
-function ConnectToConsole({server}: {server: ClipsLocalDevelopmentServer}) {
+function ConnectToConsole({server}: {server: ClipsLocalDevelopmentServerType}) {
usePerformanceNavigation({state: 'complete'});
const localUrl = useSignal('');
diff --git a/app/features/Series/Series.tsx b/app/features/Series/Series.tsx
index 8428cb43..ee90dcbe 100644
--- a/app/features/Series/Series.tsx
+++ b/app/features/Series/Series.tsx
@@ -193,7 +193,7 @@ function SeriesWithData({
@@ -212,13 +212,13 @@ function SeriesWithData({
function AccessoryClips({
id,
name,
- installations,
+ clips,
}: {
id: string;
name: string;
- installations: SeriesQueryData.Series['clipsInstallations'];
+ clips: SeriesQueryData.Series['clipsToRender'];
}) {
- const accessoryClips = useClips('series.details.accessory', installations, {
+ const accessoryClips = useClips('series.details.accessory', clips, {
id,
name,
});
@@ -228,7 +228,7 @@ function AccessoryClips({
return (
{accessoryClips.map((clip) => (
-
+
))}
);
diff --git a/app/features/Series/graphql/SeriesQuery.graphql b/app/features/Series/graphql/SeriesQuery.graphql
index 32834a59..a1a0a306 100644
--- a/app/features/Series/graphql/SeriesQuery.graphql
+++ b/app/features/Series/graphql/SeriesQuery.graphql
@@ -1,4 +1,4 @@
-#import "../../../shared/clips/graphql/ClipsExtensionFragment.graphql"
+#import "../../../shared/clips/graphql/ClipsExtensionPointFragment.graphql"
query Series($id: ID, $handle: String) {
series(id: $id, handle: $handle) {
@@ -55,8 +55,8 @@ query Series($id: ID, $handle: String) {
episode
}
}
- clipsInstallations(target: "series.details.accessory") {
- ...ClipsExtension
+ clipsToRender(target: "series.details.accessory") {
+ ...ClipsExtensionPoint
}
}
}
diff --git a/app/features/WatchThrough/WatchThrough.tsx b/app/features/WatchThrough/WatchThrough.tsx
index fb655d85..eb26c175 100644
--- a/app/features/WatchThrough/WatchThrough.tsx
+++ b/app/features/WatchThrough/WatchThrough.tsx
@@ -127,7 +127,7 @@ function WatchThroughWithData({
settings,
from,
to,
- clipsInstallations,
+ clipsToRender,
} = watchThrough;
const watchingSingleSeason = from.season === to.season;
@@ -234,7 +234,7 @@ function WatchThroughWithData({
id={id}
url={url}
series={series}
- installations={clipsInstallations}
+ clips={clipsToRender}
currentWatch={nextEpisodeForm}
/>
@@ -1016,24 +1016,26 @@ function AccessoryClips({
id,
url,
series,
- installations,
+ clips,
currentWatch,
}: Pick & {
- installations: WatchThroughQueryData.WatchThrough['clipsInstallations'];
+ clips: WatchThroughQueryData.WatchThrough['clipsToRender'];
currentWatch: Signal;
}) {
- const accessoryClips = useClips(
- 'watch-through.details.accessory',
- installations,
- {id, url, seriesId: series.id, seriesName: series.name, currentWatch},
- );
+ const accessoryClips = useClips('watch-through.details.accessory', clips, {
+ id,
+ url,
+ seriesId: series.id,
+ seriesName: series.name,
+ currentWatch,
+ });
if (accessoryClips.length === 0) return null;
return (
{accessoryClips.map((clip) => (
-
+
))}
);
diff --git a/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql b/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql
index b307a674..d8fa0b53 100644
--- a/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql
+++ b/app/features/WatchThrough/graphql/WatchThroughDetailsFragment.graphql
@@ -1,4 +1,4 @@
-#import "../../../shared/clips/graphql/ClipsExtensionFragment.graphql"
+#import "../../../shared/clips/graphql/ClipsExtensionPointFragment.graphql"
fragment WatchThroughDetails on WatchThrough {
id
@@ -129,7 +129,7 @@ fragment WatchThroughDetails on WatchThrough {
settings {
spoilerAvoidance
}
- clipsInstallations(target: "watch-through.details.accessory") {
- ...ClipsExtension
+ clipsToRender(target: "watch-through.details.accessory") {
+ ...ClipsExtensionPoint
}
}
diff --git a/app/server/app.tsx b/app/server/app.tsx
index 17edf88f..4c1ecc3e 100644
--- a/app/server/app.tsx
+++ b/app/server/app.tsx
@@ -5,7 +5,7 @@ import {Router} from '@quilted/quilt/navigation';
import {BrowserAssets} from 'quilt:module/assets';
import type {AppContext} from '~/shared/context.ts';
-import {createClipsManager} from '~/shared/clips.ts';
+import {ClipsManager} from '~/shared/clips.ts';
import App from '../App.tsx';
import {EXTENSION_POINTS} from '../clips.ts';
@@ -62,7 +62,7 @@ export const handleApp: RequestHandler = async function handleApp(request) {
router,
graphql: {cache: new GraphQLCache(), fetch: fetchGraphQL},
clipsManager: user
- ? createClipsManager(
+ ? new ClipsManager(
{user, graphql: fetchGraphQL, router},
EXTENSION_POINTS,
)
diff --git a/app/server/graphql/resolvers/apps/App.ts b/app/server/graphql/resolvers/apps/App.ts
index d1ae208d..583b296a 100644
--- a/app/server/graphql/resolvers/apps/App.ts
+++ b/app/server/graphql/resolvers/apps/App.ts
@@ -250,6 +250,13 @@ export const AppInstallation = createResolverWithGid('AppInstallation', {
async extensions({id}, _, {prisma}) {
const installations = await prisma.clipsExtensionInstallation.findMany({
where: {appInstallationId: id},
+ include: {
+ extension: {
+ include: {
+ activeVersion: true,
+ },
+ },
+ },
take: 50,
// orderBy: {createAt, 'desc'},
});
diff --git a/app/server/graphql/resolvers/apps/ClipsExtension.ts b/app/server/graphql/resolvers/apps/ClipsExtension.ts
index 075de144..a6d4fa7c 100644
--- a/app/server/graphql/resolvers/apps/ClipsExtension.ts
+++ b/app/server/graphql/resolvers/apps/ClipsExtension.ts
@@ -6,9 +6,11 @@ import type {
ClipsExtensionInstallation as DatabaseClipsExtensionInstallation,
} from '@prisma/client';
import Env from 'quilt:module/env';
+import type {ExtensionPoint} from '@watching/clips';
import {z} from 'zod';
import type {
+ ClipsExtensionBuildModuleInput,
ClipsExtensionPointSupportInput,
ClipsExtensionPointSupportConditionInput,
CreateClipsInitialVersion,
@@ -33,11 +35,22 @@ declare module '@quilted/quilt/env' {
}
}
+type DatabaseClipsExtensionInstallationWithActiveVersion =
+ DatabaseClipsExtensionInstallation & {
+ extension: DatabaseClipsExtension & {
+ activeVersion: DatabaseClipsExtensionVersion | null;
+ };
+ };
+
declare module '../types' {
export interface GraphQLValues {
ClipsExtension: DatabaseClipsExtension;
ClipsExtensionVersion: DatabaseClipsExtensionVersion;
- ClipsExtensionInstallation: DatabaseClipsExtensionInstallation;
+ ClipsExtensionInstallation: DatabaseClipsExtensionInstallationWithActiveVersion;
+ ClipsExtensionPointInstallation: {
+ target: ExtensionPoint;
+ installation: DatabaseClipsExtensionInstallationWithActiveVersion;
+ };
}
}
@@ -51,7 +64,7 @@ export const Query = createQueryResolver({
},
include: {
extension: {
- select: {activeVersion: {select: {status: true, extends: true}}},
+ include: {activeVersion: true},
},
},
take: 50,
@@ -64,12 +77,20 @@ export const Query = createQueryResolver({
return filterInstallations(installations, {
target,
conditions: resolvedConditions,
- });
+ }).map(({installation}) => installation);
},
- clipsInstallation(_, {id}, {prisma}) {
- return prisma.clipsExtensionInstallation.findUniqueOrThrow({
- where: {id: fromGid(id).id},
- });
+ async clipsInstallation(_, {id}, {prisma}) {
+ const installation =
+ await prisma.clipsExtensionInstallation.findUniqueOrThrow({
+ where: {id: fromGid(id).id},
+ include: {
+ extension: {
+ include: {activeVersion: true},
+ },
+ },
+ });
+
+ return installation;
},
});
@@ -133,7 +154,7 @@ export const Mutation = createMutationResolver({
},
async pushClipsExtension(
_,
- {id: extensionId, code, name, translations, extends: supports, settings},
+ {id: extensionId, build, name, translations, extends: supports, settings},
{prisma, request},
) {
const id = fromGid(extensionId).id;
@@ -148,8 +169,8 @@ export const Mutation = createMutationResolver({
});
const {version: versionInput} = await createStagedClipsVersion({
- code,
id,
+ build,
appId: extension.appId,
extensionName: name ?? extension.name,
translations,
@@ -211,7 +232,7 @@ export const Mutation = createMutationResolver({
const extension = await prisma.clipsExtension.findUniqueOrThrow({
where: {id: fromGid(id).id},
include: {
- activeVersion: {select: {extends: true}},
+ activeVersion: true,
},
});
@@ -248,6 +269,11 @@ export const Mutation = createMutationResolver({
settings: settings == null ? undefined : JSON.parse(settings),
appInstallationId: appInstallation.id,
},
+ include: {
+ extension: {
+ include: {activeVersion: true},
+ },
+ },
});
return {
@@ -281,20 +307,23 @@ export const Mutation = createMutationResolver({
select: {id: true, userId: true},
});
- const {extension, ...installation} =
- await prisma.clipsExtensionInstallation.update({
- where: {id: installationDetails.id},
- include: {extension: true},
- data:
- settings == null
- ? {}
- : {
- settings: JSON.parse(settings),
- },
- });
+ const installation = await prisma.clipsExtensionInstallation.update({
+ where: {id: installationDetails.id},
+ include: {
+ extension: {
+ include: {activeVersion: true},
+ },
+ },
+ data:
+ settings == null
+ ? {}
+ : {
+ settings: JSON.parse(settings),
+ },
+ });
return {
- extension,
+ extension: installation.extension,
installation,
};
},
@@ -335,7 +364,6 @@ export const ClipsExtensionVersion = createResolverWithGid(
prisma.clipsExtension.findUniqueOrThrow({
where: {id: extensionId},
}),
- assets: ({scriptUrl}) => (scriptUrl ? [{source: scriptUrl}] : []),
translations: ({translations}) =>
translations ? JSON.stringify(translations) : null,
// Database needs to store the exact right shapes in its JSON fields
@@ -395,63 +423,125 @@ export const ClipsExtensionInstallation = createResolverWithGid(
prisma.appInstallation.findUniqueOrThrow({
where: {id: appInstallationId},
}),
- async version({extensionId}, _, {prisma}) {
- const extension = await prisma.clipsExtension.findUniqueOrThrow({
- where: {id: extensionId},
- select: {activeVersion: true},
- });
-
+ async version({extension}) {
return extension.activeVersion!;
},
- async translations({extensionId}, _, {prisma}) {
- const extension = await prisma.clipsExtension.findUniqueOrThrow({
- where: {id: extensionId},
- select: {activeVersion: true},
- });
-
+ async translations({extension}) {
const translations = extension.activeVersion?.translations;
return translations ? JSON.stringify(translations) : null;
},
- async liveQuery({target, extensionId}, _, {prisma}) {
- const extension = await prisma.clipsExtension.findUniqueOrThrow({
- where: {id: extensionId},
- select: {activeVersion: true},
- });
-
- const extend = (extension.activeVersion?.extends ?? []) as any[];
+ settings: ({settings}) => (settings ? JSON.stringify(settings) : null),
+ },
+);
- return extend.find((extend) => extend.target === target)?.liveQuery;
+export const ClipsExtensionPointInstallation = createResolverWithGid(
+ 'ClipsExtensionPointInstallation',
+ {
+ id: ({target, installation}) =>
+ toGid(`${installation.id}/${target}`, 'ClipsExtensionPointInstallation'),
+ target: ({target}) => target,
+ apiVersion: ({installation}) =>
+ installation.extension.activeVersion!.apiVersion,
+ entry({target, installation}) {
+ const {entry} = getExtensionPoint(target, installation);
+ const module = getExtensionBuildModule(entry.module, installation);
+
+ switch (module.contentType) {
+ case 'JAVASCRIPT':
+ return {
+ asHTML: null,
+ asJavaScript: {
+ src: module.src!,
+ },
+ };
+ case 'HTML':
+ return {
+ asHTML: {
+ content: module.content,
+ },
+ asJavaScript: null,
+ };
+ default:
+ throw new Error(`Unsupported content type: ${module.contentType}`);
+ }
},
- async loading({target, extensionId}, _, {prisma}) {
- const extension = await prisma.clipsExtension.findUniqueOrThrow({
- where: {id: extensionId},
- select: {activeVersion: true},
- });
+ liveQuery({target, installation}) {
+ const {liveQuery} = getExtensionPoint(target, installation);
- const extend = (extension.activeVersion?.extends ?? []) as any[];
+ if (liveQuery == null) return null;
- const loading = extend.find((extend) => extend.target === target)
- ?.loading;
+ const module = getExtensionBuildModule(liveQuery.module, installation);
+
+ return {
+ content: module.content,
+ };
+ },
+ loading({target, installation}) {
+ const {loading} = getExtensionPoint(target, installation);
if (loading == null) return null;
+ const module = getExtensionBuildModule(loading.module, installation);
+
return {
- ui: loading?.ui
- ? {
- html: loading.ui,
- }
- : null,
+ content: module.content,
};
},
- settings: ({settings}) => (settings ? JSON.stringify(settings) : null),
+ settings: ({installation}) =>
+ installation.extension.activeVersion?.settings
+ ? JSON.stringify(installation.extension.activeVersion.settings)
+ : null,
+ translations: ({installation}) =>
+ installation.extension.activeVersion?.translations
+ ? JSON.stringify(installation.extension.activeVersion.translations)
+ : null,
+ extension: ({installation}) => installation.extension,
+ extensionInstallation: ({installation}) => installation,
+ appInstallation: ({installation}, _, {prisma}) =>
+ prisma.appInstallation.findUniqueOrThrow({
+ where: {id: installation.appInstallationId},
+ }),
},
);
+function getExtensionPoint(
+ target: string,
+ installation: DatabaseClipsExtensionInstallationWithActiveVersion,
+) {
+ const extend = installation.extension.activeVersion
+ ?.extends as any as ClipsExtensionPointSupportDatabaseJSON[];
+ const extensionPoint = extend?.find((extend) => extend.target === target);
+
+ if (extensionPoint == null) {
+ throw new Error(`Extension point not found: ${target}`);
+ }
+
+ return extensionPoint;
+}
+
+function getExtensionBuildModule(
+ name: string,
+ installation: DatabaseClipsExtensionInstallationWithActiveVersion,
+) {
+ const modules = (
+ installation.extension.activeVersion
+ ?.build as any as ClipsExtensionBuildDatabaseJSON
+ )?.modules;
+
+ const module = modules?.find((module) => module.name === name);
+
+ if (module == null) {
+ throw new Error(`Unknown module: ${name}`);
+ }
+
+ return module;
+}
+
const SeriesExtensionPoint = z.enum(['series.details.accessory']);
export const Series = createResolver('Series', {
- async clipsInstallations(series, {target}, {user, prisma}) {
+ async clipsToRender(series, {target}, {user, prisma}) {
if (!SeriesExtensionPoint.safeParse(target).success) {
throw new Error(`Invalid target: ${target}`);
}
@@ -464,7 +554,7 @@ export const Series = createResolver('Series', {
},
include: {
extension: {
- select: {activeVersion: {select: {status: true, extends: true}}},
+ include: {activeVersion: true},
},
},
take: 50,
@@ -487,7 +577,7 @@ export const Series = createResolver('Series', {
const WatchThroughExtensionPoint = z.enum(['watch-through.details.accessory']);
export const WatchThrough = createResolver('WatchThrough', {
- async clipsInstallations({seriesId}, {target}, {user, prisma}) {
+ async clipsToRender({seriesId}, {target}, {user, prisma}) {
if (!WatchThroughExtensionPoint.safeParse(target).success) {
throw new Error(`Invalid target: ${target}`);
}
@@ -505,7 +595,7 @@ export const WatchThrough = createResolver('WatchThrough', {
},
include: {
extension: {
- select: {activeVersion: {select: {status: true, extends: true}}},
+ include: {activeVersion: true},
},
},
take: 50,
@@ -550,8 +640,64 @@ async function parseLoadingUI(loadingUI: string) {
return validateAndNormalizeLoadingUI(loadingUI);
}
+interface ClipsExtensionBuildDatabaseJSON {
+ modules: ClipsExtensionBuildModuleDatabaseJSON[];
+}
+
+interface ClipsExtensionBuildModuleDatabaseJSON
+ extends ClipsExtensionBuildModuleInput {
+ src?: string;
+}
+
+interface ClipsExtensionPointSupportDatabaseJSON
+ extends ClipsExtensionPointSupportInput {
+ target: ExtensionPoint;
+}
+
+// const ClipsExtensionBuildModuleInputSchema = z.object({
+// name: z.string(),
+// content: z.string(),
+// contentType: z.enum(['JAVASCRIPT', 'HTML', 'GRAPHQL']),
+// });
+
+// const ClipsExtensionBuildModuleDatabaseSchema =
+// ClipsExtensionBuildModuleInputSchema.extend({
+// src: z.string().optional(),
+// });
+
+// const ClipsExtensionBuildDatabaseSchema = z.object({
+// modules: z.array(ClipsExtensionBuildModuleDatabaseSchema),
+// });
+
+// const ClipsExtensionPointSupportConditionInputSchema = z.object({
+// series: z
+// .object({
+// handle: z.string().optional(),
+// })
+// .optional(),
+// });
+
+// const ClipsExtensionPointModuleDatabaseSchema = z.object({
+// module: z.string(),
+// });
+
+// const ClipsExtensionPointSchema = z.enum([
+// 'series.details.accessory',
+// 'watch-through.details.accessory',
+// ]);
+
+// const ClipsExtensionPointSupportDatabaseSchema = z.object({
+// target: ClipsExtensionPointSchema,
+// conditions: z
+// .array(ClipsExtensionPointSupportConditionInputSchema)
+// .optional(),
+// entry: ClipsExtensionPointModuleDatabaseSchema,
+// liveQuery: ClipsExtensionPointModuleDatabaseSchema.optional(),
+// loading: ClipsExtensionPointModuleDatabaseSchema.optional(),
+// });
+
async function createStagedClipsVersion({
- code,
+ build,
appId,
extensionName,
translations,
@@ -560,97 +706,128 @@ async function createStagedClipsVersion({
settings,
}: {
id: string;
- code: string;
appId: string;
extensionName: string;
} & Pick &
CreateClipsInitialVersion) {
- const hash = createHash('sha256').update(code).digest('hex');
- const path = `assets/clips/${appId}/${toParam(extensionName)}.${hash}.js`;
-
- const token = await createSignedToken(
- {path, code},
- {
- secret: Env.UPLOAD_CLIPS_JWT_SECRET,
- expiresIn: '5m',
- },
- );
+ const modules = new Map();
+
+ for (const buildModule of build.modules) {
+ if (buildModule.contentType === 'JAVASCRIPT') {
+ if (buildModule.name !== '_extension.js') continue;
+ modules.set(buildModule.name, buildModule);
+ }
+
+ if (buildModule.content.length > 10_000) {
+ throw new Error(
+ `Clip module content for ${buildModule.name} is too long`,
+ );
+ }
+
+ modules.set(buildModule.name, buildModule);
+ }
+
+ const javascriptModule = modules.get('_extension.js');
- const putResult = await fetch(
- new URL('/internal/upload/clips', request.url),
- {
- method: 'PUT',
- body: token,
- headers: {
- 'Content-Type': 'application/json',
+ if (javascriptModule) {
+ const code = javascriptModule.content;
+
+ const hash = createHash('sha256').update(code).digest('hex');
+ const path = `assets/clips/${appId}/${toParam(extensionName)}.${hash.slice(
+ 0,
+ 8,
+ )}.js`;
+
+ const token = await createSignedToken(
+ {path, code},
+ {
+ secret: Env.UPLOAD_CLIPS_JWT_SECRET,
+ expiresIn: '5m',
},
- // @see https://github.com/nodejs/node/issues/46221
- ...{duplex: 'half'},
- },
- );
+ );
+
+ const putResult = await fetch(
+ new URL('/internal/upload/clips', request.url),
+ {
+ method: 'PUT',
+ body: token,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ // @see https://github.com/nodejs/node/issues/46221
+ ...{duplex: 'half'},
+ },
+ );
+
+ if (!putResult.ok) {
+ throw new Error(`Could not upload clips: '${await putResult.text()}'`);
+ }
- if (!putResult.ok) {
- throw new Error(`Could not upload clips: '${await putResult.text()}'`);
+ javascriptModule.content = '';
+ javascriptModule.src = `https://watch.lemon.tools/${path}`;
}
+ const extensionPoints = await Promise.all(
+ (supports ?? []).map(
+ async ({target, entry, liveQuery, loading, conditions}) => {
+ if (!modules.has(entry.module)) {
+ throw new Error(`Unknown entry module: ${entry.module}`);
+ }
+
+ if (liveQuery) {
+ const module = modules.get(liveQuery.module);
+
+ if (module == null) {
+ throw new Error(`Unknown live query module: ${liveQuery.module}`);
+ }
+
+ if (module.contentType !== 'GRAPHQL') {
+ throw new Error(
+ `Unsupported content type for live query: ${module.contentType}`,
+ );
+ }
+
+ // TODO: what if multiple targets use the same live query?
+ module.content = await parseLiveQuery(module.content);
+ }
+
+ if (loading) {
+ const module = modules.get(loading.module);
+
+ if (module == null) {
+ throw new Error(`Unknown loading module: ${loading.module}`);
+ }
+
+ module.content = await parseLoadingUI(module.content);
+ }
+
+ return {
+ target: await parseExtensionPointTarget(target),
+ entry,
+ liveQuery,
+ loading,
+ conditions: conditions?.map((condition) => {
+ if (condition?.series?.handle == null) {
+ throw new Error(`Unknown condition: ${condition}`);
+ }
+
+ return condition;
+ }),
+ } satisfies ClipsExtensionPointSupportDatabaseJSON;
+ },
+ ),
+ );
+
const version: Omit<
import('@prisma/client').Prisma.ClipsExtensionVersionCreateWithoutExtensionInput,
'status'
> = {
- scriptUrl: `https://watch.lemon.tools/${path}`,
apiVersion: 'UNSTABLE',
translations: translations && JSON.parse(translations),
- extends: supports
- ? await Promise.all(
- supports.map(
- async ({target, modules, liveQuery, loading, conditions}) => {
- const [
- parsedTarget,
- parsedModules,
- parsedLiveQuery,
- parsedLoadingUI,
- ] = await Promise.all([
- parseExtensionPointTarget(target),
- Promise.all(
- (modules ?? []).map(
- async ({content, contentType = 'HTML'}) => {
- if (content.length > 10_000) {
- throw new Error(
- `Clip module content for ${target} is too long`,
- );
- }
-
- // TODO: validate HTML content
-
- return {content, contentType};
- },
- ),
- ),
- liveQuery ? parseLiveQuery(liveQuery) : undefined,
- loading?.ui ? parseLoadingUI(loading.ui) : undefined,
- ]);
-
- return {
- target: parsedTarget,
- modules: parsedModules,
- liveQuery: parsedLiveQuery,
- loading: parsedLoadingUI
- ? {
- ui: parsedLoadingUI,
- }
- : undefined,
- conditions: conditions?.map((condition) => {
- if (condition?.series?.handle == null) {
- throw new Error(`Unknown condition: ${condition}`);
- }
-
- return condition;
- }),
- };
- },
- ) as any,
- )
- : [],
+ build: {
+ modules: Array.from(modules.values()),
+ } satisfies ClipsExtensionBuildDatabaseJSON as any,
+ extends: extensionPoints as any,
settings: settings?.fields
? {
fields: settings.fields?.map(
@@ -768,14 +945,7 @@ async function resolveConditions(
}
function filterInstallations(
- installations: (DatabaseClipsExtensionInstallation & {
- extension: {
- activeVersion: Pick<
- DatabaseClipsExtensionVersion,
- 'status' | 'extends'
- > | null;
- };
- })[],
+ installations: DatabaseClipsExtensionInstallationWithActiveVersion[],
{
target,
conditions,
@@ -784,20 +954,19 @@ function filterInstallations(
conditions: ResolvedCondition[];
},
) {
- return installations.filter((installation) => {
+ return installations.flatMap<{
+ target: ExtensionPoint;
+ installation: DatabaseClipsExtensionInstallationWithActiveVersion;
+ }>((installation) => {
const version = installation.extension.activeVersion;
if (version == null || version.status !== 'PUBLISHED') {
- return false;
+ return [];
}
- const extensionPoints =
- (version.extends as {
- target: string;
- conditions?: {series?: {handle?: string}}[];
- }[]) ?? [];
-
- return extensionPoints.some((extensionPoint) => {
+ const matches = (
+ version.extends as unknown as ClipsExtensionPointSupportDatabaseJSON[]
+ ).some((extensionPoint) => {
if (target != null && target !== extensionPoint.target) {
return false;
}
@@ -817,5 +986,9 @@ function filterInstallations(
);
});
});
+
+ return matches
+ ? {target: installation.target as ExtensionPoint, installation}
+ : [];
});
}
diff --git a/app/server/graphql/resolvers/shared/resolvers.ts b/app/server/graphql/resolvers/shared/resolvers.ts
index 297ba330..72ca016e 100644
--- a/app/server/graphql/resolvers/shared/resolvers.ts
+++ b/app/server/graphql/resolvers/shared/resolvers.ts
@@ -32,8 +32,8 @@ export {createResolver, createQueryResolver, createMutationResolver};
export function createResolverWithGid<
Type extends keyof Resolvers,
- Fields extends keyof Resolvers[Type],
->(type: Type, resolver: Required>) {
+ Resolver extends Partial,
+>(type: Type, resolver: Resolver): Resolver & {id: (arg: any) => string} {
return {id: ({id}: {id: string}) => toGid(id, type), ...resolver};
}
@@ -51,10 +51,10 @@ export function createUnionResolver(): UnionResolver {
const RESOLVED_TYPE = Symbol.for('watch.resolved-type');
-export function addResolvedType(type: string, object: T) {
+export function addResolvedType(type: string, object: T): T {
return {...object, [RESOLVED_TYPE]: type};
}
-function resolveType(obj: {[RESOLVED_TYPE]?: string; id: string}) {
+function resolveType(obj: any) {
return obj[RESOLVED_TYPE] ?? fromGid(obj.id).type;
}
diff --git a/app/shared/clips.ts b/app/shared/clips.ts
index 9c00c511..0aefd46f 100644
--- a/app/shared/clips.ts
+++ b/app/shared/clips.ts
@@ -8,4 +8,4 @@ export {
export * from './clips/manager.ts';
export * from './clips/local-development.tsx';
export * from './clips/extension-points';
-export {type ClipsExtensionFragmentData as InstalledClipsExtensionPointFragment} from './clips/graphql/ClipsExtensionFragment.graphql';
+export {type ClipsExtensionPointFragmentData as InstalledClipsExtensionPointFragment} from './clips/graphql/ClipsExtensionPointFragment.graphql';
diff --git a/app/shared/clips/Clip/Clip.tsx b/app/shared/clips/Clip/Clip.tsx
index 3937394e..c552c9e7 100644
--- a/app/shared/clips/Clip/Clip.tsx
+++ b/app/shared/clips/Clip/Clip.tsx
@@ -20,11 +20,13 @@ import type {ThreadRendererInstance} from '@watching/thread-render';
import {useGraphQLMutation} from '~/shared/graphql.ts';
-import {ClipsExtensionPointBeingRenderedContext} from '../context.ts';
-import {useClipsManager} from '../react.tsx';
import {
+ ClipsExtensionPointBeingRenderedContext,
+ useClipsExtensionPointBeingRendered,
+} from '../context.ts';
+import {
+ ClipsExtensionPointRenderer,
type ClipsExtensionPoint,
- type ClipsExtensionPointInstance,
type ClipsExtensionPointInstanceContext,
} from '../extension.ts';
@@ -35,43 +37,33 @@ import styles from './Clip.module.css';
import uninstallClipsExtensionFromClipMutation from './graphql/UninstallClipsExtensionFromClipMutation.graphql';
export interface ClipProps {
- extension: ClipsExtensionPoint;
+ extensionPoint: ClipsExtensionPoint;
}
export function Clip({
- extension,
+ extensionPoint,
}: ClipProps) {
- const {name, app} = extension.extension;
-
- const manager = useClipsManager();
- const installed =
- extension.installed && manager.fetchInstance(extension.installed);
- const local = extension.local && manager.fetchInstance(extension.local);
-
- const renderer = local ?? installed;
+ const {name, app} = extensionPoint.extension;
+ const renderer =
+ extensionPoint.installed?.renderer ?? extensionPoint.local?.renderer;
return (
-
+
- {installed?.instance.value && (
+ {extensionPoint.installed && (
)}
}
@@ -118,27 +110,25 @@ function ViewAppAction() {
}
function RestartClipButton({
- instance,
+ renderer,
}: {
- instance: ClipsExtensionPointInstance;
+ renderer: ClipsExtensionPointRenderer;
}) {
return (
-