@crombolo/server 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbraco's ModelsBuilder over `crombolo generate`: the models are the
|
|
3
|
+
* TypeScript in `schema/content-types.d.ts`, written from the TOML schema.
|
|
4
|
+
*
|
|
5
|
+
* Umbraco reports staleness from a flag file written when a content type
|
|
6
|
+
* changes. There is nothing to flag here — the schema files are the truth — so
|
|
7
|
+
* "out of date" is answered by generating the types and comparing them with
|
|
8
|
+
* what is on disk, which cannot drift from the question being asked.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { join, relative } from 'node:path'
|
|
13
|
+
import type {
|
|
14
|
+
ModelsBuilderInfo,
|
|
15
|
+
ModelsBuilderPort,
|
|
16
|
+
OutOfDateStatus,
|
|
17
|
+
} from '@crombolo/api-management'
|
|
18
|
+
import { generateTypes, loadSchemaDirectory, validateSchemaSet } from '@crombolo/schema'
|
|
19
|
+
import type { CromboloConfig } from './config.ts'
|
|
20
|
+
import { VERSION } from './config.ts'
|
|
21
|
+
import { logger } from './logging.ts'
|
|
22
|
+
|
|
23
|
+
/** The file `crombolo generate` writes, inside the schema directory. */
|
|
24
|
+
export const GENERATED_FILE = 'content-types.d.ts'
|
|
25
|
+
|
|
26
|
+
export type Intended = { types: string } | { problems: string[] }
|
|
27
|
+
|
|
28
|
+
/** What `crombolo generate` would write now, or the problems stopping it. */
|
|
29
|
+
export function intendedTypes(schemaDir: string): Intended {
|
|
30
|
+
if (!existsSync(schemaDir)) return { problems: [`No schema directory at ${schemaDir}`] }
|
|
31
|
+
const loaded = loadSchemaDirectory(schemaDir)
|
|
32
|
+
const problems = [...loaded.problems, ...validateSchemaSet(loaded.set)]
|
|
33
|
+
if (problems.length > 0)
|
|
34
|
+
return {
|
|
35
|
+
problems: problems.map((p) => `${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`),
|
|
36
|
+
}
|
|
37
|
+
return { types: generateTypes(loaded.set) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Undefined when the schema does not generate, so staleness is unknowable. */
|
|
41
|
+
export async function compareModels(
|
|
42
|
+
schemaDir: string,
|
|
43
|
+
): Promise<{ outOfDate: boolean } | undefined> {
|
|
44
|
+
const intended = intendedTypes(schemaDir)
|
|
45
|
+
if ('problems' in intended) return undefined
|
|
46
|
+
const target = join(schemaDir, GENERATED_FILE)
|
|
47
|
+
if (!existsSync(target)) return { outOfDate: true }
|
|
48
|
+
const current = await readFile(target, 'utf8').catch(() => undefined)
|
|
49
|
+
return { outOfDate: current !== intended.types }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createModelsBuilderPort(
|
|
53
|
+
config: Pick<CromboloConfig, 'schemaDir'>,
|
|
54
|
+
): ModelsBuilderPort {
|
|
55
|
+
// Umbraco keeps the last generation error in memory and shows it on the
|
|
56
|
+
// dashboard; a build that succeeds clears it.
|
|
57
|
+
let lastError: string | null = null
|
|
58
|
+
const log = logger('models-builder')
|
|
59
|
+
|
|
60
|
+
const describe = async (): Promise<ModelsBuilderInfo> => {
|
|
61
|
+
const compared = await compareModels(config.schemaDir)
|
|
62
|
+
return {
|
|
63
|
+
// The models are TypeScript source, written on request: Umbraco's mode for
|
|
64
|
+
// exactly that. The client only explains its own four mode strings.
|
|
65
|
+
mode: 'SourceCodeManual',
|
|
66
|
+
canGenerate: true,
|
|
67
|
+
outOfDateModels: compared?.outOfDate ?? false,
|
|
68
|
+
trackingOutOfDateModels: true,
|
|
69
|
+
lastError,
|
|
70
|
+
version: VERSION,
|
|
71
|
+
// Shown to a person, so the path the CLI would print rather than an
|
|
72
|
+
// absolute one from inside a container.
|
|
73
|
+
modelsNamespace: relative(process.cwd(), join(config.schemaDir, GENERATED_FILE)),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
info: describe,
|
|
79
|
+
|
|
80
|
+
async status(): Promise<OutOfDateStatus> {
|
|
81
|
+
const compared = await compareModels(config.schemaDir)
|
|
82
|
+
if (!compared) return 'Unknown'
|
|
83
|
+
return compared.outOfDate ? 'OutOfDate' : 'Current'
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async build() {
|
|
87
|
+
const intended = intendedTypes(config.schemaDir)
|
|
88
|
+
if ('problems' in intended) {
|
|
89
|
+
lastError = intended.problems.join('\n')
|
|
90
|
+
log.warn('Models were not generated: {problems}', { problems: intended.problems.length })
|
|
91
|
+
return { ok: false, error: lastError }
|
|
92
|
+
}
|
|
93
|
+
const target = join(config.schemaDir, GENERATED_FILE)
|
|
94
|
+
try {
|
|
95
|
+
await writeFile(target, intended.types)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
98
|
+
log.error('Models could not be written to {target}', { target })
|
|
99
|
+
return { ok: false, error: lastError }
|
|
100
|
+
}
|
|
101
|
+
lastError = null
|
|
102
|
+
log.info('Models written to {target}', { target })
|
|
103
|
+
return { ok: true }
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/no-nodes.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The holding page for a site with nothing published — Umbraco's "no nodes"
|
|
3
|
+
* screen, which `docs/06-features.md` asks for.
|
|
4
|
+
*
|
|
5
|
+
* It answers 404, not 200: a brand-new site is not a working one, and anything
|
|
6
|
+
* watching the URL should keep saying so until a page is published. It is only
|
|
7
|
+
* reached when the published tree is empty, so a missing path on a live site is
|
|
8
|
+
* the bare 404 it always was.
|
|
9
|
+
*/
|
|
10
|
+
export function noNodesPage(options: { siteName: string; backOfficePath: string }): string {
|
|
11
|
+
const site = escapeHtml(options.siteName)
|
|
12
|
+
const backoffice = escapeHtml(options.backOfficePath)
|
|
13
|
+
return `<!doctype html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
+
<meta name="robots" content="noindex">
|
|
19
|
+
<title>${site} — nothing published yet</title>
|
|
20
|
+
<style>
|
|
21
|
+
:root { color-scheme: light dark; }
|
|
22
|
+
body {
|
|
23
|
+
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
24
|
+
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
25
|
+
background: #f6f5f3; color: #1b1b1b;
|
|
26
|
+
}
|
|
27
|
+
main { max-width: 34rem; padding: 2.5rem 1.5rem; }
|
|
28
|
+
h1 { font-size: 1.6rem; margin: 0 0 .75rem; }
|
|
29
|
+
p { margin: 0 0 1rem; }
|
|
30
|
+
ol { margin: 0 0 1.5rem; padding-left: 1.25rem; }
|
|
31
|
+
li { margin-bottom: .35rem; }
|
|
32
|
+
code { font-size: .9em; background: #e9e7e2; padding: .1em .35em; border-radius: 3px; }
|
|
33
|
+
a.cta { display: inline-block; padding: .55rem 1rem; border-radius: 4px; background: #1b1b1b; color: #fff; text-decoration: none; }
|
|
34
|
+
.fine { font-size: .85rem; color: #6b6b63; }
|
|
35
|
+
@media (prefers-color-scheme: dark) {
|
|
36
|
+
body { background: #16171a; color: #e8e6e3; }
|
|
37
|
+
code { background: #26282c; }
|
|
38
|
+
a.cta { background: #e8e6e3; color: #16171a; }
|
|
39
|
+
.fine { color: #9a9a93; }
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<main>
|
|
45
|
+
<h1>Nothing is published yet</h1>
|
|
46
|
+
<p>${site} is running. There is no published page for visitors to see, so this is what the site serves.</p>
|
|
47
|
+
<ol>
|
|
48
|
+
<li>Sign in to the backoffice.</li>
|
|
49
|
+
<li>Create a page from one of the content types in <code>schema/</code> — if there are none, add one there first.</li>
|
|
50
|
+
<li>Publish it. This page goes away.</li>
|
|
51
|
+
</ol>
|
|
52
|
+
<p><a class="cta" href="${backoffice}">Open the backoffice</a></p>
|
|
53
|
+
<p class="fine">Served with a 404: nothing is published, and a crawler should be told that plainly.</p>
|
|
54
|
+
</main>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|
|
57
|
+
`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const escapeHtml = (value: string): string =>
|
|
61
|
+
value.replace(
|
|
62
|
+
/[&<>"']/g,
|
|
63
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
64
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development aid: a line on the console the first time the backoffice calls an
|
|
3
|
+
* operation that answers 501, and a running list, so one walk through a
|
|
4
|
+
* section shows everything it still needs.
|
|
5
|
+
*/
|
|
6
|
+
import type { OperationInfo } from '@crombolo/contracts'
|
|
7
|
+
|
|
8
|
+
export interface NotImplementedEntry {
|
|
9
|
+
operationId: string
|
|
10
|
+
method: string
|
|
11
|
+
path: string
|
|
12
|
+
/** How many times it was asked for. */
|
|
13
|
+
count: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class NotImplementedLog {
|
|
17
|
+
readonly #entries = new Map<string, NotImplementedEntry>()
|
|
18
|
+
readonly #write: (line: string, entry: NotImplementedEntry) => void
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
write: (line: string, entry: NotImplementedEntry) => void = (line) => console.warn(line),
|
|
22
|
+
) {
|
|
23
|
+
this.#write = write
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
record(operation: OperationInfo, request: Request): void {
|
|
27
|
+
const existing = this.#entries.get(operation.operationId)
|
|
28
|
+
if (existing) {
|
|
29
|
+
existing.count += 1
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const path = new URL(request.url).pathname
|
|
33
|
+
const entry = { operationId: operation.operationId, method: request.method, path, count: 1 }
|
|
34
|
+
this.#entries.set(operation.operationId, entry)
|
|
35
|
+
this.#write(
|
|
36
|
+
`501 ${operation.operationId.padEnd(40)} ${request.method} ${path} (${this.#entries.size} missing so far)`,
|
|
37
|
+
entry,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
entries(): NotImplementedEntry[] {
|
|
42
|
+
return [...this.#entries.values()]
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/oembed.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich text editor's "embed" dialog asks the server for a URL's embed
|
|
3
|
+
* markup (Umbraco's `OEmbedService`). These are Umbraco's providers, each asked
|
|
4
|
+
* for JSON; a photo becomes an `<img>`, anything else is the provider's HTML.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface Provider {
|
|
8
|
+
name: string
|
|
9
|
+
endpoint: string
|
|
10
|
+
patterns: RegExp[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const OEMBED_PROVIDERS: readonly Provider[] = [
|
|
14
|
+
{
|
|
15
|
+
name: 'YouTube',
|
|
16
|
+
endpoint: 'https://www.youtube.com/oembed',
|
|
17
|
+
patterns: [
|
|
18
|
+
/^https?:\/\/(www\.)?youtu\.be\//,
|
|
19
|
+
/^https?:\/\/(www\.)?youtube\.com\/watch/,
|
|
20
|
+
/^https?:\/\/(www\.)?youtube\.com\/shorts\//,
|
|
21
|
+
/^https?:\/\/(www\.)?youtube\.com\/live\//,
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'Vimeo',
|
|
26
|
+
endpoint: 'https://vimeo.com/api/oembed.json',
|
|
27
|
+
patterns: [/^https?:\/\/(www\.)?vimeo\.com\//],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'X',
|
|
31
|
+
endpoint: 'https://publish.x.com/oembed',
|
|
32
|
+
patterns: [/^https?:\/\/(www\.)?(twitter|x)\.com\/[^/]+\/status\//],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'DailyMotion',
|
|
36
|
+
endpoint: 'https://www.dailymotion.com/services/oembed',
|
|
37
|
+
patterns: [/^https?:\/\/(www\.)?dailymotion\.com\/video\//],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'Flickr',
|
|
41
|
+
endpoint: 'https://www.flickr.com/services/oembed/',
|
|
42
|
+
patterns: [/^https?:\/\/(www\.)?flickr\.com\/photos\//, /^https?:\/\/flic\.kr\/p\//],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'GettyImages',
|
|
46
|
+
endpoint: 'https://embed.gettyimages.com/oembed',
|
|
47
|
+
patterns: [/^https?:\/\/(www\.)?gty\.im\//, /^https?:\/\/(www\.)?gettyimages\.com\/detail\//],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Giphy',
|
|
51
|
+
endpoint: 'https://giphy.com/services/oembed',
|
|
52
|
+
patterns: [/^https?:\/\/(www\.)?giphy\.com\//, /^https?:\/\/(www\.)?gph\.is\//],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'Hulu',
|
|
56
|
+
endpoint: 'https://www.hulu.com/api/oembed.json',
|
|
57
|
+
patterns: [/^https?:\/\/(www\.)?hulu\.com\/watch\//],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'Issuu',
|
|
61
|
+
endpoint: 'https://issuu.com/oembed',
|
|
62
|
+
patterns: [/^https?:\/\/(www\.)?issuu\.com\/[^/]+\/docs\//],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'Kickstarter',
|
|
66
|
+
endpoint: 'https://www.kickstarter.com/services/oembed',
|
|
67
|
+
patterns: [/^https?:\/\/(www\.)?kickstarter\.com\/projects\//],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'LottieFiles',
|
|
71
|
+
endpoint: 'https://embed.lottiefiles.com/oembed',
|
|
72
|
+
patterns: [/^https?:\/\/(www\.)?lottiefiles\.com\//],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'Slideshare',
|
|
76
|
+
endpoint: 'https://www.slideshare.net/api/oembed/2',
|
|
77
|
+
patterns: [/^https?:\/\/(www\.)?slideshare\.net\//],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'SoundCloud',
|
|
81
|
+
endpoint: 'https://soundcloud.com/oembed',
|
|
82
|
+
patterns: [/^https?:\/\/(www\.)?soundcloud\.com\//],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'Ted',
|
|
86
|
+
endpoint: 'https://www.ted.com/services/v1/oembed.json',
|
|
87
|
+
patterns: [/^https?:\/\/(www\.)?ted\.com\/talks\//],
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
export type OEmbedResult =
|
|
92
|
+
| { ok: true; markup: string }
|
|
93
|
+
| { ok: false; status: 'unsupported' | 'failed'; reason: string }
|
|
94
|
+
|
|
95
|
+
const escapeAttribute = (value: string) =>
|
|
96
|
+
value
|
|
97
|
+
.replaceAll('&', '&')
|
|
98
|
+
.replaceAll('"', '"')
|
|
99
|
+
.replaceAll('<', '<')
|
|
100
|
+
.replaceAll('>', '>')
|
|
101
|
+
|
|
102
|
+
export function createOEmbedService(fetcher: typeof fetch = fetch) {
|
|
103
|
+
return {
|
|
104
|
+
async markup(url: string, maxWidth?: number, maxHeight?: number): Promise<OEmbedResult> {
|
|
105
|
+
const provider = OEMBED_PROVIDERS.find((p) => p.patterns.some((pattern) => pattern.test(url)))
|
|
106
|
+
if (!provider)
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
status: 'unsupported',
|
|
110
|
+
reason: 'No oEmbed provider was found for the specified url.',
|
|
111
|
+
}
|
|
112
|
+
const request = new URL(provider.endpoint)
|
|
113
|
+
request.searchParams.set('url', url)
|
|
114
|
+
request.searchParams.set('format', 'json')
|
|
115
|
+
if (maxWidth) request.searchParams.set('maxwidth', String(maxWidth))
|
|
116
|
+
if (maxHeight) request.searchParams.set('maxheight', String(maxHeight))
|
|
117
|
+
let body: Record<string, unknown>
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetcher(request, { headers: { accept: 'application/json' } })
|
|
120
|
+
if (!response.ok) throw new Error(`${provider.name} answered ${response.status}`)
|
|
121
|
+
body = (await response.json()) as Record<string, unknown>
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 'failed',
|
|
126
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (body.type === 'photo' && typeof body.url === 'string')
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
markup: `<img src="${escapeAttribute(body.url)}" width="${escapeAttribute(String(body.width ?? ''))}" height="${escapeAttribute(String(body.height ?? ''))}" alt="${escapeAttribute(String(body.title ?? ''))}" />`,
|
|
133
|
+
}
|
|
134
|
+
if (typeof body.html === 'string') return { ok: true, markup: body.html }
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: 'failed',
|
|
138
|
+
reason: `${provider.name} returned no embeddable markup.`,
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type OEmbedService = ReturnType<typeof createOEmbedService>
|