This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// The handlers of the branding procedures: each resolves its ports and calls one use case.
|
||||
|
||||
import { type Container, implement, validationFailed } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, OBJECT_STORE } from "@conusai-kit/data"
|
||||
import { brandGet, brandSet, logoClear, logoGet, logoSet } from "../contract/index.js"
|
||||
import { type BrandingDeps, clearLogo, getBrand, readLogo, setBrand, setLogo } from "../core/brand.js"
|
||||
|
||||
export const brandingDeps = (container: Container): BrandingDeps => ({
|
||||
data: container.resolve(DATA_SOURCE),
|
||||
store: container.resolve(OBJECT_STORE),
|
||||
})
|
||||
|
||||
/** Standard base64 to bytes, refusing anything else as a 422 that names the field. */
|
||||
function bytesOf(body: string): Uint8Array {
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) {
|
||||
throw validationFailed([{ path: "body", message: "is not standard base64" }])
|
||||
}
|
||||
return Uint8Array.from(atob(body), (char) => char.charCodeAt(0))
|
||||
}
|
||||
|
||||
const toBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = ""
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export const brandGetImpl = implement(brandGet, async ({ ctx }) =>
|
||||
getBrand(brandingDeps(ctx.container).data, ctx.scope),
|
||||
)
|
||||
|
||||
export const brandSetImpl = implement(brandSet, async ({ input, ctx }) =>
|
||||
setBrand(brandingDeps(ctx.container).data, ctx.scope, {
|
||||
displayName: input.displayName,
|
||||
primary: input.primary,
|
||||
accent: input.accent,
|
||||
}),
|
||||
)
|
||||
|
||||
export const logoSetImpl = implement(logoSet, async ({ input, ctx }) =>
|
||||
setLogo(brandingDeps(ctx.container), ctx.scope, {
|
||||
variant: input.variant,
|
||||
contentType: input.contentType,
|
||||
bytes: bytesOf(input.body),
|
||||
}),
|
||||
)
|
||||
|
||||
export const logoClearImpl = implement(logoClear, async ({ input, ctx }) =>
|
||||
clearLogo(brandingDeps(ctx.container), ctx.scope, input.variant),
|
||||
)
|
||||
|
||||
export const logoGetImpl = implement(logoGet, async ({ input, ctx }) => {
|
||||
const logo = await readLogo(brandingDeps(ctx.container), ctx.scope, input.variant, input.size)
|
||||
return { contentType: logo.contentType, body: toBase64(logo.bytes) }
|
||||
})
|
||||
|
||||
export const BRANDING_PROCEDURES = [brandGetImpl, brandSetImpl, logoSetImpl, logoClearImpl, logoGetImpl]
|
||||
@@ -0,0 +1,151 @@
|
||||
// The brand as a share image and an email show it (spec §10.2, §10.3): the OG image of an app page or an
|
||||
// organization's page, and a transactional email's header. Both draw the same thing, a name and a logo, so
|
||||
// both start from `shareBrand`, which decides them once.
|
||||
//
|
||||
// The name is the organization's display name, else its own name, else the app brand's. The logo is the one
|
||||
// `resolveLogo` picks for the dark page both of them sit on (the organization's own, the organization's
|
||||
// other one when it has only one, or the app brand's), and it goes through the kit's logo checks wherever it
|
||||
// came from: an organization's logo as stored, the app brand's as a file of apps/web/public.
|
||||
//
|
||||
// A logo that cannot be drawn does not stop a card or a mail: the app brand's logo stands in, and the reason
|
||||
// is the kit's, naming a rule and not the file.
|
||||
|
||||
import { tokens } from "@conusai-kit/brand"
|
||||
import { emailHeader, emailHeaderLogo } from "@conusai-kit/brand/email"
|
||||
import type { LogoSource } from "@conusai-kit/brand/logo/check"
|
||||
import { renderOgImage } from "@conusai-kit/brand/og"
|
||||
import { type BrandView, type LogoVariant, resolveLogo } from "../contract/index.js"
|
||||
|
||||
/** What the app brand (config/src/brand.ts) contributes: its name, and the paths of its two logos. */
|
||||
export interface AppIdentity {
|
||||
readonly name: string
|
||||
readonly logo: Readonly<Record<LogoVariant, string>>
|
||||
}
|
||||
|
||||
/** Where the logos are read from: the organization's stored logo, and a file of the app's public folder. */
|
||||
export interface ShareReaders {
|
||||
tenantLogo(variant: LogoVariant): Promise<LogoSource>
|
||||
appLogo(path: string): Promise<LogoSource>
|
||||
}
|
||||
|
||||
export interface ShareBrand {
|
||||
/** The name the card or the mail carries. */
|
||||
readonly name: string
|
||||
/** The logo to draw; an organization's when it has one. */
|
||||
readonly logo: LogoSource
|
||||
/** The app brand's own logo, which stands in when `logo` cannot be drawn. */
|
||||
readonly fallback: LogoSource
|
||||
}
|
||||
|
||||
export async function shareBrand(input: {
|
||||
readonly app: AppIdentity
|
||||
/** The organization the card or mail is for, or null for the app's own. */
|
||||
readonly tenant: { readonly name: string; readonly brand: BrandView } | null
|
||||
readonly readers: ShareReaders
|
||||
/** The page the logo sits on; both a share card and a mail header are dark. */
|
||||
readonly theme?: LogoVariant
|
||||
}): Promise<ShareBrand> {
|
||||
const theme = input.theme ?? "dark"
|
||||
const choice = resolveLogo(input.tenant?.brand ?? null, input.app.logo, theme)
|
||||
const name = input.tenant?.brand.displayName ?? input.tenant?.name ?? input.app.name
|
||||
if (choice.source === "app") {
|
||||
const logo = await input.readers.appLogo(input.app.logo[theme])
|
||||
return { name, logo, fallback: logo }
|
||||
}
|
||||
const logo = await input.readers.tenantLogo(choice.variant)
|
||||
// The app brand's logo only stands in; an organization with a good logo is not stopped by a missing app file.
|
||||
const fallback = await input.readers.appLogo(input.app.logo[theme]).catch(() => logo)
|
||||
return { name, logo, fallback }
|
||||
}
|
||||
|
||||
/** True for the error the kit's renderers throw when the logo they were given is refused. */
|
||||
const isLogoRefusal = (error: unknown): boolean =>
|
||||
error instanceof Error && /the logo cannot be used/.test(error.message)
|
||||
|
||||
/** Tries the chosen logo, then the app brand's; any other failure is not about the logo and is thrown. */
|
||||
async function withLogo<T>(share: ShareBrand, draw: (logo: LogoSource) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await draw(share.logo)
|
||||
} catch (error) {
|
||||
if (share.logo === share.fallback || !isLogoRefusal(error)) throw error
|
||||
return draw(share.fallback)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShareText {
|
||||
readonly title: string
|
||||
readonly subtitle?: string
|
||||
readonly footer?: string
|
||||
}
|
||||
|
||||
/** The share image of a page: 1200x630 PNG in the brand's treatment, the organization's logo if it has one. */
|
||||
export function renderShareImage(share: ShareBrand, text: ShareText): Promise<Uint8Array> {
|
||||
return withLogo(share, (logo) => renderOgImage({ ...text, logo }))
|
||||
}
|
||||
|
||||
export interface EmailContent {
|
||||
readonly subject: string
|
||||
/** The body, one string per paragraph, as plain text: it is escaped, never taken as markup. */
|
||||
readonly paragraphs: readonly string[]
|
||||
readonly locale?: string
|
||||
}
|
||||
|
||||
export interface EmailAttachment {
|
||||
readonly cid: string
|
||||
readonly contentType: "image/png"
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
export interface ComposedEmail {
|
||||
readonly subject: string
|
||||
readonly html: string
|
||||
readonly text: string
|
||||
/** The logo, inline: the HTML refers to it as `cid:`, so the mail names no host. */
|
||||
readonly attachments: readonly EmailAttachment[]
|
||||
}
|
||||
|
||||
/** The port an app implements to send mail (SMTP, a provider's API): this module composes and does not deliver. */
|
||||
export interface MailTransport {
|
||||
send(message: ComposedEmail & { readonly to: string }): Promise<void>
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string): string => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)
|
||||
const LINE_BREAK = /[\r\n]/
|
||||
|
||||
/** A transactional email in the brand: the header with the logo, the paragraphs, and the name at the foot. */
|
||||
export async function composeEmail(share: ShareBrand, content: EmailContent): Promise<ComposedEmail> {
|
||||
if (LINE_BREAK.test(content.subject)) throw new Error("composeEmail: the subject is one line")
|
||||
if (content.paragraphs.length === 0) throw new Error("composeEmail: a mail has at least one paragraph")
|
||||
const logo = await withLogo(share, (source) => emailHeaderLogo(source))
|
||||
const dark = tokens.color.dark
|
||||
const fonts = `'${tokens.typography.families.sans}', ${tokens.typography.fallbacks.sans}`
|
||||
const cell = `padding:24px 32px;font-family:${fonts};color:${dark.foreground};line-height:${tokens.typography.roles.body.lineHeight};`
|
||||
const body = content.paragraphs
|
||||
.map((paragraph) => `<p style="margin:0 0 16px;">${escapeHtml(paragraph)}</p>`)
|
||||
.join("")
|
||||
const html =
|
||||
`<!doctype html><html lang="${escapeHtml(content.locale ?? "en")}"><head><meta charset="utf-8"><meta name="color-scheme" content="dark"></head>` +
|
||||
`<body style="margin:0;background-color:${dark.background};">` +
|
||||
emailHeader({ alt: share.name, aspect: logo.aspect }) +
|
||||
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${dark.background};">` +
|
||||
`<tr><td style="${cell}">${body}</td></tr>` +
|
||||
`<tr><td style="${cell}color:${dark.text70};font-size:14px;">${escapeHtml(share.name)}</td></tr></table></body></html>`
|
||||
return {
|
||||
subject: `${content.subject} - ${share.name}`,
|
||||
html,
|
||||
text: `${content.paragraphs.join("\n\n")}\n\n${share.name}\n`,
|
||||
attachments: [{ cid: logo.cid, contentType: logo.contentType, content: logo.content }],
|
||||
}
|
||||
}
|
||||
|
||||
/** Composes the mail and hands it to the app's transport. `to` is one address and cannot carry a header. */
|
||||
export async function sendEmail(
|
||||
transport: MailTransport,
|
||||
share: ShareBrand,
|
||||
to: string,
|
||||
content: EmailContent,
|
||||
): Promise<void> {
|
||||
if (LINE_BREAK.test(to) || !/^[^\s@,;<>]+@[^\s@,;<>]+$/.test(to))
|
||||
throw new Error("sendEmail: to is one email address")
|
||||
await transport.send({ ...(await composeEmail(share, content)), to })
|
||||
}
|
||||
Reference in New Issue
Block a user