-
Notifications
You must be signed in to change notification settings - Fork 79
/
blocks.ts
265 lines (235 loc) · 8.21 KB
/
blocks.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
import { BlockMetadata, BlockVariant } from "@blockprotocol/core";
/** @todo: might need refactor: https://github.com/hashintel/dev/pull/206#discussion_r723210329 */
// eslint-disable-next-line global-require
const fetch = (globalThis as any).fetch ?? require("node-fetch");
export interface HashBlockMeta extends BlockMetadata {
componentId: string;
variants: NonNullable<BlockMetadata["variants"]>;
}
export type HashBlock = {
meta: HashBlockMeta;
};
/**
* The cache is designed to store promises, not resolved values, in order to
* ensure multiple requests for the same block in rapid succession don't cause
* multiple web requests
*
* @deprecated in favor of react context "blockMeta" (which is not the final
* solution either)
*/
const blockCache = new Map<string, Promise<HashBlock>>();
export const componentIdToUrl = (componentId: string) =>
componentId.replace(/\/$/, "");
const devReloadEndpointSet = new Set<string>();
const configureAppReloadWhenBlockChanges = (
devReloadEndpoint: string,
reportProblem: (problem: string) => void,
) => {
if (typeof window === "undefined") {
return;
}
if (devReloadEndpointSet.has(devReloadEndpoint)) {
return;
}
devReloadEndpointSet.add(devReloadEndpoint);
if (devReloadEndpoint.match(/^wss?:\/\//)) {
try {
const socket = new WebSocket(devReloadEndpoint);
socket.addEventListener("message", ({ data }) => {
try {
const messageType = JSON.parse(data).type;
// Assume webpack dev server socket
if (["invalid", "static-changed"].includes(messageType)) {
window.location.reload();
}
} catch {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- error stringification may need improvement
reportProblem(`Could not parse socket message: ${data}`);
}
});
} catch {
reportProblem(`Could not connect to a websocket at ${devReloadEndpoint}`);
}
return;
}
reportProblem(
`URLs like "${devReloadEndpoint}" are not supported (expected a websocket)`,
);
};
/**
* Get an absolute url if the path is not already one.
*/
function deriveAbsoluteUrl(args: { baseUrl: string; path: string }): string;
function deriveAbsoluteUrl(args: {
baseUrl: string;
path?: string | null | undefined;
}): string | null | undefined;
function deriveAbsoluteUrl({
baseUrl,
path,
}: {
baseUrl: string;
path?: string | null;
}): string | null | undefined {
const regex = /^(?:[a-z]+:)?\/\//i;
if (!path || regex.test(path)) {
return path;
}
return `${baseUrl}/${path.replace(/^\//, "")}`;
}
/**
* Transform a block metadata and schema file into fully-defined block variants.
* This would ideally be the one place we manipulate block metadata.
*/
const transformBlockConfig = ({
componentId,
metadata,
}: {
componentId: string;
metadata: BlockMetadata;
}): HashBlockMeta => {
const defaultVariant: BlockVariant = {
description: metadata.description ?? metadata.displayName ?? metadata.name,
name: metadata.displayName ?? metadata.name,
icon: metadata.icon ?? "",
properties: {},
};
const baseUrl = componentIdToUrl(componentId);
const variants = (metadata.variants?.length ? metadata.variants : [{}])
.map((variant) => ({ ...defaultVariant, ...variant }))
.map((variant) => ({
...variant,
// the Block Protocol API is returning absolute URLs for icons, but this might be from elsewhere
icon: deriveAbsoluteUrl({ baseUrl, path: variant.icon }),
name: variant.name,
}));
return {
...metadata,
componentId,
variants,
icon: deriveAbsoluteUrl({ baseUrl, path: metadata.image }),
image: deriveAbsoluteUrl({ baseUrl, path: metadata.icon }),
schema: deriveAbsoluteUrl({ baseUrl, path: metadata.schema }),
source: deriveAbsoluteUrl({ baseUrl, path: metadata.source }),
};
};
export const prepareBlockCache = (
componentId: string,
block: HashBlock | Promise<HashBlock>,
) => {
if (typeof window !== "undefined") {
const key = componentIdToUrl(componentId);
if (!blockCache.has(key)) {
blockCache.set(
key,
Promise.resolve().then(() => block),
);
}
}
};
// @todo deal with errors, loading, abort etc.
export const fetchBlock = async (
componentId: string,
options?: { bustCache: boolean },
): Promise<HashBlock> => {
const baseUrl = componentIdToUrl(componentId);
if (options?.bustCache) {
blockCache.delete(baseUrl);
} else if (blockCache.has(baseUrl)) {
return blockCache.get(baseUrl)!;
}
const promise = (async () => {
// the spec requires a metadata file called `block-metadata.json`
const metadataUrl = `${baseUrl}/block-metadata.json`;
let metadata: BlockMetadata;
try {
// @todo needs validation
metadata = await (await fetch(metadataUrl)).json();
} catch (err) {
blockCache.delete(baseUrl);
throw new Error(
`Could not fetch and parse block metadata at url ${metadataUrl}: ${
(err as Error).message
}`,
);
}
// @todo Move this logic to a place where a block is mounted. This requires
// block metadata to be available there. Current implementation reloads
// the EA even if a locally developed block is not mounted (which should be rare).
if (metadata.devReloadEndpoint) {
configureAppReloadWhenBlockChanges(
metadata.devReloadEndpoint,
(problem) => {
// eslint-disable-next-line no-console -- @todo consider using logger
console.error(
`${baseUrl} → block-metadata.json → devReloadEndpoint: ${problem}`,
);
},
);
}
const result: HashBlock = {
meta: transformBlockConfig({
metadata,
componentId: baseUrl,
}),
};
return result;
})();
prepareBlockCache(baseUrl, promise);
return await promise;
};
/**
* @todo-0.3 replace this temporary domain with blockprotocol.org
* https://app.asana.com/0/1203358502199087/1203788113163116/f
*/
export const blockProtocolHubOrigin =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want empty strings either
process.env.BLOCK_PROTOCOL_HUB_ORIGIN ||
"https://blockprotocol-git-03.stage.hash.ai";
export const paragraphBlockComponentId = `${blockProtocolHubOrigin}/blocks/@hash/paragraph`;
const textBlockComponentIds = new Set([
paragraphBlockComponentId,
`${blockProtocolHubOrigin}/blocks/@hash/header`,
`${blockProtocolHubOrigin}/blocks/@hash/callout`,
]);
/**
* Default blocks loaded for every user.
*
* @todo allow users to configure their own default block list, and store in db.
* this should be a list of additions and removals from this default list,
* to allow us to add new default blocks that show up for all users.
* we currently store this in localStorage - see UserBlockProvider.
*/
export const defaultBlockComponentIds = [
...Array.from(textBlockComponentIds),
`${blockProtocolHubOrigin}/blocks/@hash/person`,
`${blockProtocolHubOrigin}/blocks/@hash/image`,
`${blockProtocolHubOrigin}/blocks/@hash/table`,
`${blockProtocolHubOrigin}/blocks/@hash/divider`,
`${blockProtocolHubOrigin}/blocks/@hash/embed`,
`${blockProtocolHubOrigin}/blocks/@hash/code`,
`${blockProtocolHubOrigin}/blocks/@hash/video`,
];
/**
* This is used to work out if the block is one of our hardcoded text blocks,
* which is used to know if the block is compatible for switching from one
* text block to another
*/
export const isHashTextBlock = (componentId: string) =>
textBlockComponentIds.has(componentId);
/**
* In some places, we need to know if the current component and a target
* component we're trying to switch to are compatible, in order to know whether
* to share existing properties or whether to enabling switching. This does that
* by checking IDs are the same (i.e, they're variants of the same block) or
* if we've hardcoded support for switching (i.e, they're HASH text blocks)
*/
export const areComponentsCompatible = (
currentComponentId: string | null = null,
targetComponentId: string | null = null,
) =>
currentComponentId &&
targetComponentId &&
(currentComponentId === targetComponentId ||
(isHashTextBlock(currentComponentId) &&
isHashTextBlock(targetComponentId)));